Versioned Business Logic With ActiveRecord

Every succesful application evolves. Business logic is often one of the things that evolves the most, but it is customary to have data which changes over time. Sometimes - over months or years. A lot of spots have logic related to “data over time”. For example: you collect payments from users, but some users were not getting charged VAT. Your new users need to get charged VAT, but they will also pay more, but you want to “grandfather” your existing users into a pricing plan where VAT is included in their pricing, so that the amount they pay does not change.

ActiveRecord, by default, is not very conductive to such changes, but I have recently discovered a very nice pattern for adding versioned logic to models.

Let’s go at the VAT example. Our previous business logic might have looked like this:

class Customer < ApplicationRecord
  def charge!
    monthly_charge = pricing_plan.monthly_price
    ledger_account.debit!(monthly_charge)
    OurCompany.ledger_account.credit!(monthly_charge)
  end
end

Now we are introducing VAT per locality, and we want our pricing to be “net” pricing. On top of that, we want to put VAT that we charge users into a special ledger account, so that VAT returns can be done in an easier way:

class Customer < ApplicationRecord
  def charge!
    monthly_charge = pricing_plan.monthly_price
    vat = monthly_charge * pricing_plan.vat_ratio_for(self)

    ledger_account.debit!(monthly_charge)
    ledger_account.debit!(vat)

    OurCompany.ledger_account.credit!(monthly_charge)
    OurCompany.vat_returns_account.credit!(vat)
  end
end

That is a Customer of a different version - it is not the pricing plan that changes, but the business logic (the way) of how we charge! the customer. There is, in fact, a pattern which is perfect for this sort of thing - the Strategy pattern. Let’s make the charge! logic versionable:

def change
  add_column :customers, :billing_strategy_module_name, :string, default: "BillingStrategyV1"
end

and extract our first strategy (for old customers) into this billing strategy:

module BillingStrategyV1
  def self.charge!(customer)
    monthly_charge = customer.pricing_plan.monthly_price
    customer.ledger_account.debit!(monthly_charge)
    OurCompany.ledger_account.credit!(monthly_charge)
  end
end

module BillingStrategyV2
  def self.charge!(customer)
    monthly_charge = customer.pricing_plan.monthly_price
    vat = monthly_charge * customer.pricing_plan.vat_ratio_for(customer)

    customer.ledger_account.debit!(monthly_charge)
    customer.ledger_account.debit!(vat)

    OurCompany.ledger_account.credit!(monthly_charge)
    OurCompany.vat_returns_account.credit!(vat)
  end
end

class Customer < ApplicationRecord
  def charge!
    billing_strategy_module_name.constantize.charge!(self)
  end
end

Now we can “move” our customers from one version of the business logic to another:

customer.update!(billing_strategy_module_name: "BillingStrategyV2")

Implementing “grandfathering” in this setup becomes trivial:

module BillingStrategyV1B
  def self.charge!(customer)
    monthly_charge = customer.pricing_plan.monthly_price
    vat_ratio = monthly_charge * customer.pricing_plan.vat_ratio_for(customer)
    net_charge = monthly_charge / (1.0 + vat_ratio)
    vat = net_charge * vat_ratio

    customer.ledger_account.debit!(net_charge)
    customer.ledger_account.debit!(vat)

    OurCompany.ledger_account.credit!(net_charge)
    OurCompany.vat_returns_account.credit!(vat)
  end
end

If the methods you used to call inside charge! are public - congratulations, you are done!

Dealing with private methods

An obvious issue here is that this “evolving” business logic often does not start versioned. And the method we want to version might as well be using ActiveRecord internals, for a good reason too. For example, imagine our first version looks like this:

class Customer < ApplicationRecord
  after :update, :update_portfolio_value

  def update_portfolio_value
    total_value_was, total_value_has_become = attribute_was(:total_value), total_value
    delta = total_value_has_become - total_value_was
    self.portfolio_value += delta
  end
end

and our second version (for customers in draconian stock option buyout regimes in the EU) must become:

class Customer < ApplicationRecord
  after :update, :update_portfolio_value

  def update_portfolio_value
    total_value_was, total_value_has_become = attribute_was(:total_value), total_value
    delta = total_value_has_become - total_value_was
    tax = calculate_tax_over_potential_earnings(delta)
    self.portfolio_value += delta
    self.potential_tax_due += tax
  end
end

We could use our approach with the Struct here, were it not so that we are using attribute_was. attribute_was is a private ActiveRecord method - you can use it from the “inside” of the model, but a Struct - even one defined inside the Customer module namespace - won’t be permitted to call it. I won’t discuss the benefits/downsides of module privates and will refer you to my favorite article on the subject instead. But how do we implement it?

We know that we want an object “somewhere” which responds to attribute_was and forwards it to the Customer model. Ideally, we would do this:

module PortfolioStrategyV1
  def self.update_portfolio_value(customer)
    total_value_was, total_value_has_become = some_shim_for_private_methods(customer).attribute_was(:total_value), customer.total_value
    delta = total_value_has_become - total_value_was
    customer.portfolio_value += delta
  end
end

module PortfolioStrategyV2
  def self.update_portfolio_value(customer)
    total_value_was, total_value_has_become = some_shim_for_private_methods(customer).attribute_was(:total_value), customer.total_value
    delta = total_value_has_become - total_value_was
    tax = customer.calculate_tax_over_potential_earnings(delta)
    customer.portfolio_value += delta
    customer.potential_tax_due += tax
  end
end

class Customer < ApplicationRecord
  after :update, :update_portfolio_value

  def update_portfolio_value
    portfolio_value_strategy.constantize.update_portfolio_value(self)
  end
end

Note that in addition to introducing our shim, we would need to replace all instances of self with customer and we are losing “implicit self”, which is a substantial ergonomic advantage. This means that when you extract the update_portfolio_value method you won’t be able to just copy and paste it into your strategy, but you will need to do replacements. And that shim… could we do without?

Delegate, delegate, delegate

Ruby has a neat module for this in its standard library - SimpleDelegator. The delegator wraps an object, and forwards calls it does not override to that object - which allows for very good composition.

class PortfolioStrategyV1 < SimpleDelegator
  def update_portfolio_value
    total_value_was, total_value_has_become = some_shim_for_private_methods(__getobj__).attribute_was(:total_value), customer.total_value
    delta = total_value_has_become - total_value_was
    self.portfolio_value += delta
  end
end

class PortfolioStrategyV2 < SimpleDelegator
  def update_portfolio_value
    total_value_was, total_value_has_become = some_shim_for_private_methods(__getobj__).attribute_was(:total_value), total_value
    delta = total_value_has_become - total_value_was
    tax = calculate_tax_over_potential_earnings(delta)
    self.portfolio_value += delta
    self.potential_tax_due += tax
  end
end

To change our code in to use delegation, we will need to revize our method like so:

class Customer < ApplicationRecord
  after :update, :update_portfolio_value

  def update_portfolio_value
    portfolio_value_strategy.constantize.new(self).update_portfolio_value
  end
end

But there is a catch. We are still having to deal with the some_shim_for_private_methods(__getobj__), which means that once the logic becomes versioned - we will need to edit the method when placing it into a strategy. This is actually a spot where we can use some Ruby magic to allow us access to the private methods inside of our delegator. Become ungovernable and all - but seriously, this is useful. Let’s create an AccursedDelegator (props to Wander for the name):

class AccursedDelegator
  def initialize(model)
    @model = model
  end

  def respond_to_missing?(method_name, including_private_methods)
    # Below comes the only meaningful difference with SimpleDelegator
    # - we allow the `including_private_methods` variable to get passed
    # through to the delegated object
    @model.respond_to?(method_name, including_private_methods)
  end

  def method_missing(method_name, ...)
    @model.send(method_name, ...)
  end
end

Once we have this, our strategies can drop all the indirections and operate “as if” they existed on the Customer model itself:

class PortfolioStrategyV1 < AccursedDelegator
  def update_portfolio_value
    total_value_was, total_value_has_become = attribute_was(:total_value), total_value
    delta = total_value_has_become - total_value_was
    self.portfolio_value += delta
  end
end

class PortfolioStrategyV2 < AccursedDelegator
  def update_portfolio_value
    total_value_was, total_value_has_become = attribute_was(:total_value), total_value
    delta = total_value_has_become - total_value_was
    tax = calculate_tax_over_potential_earnings(delta)
    self.portfolio_value += delta
    self.potential_tax_due += tax
  end
end

Note that thing such as this AccursedDelegator are bound to cause involuntary muscle contractions in developers who discover your code after you. It is a sharp knife, and using it merits an explanation. This is in alignment with my rule for “clever code” in general, which lauds as follows:

❗️ Clever code is almost never necessary, but when it is - your peers deserve an explanation as to why.

Conclusion

With a bit of work it is trivial to have versioned business logic, and Ruby allows us to do so with minimum changes to the logic itself. This is one of the cases where introducing indirection is a near-perfect tool for the job.