• car_rental.rb

  • ¶

    This example is from the perpective of a car rental company. It revolves around the pricing of a car for a given Rental.

    A Rental represents a car being shared for a given period of time.

    Rental = Struct.new(:car, :period, keyword_init: true)
  • ¶

  • ¶

    At the beginning, 2020-04-01, logic is simple. There is one way to compute the price of a Rental. It is the price per day times the number of days.

    class Rental
      def price
        duration * car.price_per_day
      end
    
      def duration
        1 + (period.end_on - period.start_on).to_i
      end
    end
  • ¶

    To show the whole code, here are the Car and Period class. One could easily argue and convice me to put #duration in period rather than in Rental.

    require "date"
    
    Car = Struct.new(:price_per_day, keyword_init: true)
    
    Period = Struct.new(:start_at, :end_at, keyword_init: true) do
      def start_on
        start_at.to_date
      end
    
      def end_on
        end_at.to_date
      end
    end
  • ¶

  • ¶

    Because the Car#price_per_day can change outside of the lifecycle of a Rental, we’ll denormalize it. Without that, the Rental#price could change if the Car#price_per_day is updated.

    car = Car.new(price_per_day: 30)
    rental = Rental.new(
      car: car,
      period: Period.new(
        start_at: Time.utc(2020, 4, 1),
        end_at: Time.utc(2020, 4, 3),
      ),
    )
    
    rental.price # => 90
  • ¶

    That is the situation we want to avoid:

    car.price_per_day = 10
    rental.price # => 30
  • ¶

    Let’s reopen the Rental class and add stuff to avoid it…

    class Rental
      attr_reader :car_price_per_day
    
      def initialize(*)
        super
        @car_price_per_day = car.price_per_day
      end
    
      def price
        duration * car_price_per_day
      end
    end
  • ¶

  • ¶

    A Rental can be updated. It means that its start_on and end_on dates can be updated. The desired side-effect of that is to change the Rental#price. Here I decided to instanciate another Rental, keeping the denormalized price of the car: @car_price_per_day.

    class Rental
      def adjust(new_period)
        dup.tap do |new_rental|
          new_rental.period = new_period
        end
      end
    end
  • ¶

  • ¶

    Starting from 2020-06-01 we’ll introduce a new way to do the pricing. The new feature will be called “discounts”. The longer the rental is the more its price will decrease. For this iteration, the discount, in percentage, will follow this formula:

    discount = min(40, max(0, 2 * duration - 4))

    https://cl.ly/20ef2ddde07a
    class Rental
      def price
        duration * car_price_per_day * (1 - discount)
      end
    
      def discount
        (2 * duration - 4).clamp(0, 40) / 100.0
      end
    end
  • ¶

    That should work. But what about rentals from before 2020-06-01? And what does “a rental from before 2020-06-01“ means? To know which pricing to use, we need to answer that first.

  • ¶

    We consider the time the car was selected by the driver as the reference time to decide which pricing must apply. We can change a bit the Rental to include that information.

    Rental = Struct.new(:car, :period, :selected_at, keyword_init: true)
  • ¶

    From there, we can tweak the discount method to add a conditional:

    class Rental
      DISCOUNT_APPLY_AFTER = Time.utc(2020, 6, 1)
    
      def discount
        if selected_at >= DISCOUNT_APPLY_AFTER
          (2 * duration - 4).clamp(0, 40) / 100.0
        else
          0.0
        end
      end
    end
  • ¶

    If we take a rental that spans on 3 days, we can see the discount is being applied only when the selected_at is after DISCOUNT_APPLY_AFTER.

    price_for_3_days = ->(selected_at) {
      Rental.new(
        car: Car.new(price_per_day: 30),
        period: Period.new(
          start_at: Time.utc(2020, 10, 1, 8),  # 2020-10-01 08:00
          end_at: Time.utc(2020, 10, 3, 12),   # 2020-10-03 12:00
        ),
        selected_at: selected_at,
      ).price.round(2)
    }
    
    price_for_3_days.call(Time.utc(2020, 5, 15))  # => 90.0
    price_for_3_days.call(Time.utc(2020, 6, 15))  # => 88.2 (2% discount)
  • ¶

  • ¶

    This starts to smell bad for the long term already. If requirements continue to pile up like this, the pricing code could become a mess.

    For the sake of it, let’s add another change…

  • ¶

    Starting from 2020-08-01 we would like to price rentals on a per-minute basis. Lets consider a very simplistic version for this. The price per minute will simply be the price per day divided by the number of minutes in a day.

    Looking at the whole Rental class, we now have something pretty big (apologies for the magic-numbers).

    Rental = Struct.new(:car, :period, :selected_at, keyword_init: true) do
      attr_reader :car_price_per_day
    
      DISCOUNT_APPLY_AFTER = Time.utc(2020, 6, 1)
      PRICE_PER_MINUTE_APPLY_AFTER = Time.utc(2020, 8, 1)
    
      def initialize(*)
        super
        @car_price_per_day = car.price_per_day
      end
    
      def price
        base_price =
          if selected_at >= PRICE_PER_MINUTE_APPLY_AFTER
            duration_in_minutes * car_price_per_minute
          else
            duration * car_price_per_day
          end
    
        base_price * (1 - discount)
      end
    
      def discount
        if selected_at >= DISCOUNT_APPLY_AFTER
          (2 * duration - 4).clamp(0, 40) / 100.0
        else
          0.0
        end
      end
    
      def duration
        1 + (period.end_on - period.start_on).to_i
      end
    
      def duration_in_minutes
        (period.end_at.to_i - period.start_at.to_i) / 60.0
      end
    
      def car_price_per_minute
        car_price_per_day / (24.0 * 60.0)
      end
    end
  • ¶

    This will lead to different results that combines each other depending on the selected_at value.

    price_for_3_days.call(Time.utc(2020, 4, 15))  # => 90.0
    price_for_3_days.call(Time.utc(2020, 6, 15))  # => 88.2 (2% discount)
    price_for_3_days.call(Time.utc(2020, 8, 15))  # => 63.7 (2% discount on 52h)
  • ¶

  • ¶

    At that point, extracting that pricing logic into a separate so-called “service object”. Since our Rental class is only doing that, it would only move complexity from a file to another.

  • ¶

    Instead, we could use a layering abstraction and the brule gem.

    require "brule"
  • ¶

    We need to decide on what’s required in the context for all rules to perform correctly. We did that already and we needed:

    • the car’s price per day,
    • the time the car was selected by a driver, and
    • the period the rental spans on.

    We also need to decide on the keys that will stand as the result.

  • ¶

    Now we get Rental#price by using the engine and feeding it the appropriate context.

    class Rental
      def price
        engine = RentalPrice::Engine.new(
          rules: [
            Brule::Utils::Either.new(
              rules: [
                RentalPrice::PricePerMinute.new,
                RentalPrice::PricePerDay.new,
              ],
            ),
            RentalPrice::Discount.new,
          ],
        )
        engine.call(
          car_price_per_day: car_price_per_day,
          selected_at: selected_at,
          period: period,
        )
      end
    end
    
    module RentalPrice
      class Engine < Brule::Engine
        def result
          context.fetch(:price)
        end
      end
  • ¶

    The last details are now in the implementation of the rules themselves.

    We have the same pattern for each rule, and we could create custom Rule subclasses to centralize the common patterns. For instance, each rule has a guard clause then uses #merge! to add or overwrite stuff to context.

      class PricePerDay < Brule::Rule
        APPLY_STRICTLY_BEFORE = Time.utc(2020, 9, 1)
    
        context_reader :selected_at, :period, :car_price_per_day
        context_writer :price
    
        def trigger?
          selected_at < APPLY_STRICTLY_BEFORE
        end
    
        def apply
          self.price = period.duration_in_days * car_price_per_day
        end
      end
    
      class PricePerMinute < Brule::Rule
        APPLY_AFTER = PricePerDayRule::APPLY_STRICTLY_BEFORE
    
        context_reader :selected_at, :period, :car_price_per_day
        context_writer :price
    
        def trigger?
          selected_at >= APPLY_AFTER
        end
    
        def apply
          car_price_per_minute = car_price_per_day / (24.0 * 60.0)
          self.price = period.duration_in_minutes * car_price_per_minute
        end
      end
    
      class Discount < Brule::Rule
        APPLY_AFTER = Time.utc(2020, 6, 1)
    
        context_reader :period
        context_accessor :price, :discount
    
        def trigger?
          selected_at >= APPLY_AFTER
        end
    
        def apply
          self.discount = (2 * period.duration_in_days - 4).clamp(0, 40) / 100.0
          self.price = price * (1 - discount)
        end
      end
    end
  • ¶

    I did migrate the duration in the Period class at that point.

    Period = Struct.new(:start_at, :end_at, keyword_init: true) do
      def duration_in_days
        1 + (end_at.to_date - start_at.to_date).to_i
      end
    
      def duration_in_minutes
        (end_at.to_i - start_at.to_i) / 60.0
      end
    end
  • ¶

    This approach has its flaws. PricePerDay and PricePerMinute aren’t exactly independent from each other. They serve the same purpose: to add :price to the context. And, Discount assume a :price will be in the context at the time it is applied, which make the rules array order-sensitive.

    All those assumptions aren’t great but could be addressed, I guess.

  • ¶

  • ¶

    The idea behind this gem would be to provide simple tools to build engines like this and provide a good excuse to collect use-cases from outside.