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.