Arkency Ecommerce follows the CQRS pattern and creates separate modules for "reads".
We call them read models.
The simplest read models consist of:
- one or more database table
- some configuration code which maps events to columns in the table
Today, I was about to create a read model. A public offer of products presented to the client.
We already have a Products
read model, used on the admin/sales panel.
At first they seem to be almost the same and it's tempting to just reuse it.
This is an often temptation in CQRS. This temptation is obvious in CRUD systems. There's one way of retrieving products, how complex can it be, right?
However, even in our simple feature set it's already visible that they contain different columns.
They serve different needs.
What am I really saving by reusing it?
The only risk is if we start having some calculations to show price (very likely, but is it part of read models?), then we can forget about some of them.
Anyway.
I looked at the current read model:
module Products
class Product < ApplicationRecord
self.table_name = "products"
end
class Configuration
def call(cqrs)
cqrs.subscribe(
-> (event) { register_product(event) },
[ProductCatalog::ProductRegistered]
)
cqrs.subscribe(
->(event) { change_stock_level(event) },
[Inventory::StockLevelChanged]
)
cqrs.subscribe(
-> (event) { set_price(event) },
[Pricing::PriceSet])
cqrs.subscribe(
-> (event) { set_vat_rate(event) },
[Taxes::VatRateSet])
end
private
def register_product(event)
Product.create(id: event.data.fetch(:product_id), name: event.data.fetch(:name))
end
def set_price(event)
find(event.data.fetch(:product_id)).update_attribute(:price, event.data.fetch(:price))
end
def set_vat_rate(event)
find(event.data.fetch(:product_id)).update_attribute(:vat_rate_code, event.data.fetch(:vat_rate).fetch(:code))
end
def change_stock_level(event)
find(event.data.fetch(:product_id)).update_attribute(:stock_level, event.data.fetch(:stock_level))
end
def find(product_id)
Product.where(id: product_id).first
end
end
end
I'm not about you, but it screams to me:
THIS CODE IS MOSTLY DATA
It's basically mapping declarations, with some subtleties. All on top of ActiveRecord.
Just to show you the db schema:
create_table "products", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "name"
t.decimal "price", precision: 8, scale: 2
t.integer "stock_level"
t.datetime "registered_at", precision: nil
t.string "vat_rate_code"
end
(and I'm adding to my TODO to check if the registered_at
column is somehow magically used or it can be deleted)
Then I looked at how it's tested.
While we maintain almost 100% mutation coverage at the domain level, we don't share a similar quality at the app level (controllers, read models).
I noticed that this read model doesn't have unit tests. It's tested only via integration test, if anything. That's sad. I need to improve on this.
But then a second thought.
If I make some mechanical refactorings (the ones which can be done without full test coverage, then this code will become mostly declarations or some kind of DSL API.
If it really happens, then I should the resulting "framework" which will be extracted instead of the usages.
The issue with testing declarative code is that it's usually a "typotest", repeating what's in the production code.
Long story short, this is my first attempt to make the code more declarative:
module Products
class Product < ApplicationRecord
self.table_name = "products"
end
class Configuration
def initialize(cqrs)
@cqrs = cqrs
end
def call
@cqrs.subscribe(-> (event) { register_product(event) }, [ProductCatalog::ProductRegistered])
copy(Inventory::StockLevelChanged, :stock_level)
copy(Pricing::PriceSet, :price)
copy_nested_to_column(Taxes::VatRateSet, :vat_rate, :code, :vat_rate_code)
end
While those 2 lines are quite elegant to me:
copy(Inventory::StockLevelChanged, :stock_level)
copy(Pricing::PriceSet, :price)
They look nice, because they (by accident) follow the convention of matching the event attribute with the column name.
The other lines still need some love.
First, creating a record here requires a name, it's not just empty constructor. It was hard for me to find some generalization of this code.
The last line was the edge case. It happens to have nested data in the event. Also it doesn't match the column name.
copy_nested_to_column(Taxes::VatRateSet, :vat_rate, :code, :vat_rate_code)
and here is the remaining supporting code to make it all work:
private
def copy(event, attribute)
@cqrs.subscribe(-> (event) { copy_event_attribute_to_column(event, attribute, attribute) }, [event])
end
def copy_nested_to_column(event, top_event_attribute, nested_attribute, column)
@cqrs.subscribe(
-> (event) { copy_nested_event_attribute_to_column(event, top_event_attribute, nested_attribute, column) }, [event])
end
def register_product(event)
Product.create(id: event.data.fetch(:product_id), name: event.data.fetch(:name))
end
def copy_event_attribute_to_column(event, event_attribute, column)
product(event).update_attribute(column, event.data.fetch(event_attribute))
end
def copy_nested_event_attribute_to_column(event, top_event_attribute, nested_attribute, column)
product(event).update_attribute(column, event.data.fetch(top_event_attribute).fetch(nested_attribute))
end
def product(event)
find(event.data.fetch(:product_id))
end
def find(product_id)
Product.where(id: product_id).first
end
This code should still become more generic.
We need to un-hardcode the ActiveRecord class name Product
.
And just to remind you. My initial goal was to create a new read model. What I'm doing here is a preparatory refactoring. After this, I expect my new read model to be implemented in only few lines of declarations.
We will see how it goes :)
Here is the commit with those changes:
https://github.com/RailsEventStore/ecommerce/commit/9e42950fc7eb34257938b0501fa6e50733d0d568
Thanks for reading ❤️
Top comments (0)