en un clic
read-model
// Build a new read model following project conventions (event handlers, tests, migration, configuration)
// Build a new read model following project conventions (event handlers, tests, migration, configuration)
| name | read-model |
| description | Build a new read model following project conventions (event handlers, tests, migration, configuration) |
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/.
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.
Before writing any code, clarify:
Wishlist, Notifications)Catalog::ProductAdded, Ordering::OrderPlaced)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:
InMemoryTestCasecover "ModuleName*" for mutation testingrails_application, override configure to load only the read model's own configuration:
def configure(event_store, _command_bus)
ModuleName::Configuration.new.call(event_store)
end
todo_mvc), the full Configuration is loaded in before_setup — no override needed if the app only has a few read modelsevent_store.publish(event) to trigger handlersassert_equal(expected, actual) with parentheses alwaysStores::*Registered events for store assignment, Crm::CustomerRegistered before customer assignment, etc.create_record, register_customer) to express realistic event sequencesfind_by → Model.update! mutationsEcommerce::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# 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.
Everything goes in one file: app/read_models/{module_name}/configuration.rb. It contains:
private_constantThree patterns exist in the codebase:
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
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
Some older read models still use one class per event type in separate files. When modifying these, prefer consolidating into Pattern A.
event.data.fetch(:key), never event.data[:key] or event[:key]case event — no separate files per event typefind_by! for record lookups — records must exist because events follow the real application flow (e.g., OfferDrafted always comes before OrderRegistered)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 skipreturn unless record guards — use find_by! insteadfind_* methods as private helpers for reusabilityWhen 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.
customer_id) to the table that stores denormalized data, not just the display value (e.g., customer_name)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))
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).
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.
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 scorereturn unless guards — always use find_by!, never find_by with &.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.private_constant for ActiveRecord classescase event routing — all in configuration.rbPlan a new feature end-to-end — impact analysis across all layers before delegating to /domain, /read-model, /controller skills
Create atomic git commits following project conventions
Upgrade Rails framework to a newer version following the smooth upgrade methodology
Upgrade RailsEventStore (RES) gems to a newer version
Create a new domain bounded context with aggregates, commands, events, and handlers
Scaffold a new Rails app with event sourcing, following project conventions (todomvc as reference)