One more step to DDD in a legacy rails app
One more step to DDD in a legacy rails app
Recently I picked up a ticket from support team of one of our clients. Few months ago VAT rates have changed in Norway - 5% became 10% and 12% became 15%. It has some implications to platform users — event organizers, since they can choose which VAT rate applies to products which they offer to the ticket buyers. You'll learn why I haven't just updated db column.
Current state of the app
This app is a great example of a legacy software. It's successful, earns a lot of money, but have some areas of code which haven't been cleaned yet. There's a concept of an organization in codebase, which represents the given country market. The organization has an attribute called available_vat_rates
which is simply a serialized attribute, keeping VatRate
value objects. I won't focus on this object here, since its implementation is not a point of this post. It works in a really simple manner:
vat_rate = VatRate.new(15)
# => #<VatRate:0x007fdb8ed50db0 @value=15.0, @code="15">
vat_rate.code
# => "15"
vat_rate.to_d
=> #<BigDecimal:7fdb8ed716c8,'0.15E2',9(27)>
VatRate
objects are Comparable
so you can easily sort them; pretty neat solution.
Event organizer, who creates eg. a ticket, can choose a valid VAT rate applying to his product. Then, after purchase is made, ticket buyer receives the e-mail with a receipt. This has also side-effects in the financial reporting, obviously.
So what's the problem?
I could simply write a migration and add new VAT rates, remove old ones and update events' products which use old rates. However, no domain knowledge would be handed down about when change was made and what kind of change happened. You simply can't get that information from updated_at
column in your database. We have nice domain facts "coverage" around event concept in the application, so we're well informed here. We don't have such knowledge in regard to the Organization.
Start with a plan
I simply started with making a plan of this upgrade.
- I've checked if the change made to
available_vat_rates
will be represented properly in the financial reports. - I've checked how many products were having old VAT rates set.
- I've introduced new domain events called
Organization::VatRatedAdded
andOrganization::VatRateRemoved
which are published to theOrganization$organization_id
stream. - I've run a migration, which was adding new VAT rates (10% & 15%) and publishing sufficient domain facts — let's call it step 1.
- I've performed an upgrade of the VAT rates on the products which required it - step 2.
- I've run a migration, which has removed old VAT rates (5% & 12%) and published domain facts - step 3.
Step 1 - adding new VAT rates
require 'event_store'
class AddNewVatRatesToNoOrgazation < ActiveRecord::Migration
#… minimum viable implementations of used classes
def up
event_store = Rails.application.config.event_store
organization = Organization.find_by(domain: 'me.no')
originator_id = User.find_by(email: 'me@me.no').id
organization.available_vat_rates = [
VatRate.new('NoVat'),
VatRate.new(5), # deprecated one
VatRate.new(10), # new one
VatRate.new(12), # deprecated one
VatRate.new(15), # new one
VatRate.new(25),
]
if organization.save
event_store.publish(Organization::VatRateAdded.new(
organization: organization.id,
vat_rate_code: 10,
originator_id: originator_id
)
event_store.publish(Organization::VatRateAdded.new(
organization: organization.id,
vat_rate_code: 15,
originator_id: originator_id
)
end
end
end
Two things worth notice happen here. Event data contain originator_id
, I simply passed there my user_id
. Just to leave other team members information about person who performed the change in the event store — audit log purpose. The second thing is that I leave old VAT rates still available. Just in case if any event organizer performing changes on his products, to prevent errors and partially migrated state.
Step 2 - migrating affected product data
The amount of products which required change of the VAT rates was so small that I simply used web interface to update them. Normally I would just go with baking EventService
with UpdateTicketTypeCommand
containing all the necessary data.
Step 3 - remove deprecated VAT rates
require 'event_store'
class RemoveOldVatRatesFromNoOrgazation < ActiveRecord::Migration
#… minimum viable implementations of used classes
def up
event_store = Rails.application.config.event_store
organization = Organization.find_by(domain: 'me.no')
originator_id = User.find_by(email: 'me@me.no').id
organization.available_vat_rates = [
VatRate.new('NoVat'),
VatRate.new(10),
VatRate.new(15),
VatRate.new(25),
]
if organization.save
event_store.publish(Organization::VatRateRemoved.new(
organization: organization.id,
vat_rate_code: 5,
originator_id: originator_id
)
event_store.publish(Organization::VatRateRemoved.new(
organization: organization.id,
vat_rate_code: 12,
originator_id: originator_id
)
end
end
end
Summary
All the products on the platform have proper VAT rates set, organization has proper list of available VAT rates. And last, but not least, we know what and when exactly happened, we have better domain understanding, we started publishing events for another bounded context of our app. If you're still not convinced to publishing domain events, please read Andrzej post on that topic or even better, by watching his keynote From Rails legacy to DDD performed on wroc_love.rb.