DEV Community

Andrzej Krzywda
Andrzej Krzywda

Posted on

Exploring a generic read model API

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

Enter fullscreen mode Exit fullscreen mode

I'm not about you, but it screams to me:

THIS CODE IS MOSTLY DATA
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

(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
Enter fullscreen mode Exit fullscreen mode

While those 2 lines are quite elegant to me:

copy(Inventory::StockLevelChanged, :stock_level)
copy(Pricing::PriceSet,            :price)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)