Exploring a generic read model API
Tags: ruby, programming, webdev, devjournal
Published: 2022-08-15 23:57:29

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:

```ruby
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:

```ruby
  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:

```ruby
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:

```ruby
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.

```ruby
copy_nested_to_column(Taxes::VatRateSet, :vat_rate, :code, :vat_rate_code)
```

and here is the remaining supporting code to make it all work:

```ruby
    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](https://github.com/RailsEventStore/ecommerce/commit/9e42950fc7eb34257938b0501fa6e50733d0d568)

Thanks for reading ❤️