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 — always ask the user for their preferred name before creating any files. Read-model naming is a product/domain decision the user cares about (e.g. Feed vs PublicFeed, Timeline vs HomeTimeline), and renaming later touches the directory, module, test, cover/.mutant.yml entries, controller references and Configuration wiring. Propose one or two candidate names with a one-line rationale (what UI view / query it serves, and how it contrasts with sibling read models), then let the user confirm or override. Do not derive the name silently from the events or the feature description.
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).
In rails_application, override configure to load only the read model's own configuration:
defconfigure(event_store, _command_bus)
ModuleName::Configuration.new.call(event_store)
end
In other apps (e.g. todo_mvc), the full Configuration is loaded in before_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::*Registered events for store assignment, Crm::CustomerRegistered before 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::Configuration or Processes::Configuration to pass, that's a smell — the test is probably using run_command instead of publishing events directly, or the read model depends on another read model
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), never event.data[:key] or event[: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., OfferDrafted always comes before OrderRegistered)
Never use find_by with &. 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 record guards — use find_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
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:
defenable_{module_name}_read_model(event_store)
ModuleName::Configuration.new.call(event_store)
end
For Pattern B:
defenable_{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 model
rails test test/integration/ - integration tests still pass
make test - all tests green
RAILS_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 unless guards — always use find_by!, never find_by with &.
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., EntityName with entity_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_constant for 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 event routing — 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