name: read-model description: Build a new read model following project conventions (event handlers, tests, migration, configuration)
Read Model Builder
When to use
Use this skill when asked to create a new read model or add event handlers to an existing read model in any Rails application under apps/.
Working Directory
Determine which app the read model belongs to. Default is apps/rails_application/ unless the user specifies another app (e.g. apps/crm/, apps/todo_mvc/). All paths below are relative to the target app directory.
Step-by-step process
1. Gather requirements
Before writing any code, clarify:
- The module name for the read model (e.g.
Wishlist,Notifications) - Which domain events it will subscribe to (e.g.
Catalog::ProductAdded,Ordering::OrderPlaced) - What data needs to be stored and queried
- What facade methods the rest of the app needs — only add facade methods that are actually used by controllers/views, not speculative ones
2. Write tests first (TDD)
Create a single test file at test/{module_name}/{module_name}_test.rb (relative to the app directory).
Test file conventions:
# test/{module_name}/{module_name}_test.rb
require "test_helper"
module ModuleName
class ModuleNameTest < InMemoryTestCase
cover "ModuleName*"
def test_record_created
create_record(record_id)
assert_equal(1, ModuleName.facade_method(store_id).count)
end
def test_record_updated
create_record(record_id)
create_record(other_record_id)
update_record(record_id, "new value")
result = ModuleName.facade_method(store_id).find_by!(uid: record_id)
assert_equal("new value", result.attribute)
assert_nil(ModuleName.facade_method(store_id).find_by!(uid: other_record_id).attribute)
end
private
def event_store
Rails.configuration.event_store
end
def record_id
@record_id ||= SecureRandom.uuid
end
def other_record_id
@other_record_id ||= SecureRandom.uuid
end
def store_id
@store_id ||= SecureRandom.uuid
end
def create_record(rid, sid = store_id)
event_store.publish(DomainContext::RecordCreated.new(data: { record_id: rid }))
event_store.publish(Stores::RecordRegistered.new(data: { record_id: rid, store_id: sid }))
end
def update_record(rid, value)
event_store.publish(DomainContext::RecordUpdated.new(data: { record_id: rid, value: value }))
end
end
end
Test rules:
- Inherit from
InMemoryTestCase - Use
cover "ModuleName*"for mutation testing - In
rails_application, overrideconfigureto load only the read model's own configuration:def configure(event_store, _command_bus) ModuleName::Configuration.new.call(event_store) end - In other apps (e.g.
todo_mvc), the fullConfigurationis loaded inbefore_setup— no override needed if the app only has a few read models - Test via
event_store.publish(event)to trigger handlers - Assert using facade methods, never access ActiveRecord directly
- Use
assert_equal(expected, actual)with parentheses always - Single test file per read model — keep all handler tests together
- No comments in tests
- Event flows must reflect the real application flow — include
Stores::*Registeredevents for store assignment,Crm::CustomerRegisteredbefore customer assignment, etc. - Use helper methods (e.g.
create_record,register_customer) to express realistic event sequences - Always test with multiple records to kill
find_by→Model.update!mutations - If a read model test needs
Ecommerce::ConfigurationorProcesses::Configurationto pass, that's a smell — the test is probably usingrun_commandinstead of publishing events directly, or the read model depends on another read model
3. Create the database migration
# db/migrate/YYYYMMDDHHMMSS_create_{table_name}.rb
class CreateTableName < ActiveRecord::Migration[8.0]
def change
create_table :{table_name} do |t|
t.uuid :some_uuid_column
t.string :name
t.decimal :amount
t.timestamps
end
end
end
Run rails db:migrate after creating.
4. Create the read model module
Everything goes in one file: app/read_models/{module_name}/configuration.rb. It contains:
- ActiveRecord model class(es) with
private_constant - Module-level facade methods (only those used by controllers/views)
- EventHandler class with all event handling logic
- Configuration class that wires event subscriptions
Three patterns exist in the codebase:
Pattern A: EventHandler with case/when (preferred for custom logic)
Use when events require different handling logic. All handlers go in a single EventHandler class using case event.
# app/read_models/{module_name}/configuration.rb
module ModuleName
class Record < ApplicationRecord
self.table_name = "table_name"
end
private_constant :Record
def self.facade_method(store_id)
Record.where(store_id: store_id)
end
class EventHandler
def call(event)
case event
when DomainContext::RecordCreated
Record.create!(uid: event.data.fetch(:record_id))
when Stores::RecordRegistered
find_record(event).update!(store_id: event.data.fetch(:store_id))
when DomainContext::RecordUpdated
find_record(event).update!(attribute: event.data.fetch(:attribute))
end
end
private
def find_record(event)
Record.find_by!(uid: event.data.fetch(:record_id))
end
end
class Configuration
def call(event_store)
event_store.subscribe(EventHandler.new, to: [
DomainContext::RecordCreated,
Stores::RecordRegistered,
DomainContext::RecordUpdated
])
end
end
end
Pattern B: SingleTableReadModel (event_store passed to initialize)
Use when the read model is a simple projection that copies event attributes to a single table.
# app/read_models/{module_name}/configuration.rb
module ModuleName
class Record < ApplicationRecord
self.table_name = "table_name"
end
private_constant :Record
def self.facade_method(id)
Record.where(some_column: id)
end
class Configuration
def initialize(event_store)
@read_model = SingleTableReadModel.new(event_store, Record, :record_id)
@event_store = event_store
end
def call
@read_model.subscribe_create(DomainContext::RecordCreated)
@read_model.subscribe_copy(DomainContext::NameSet, :name)
@read_model.subscribe_copy(DomainContext::PriceSet, :price)
end
end
end
Pattern C: Separate handler classes (legacy)
Some older read models still use one class per event type in separate files. When modifying these, prefer consolidating into Pattern A.
5. EventHandler rules
- Always use
event.data.fetch(:key), neverevent.data[:key]orevent[:key] - Single EventHandler class with
case event— no separate files per event type - Always use
find_by!for record lookups — records must exist because events follow the real application flow (e.g.,OfferDraftedalways comes beforeOrderRegistered) - Never use
find_bywith&.safe navigation — this hides bugs. If a record is missing, it means the test or event flow is wrong, not that the handler should silently skip - No
return unless recordguards — usefind_by!instead - No comments
- No named params in method calls unless required
- No local variables, prefer method calls
- Extract shared
find_*methods as private helpers for reusability
5a. Denormalization rules
When a read model copies data from one entity into another table (e.g., customer name into an order header), always store the entity's ID alongside the denormalized value. This allows updates by ID rather than by name or other mutable attributes.
- Always add an ID column (e.g.,
customer_id) to the table that stores denormalized data, not just the display value (e.g.,customer_name) - Update by ID, not by value — when handling rename/update events, find records to update using the entity ID, never by matching the old string value. Matching by string is fragile: two entities with the same name would both get updated incorrectly
- If an existing table is missing the ID column, add a migration to include it
Bad — matching by old name:
when Crm::CustomerRenamed
old_name = customer.name
customer.update!(name: event.data.fetch(:name))
Deal.where(customer_name: old_name).update_all(customer_name: event.data.fetch(:name))
Good — matching by ID:
when Crm::CustomerRenamed
Customer.find_by!(customer_id: event.data.fetch(:customer_id)).update!(name: event.data.fetch(:name))
Deal.where(customer_id: event.data.fetch(:customer_id)).update_all(customer_name: event.data.fetch(:name))
6. Facade methods
- Only create facade methods that are actually called by controllers or views
- Do not create speculative facade methods "in case they might be useful"
- If a facade method is no longer used, remove it
7. Register in lib/configuration.rb
Add the read model to lib/configuration.rb:
For Pattern A:
def enable_{module_name}_read_model(event_store)
ModuleName::Configuration.new.call(event_store)
end
For Pattern B:
def enable_{module_name}_read_model(event_store)
ModuleName::Configuration.new(event_store).call
end
Call the method from def call(event_store, command_bus).
8. Add to mutation testing
Add the module to the app's .mutant.yml under matcher.subjects:
matcher:
subjects:
- ModuleName*
Add ModuleName::Configuration#call and ModuleName::Rendering::* to matcher.ignore.
9. Run verification
Run in this order:
rails test test/{module_name}/- unit tests for the new read modelrails test test/integration/- integration tests still passmake test- all tests greenRAILS_ENV=test bundle exec mutant run "ModuleName*"- 100% mutation score
Key conventions
- No comments in code or tests
- No local variables - prefer method calls
- No named params unless required
- No
return unlessguards — always usefind_by!, neverfind_bywith&. - Read models must not access other read models — if a read model needs data owned by another (e.g., entity names for activity descriptions), subscribe to the same domain events and maintain an internal lookup table (e.g.,
EntityNamewithentity_uid+name). This keeps the read model self-contained. Another approach worth considering is a SummaryEvent — an event built from other events that carries all the necessary data, so the read model handler receives everything it needs in a single event without any lookups. - Use
private_constantfor ActiveRecord classes - Facade methods only when used by controllers/views
- Use uuid type in migrations for UUID columns
- Single EventHandler class per read model with
case eventrouting — all in configuration.rb - Single test file per read model
- Test event flows must reflect real application flows — include store registration events, customer registration, etc.
- All calls are synchronous - no async/concurrency concerns
- 100% mutation score required
- Test-first TDD - write tests before implementation