Skip to content

Commit f5dc3a0

Browse files
Allow renaming customers
Customer names appear across multiple read models (Customers, OrderHeader, Deals, ClientOrders). The rename propagates to all of them so the new name is visible on every page. Also adds /new-feature skill for upfront impact analysis and updates /read-model skill with denormalization rules.
1 parent 65f3121 commit f5dc3a0

18 files changed

Lines changed: 245 additions & 3 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
---
2+
name: new-feature
3+
description: Plan a new feature end-to-end — impact analysis across all layers before delegating to /domain, /read-model, /controller skills
4+
---
5+
6+
# New Feature
7+
8+
## When to use
9+
10+
Use this skill when asked to add a new user-facing feature. It ensures you think through **all affected layers upfront** before writing any code.
11+
12+
## What this skill does
13+
14+
This skill is a **planning and coordination** step. It does NOT contain implementation details — those live in `/domain`, `/read-model`, and `/controller`. This skill ensures you:
15+
16+
1. Identify everything that needs to change
17+
2. Write a failing integration test that covers all affected surfaces
18+
3. Then delegate implementation to the appropriate skills
19+
20+
## Step-by-step process
21+
22+
### 1. Impact analysis
23+
24+
Before writing any code, search for all consumers of the affected entity's data:
25+
26+
```bash
27+
# Find all read models that subscribe to events from the same entity
28+
grep -r "DomainModule::RelatedEvent" apps/rails_application/app/read_models/ -l
29+
30+
# Find all places that store the affected attribute
31+
grep -r "affected_attribute" apps/rails_application/app/read_models/
32+
33+
# Find all views that display it
34+
grep -r "affected_attribute" apps/rails_application/app/views/
35+
```
36+
37+
**For each read model that stores the affected data, ask:**
38+
1. Does it subscribe to the creation event for this entity?
39+
2. Does it store or denormalize the attribute being changed?
40+
3. If yes to both: it needs a handler for the new event.
41+
42+
**Common patterns requiring multi-read-model updates:**
43+
- **Renaming** — entity names denormalized into other read models
44+
- **Price changes** — prices snapshotted in order/deal read models
45+
- **Status changes** — displayed across list views in different read models
46+
- **Reassignment** — entity associations denormalized in multiple places
47+
48+
### 2. Produce a plan
49+
50+
List every change needed:
51+
- **Domain:** new command, event, aggregate method, handler
52+
- **Read models:** which read models need new event handlers (list each one)
53+
- **Controller:** which actions change, new routes
54+
- **Views:** which templates change
55+
56+
Present this plan to the user before proceeding.
57+
58+
### 3. Write the integration test first
59+
60+
The integration test must verify the feature works **across all affected UI surfaces**, not just the primary one.
61+
62+
Create enough related data (orders, deals, etc.) so that secondary pages also display the affected data. Then assert the change is visible on **every page that shows it**.
63+
64+
### 4. Delegate implementation
65+
66+
Use the existing skills:
67+
- `/domain` — for commands, events, aggregates, handlers
68+
- `/read-model` — for read model event handlers and configuration
69+
- `/controller` — for controller actions, routes, views
70+
71+
### 5. Verify
72+
73+
1. Integration test passes
74+
2. `rails test test/integration/` — all integration tests pass
75+
3. `make test` — all tests green
76+
4. Run mutant for affected namespaces
77+
78+
## Checklist
79+
80+
- [ ] Searched all read models for the affected entity's data
81+
- [ ] Searched all views for display of the affected data
82+
- [ ] Listed every read model that needs a new handler
83+
- [ ] Integration test covers all affected pages
84+
- [ ] Denormalized copies of data are updated (not just the primary table)

.claude/skills/read-model/SKILL.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,29 @@ Some older read models still use one class per event type in separate files. Whe
228228
- No local variables, prefer method calls
229229
- Extract shared `find_*` methods as private helpers for reusability
230230

231+
### 5a. Denormalization rules
232+
233+
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.
234+
235+
- **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`)
236+
- **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
237+
- If an existing table is missing the ID column, add a migration to include it
238+
239+
**Bad — matching by old name:**
240+
```ruby
241+
when Crm::CustomerRenamed
242+
old_name = customer.name
243+
customer.update!(name: event.data.fetch(:name))
244+
Deal.where(customer_name: old_name).update_all(customer_name: event.data.fetch(:name))
245+
```
246+
247+
**Good — matching by ID:**
248+
```ruby
249+
when Crm::CustomerRenamed
250+
Customer.find_by!(customer_id: event.data.fetch(:customer_id)).update!(name: event.data.fetch(:name))
251+
Deal.where(customer_id: event.data.fetch(:customer_id)).update_all(customer_name: event.data.fetch(:name))
252+
```
253+
231254
### 6. Facade methods
232255

233256
- Only create facade methods that are **actually called by controllers or views**

apps/rails_application/app/controllers/customers_controller.rb

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,15 @@ def create
2020

2121
def update
2222
customer = Customers.find_customer_in_store(params[:id], current_store_id)
23-
promote_to_vip(customer.id)
23+
if params[:name].present?
24+
rename_customer(customer.id, params[:name])
25+
redirect_to customer_path(customer), notice: "Customer was renamed"
26+
else
27+
promote_to_vip(customer.id)
28+
redirect_to customers_path, notice: "Customer was promoted to VIP"
29+
end
2430
rescue Crm::Customer::AlreadyVip
2531
redirect_to customers_path, notice: "Customer was marked as vip"
26-
else
27-
redirect_to customers_path, notice: "Customer was promoted to VIP"
2832
end
2933

3034
def show
@@ -41,6 +45,10 @@ def create_customer(customer_id, name)
4145
command_bus.(register_customer_in_store_cmd(customer_id))
4246
end
4347

48+
def rename_customer(customer_id, name)
49+
command_bus.(rename_customer_cmd(customer_id, name))
50+
end
51+
4452
def promote_to_vip(customer_id)
4553
command_bus.(promote_to_vip_cmd(customer_id))
4654
end
@@ -53,6 +61,10 @@ def register_customer_in_store_cmd(customer_id)
5361
Stores::RegisterCustomer.new(customer_id: customer_id, store_id: current_store_id)
5462
end
5563

64+
def rename_customer_cmd(customer_id, name)
65+
Crm::RenameCustomer.new(customer_id: customer_id, name: name)
66+
end
67+
5668
def promote_to_vip_cmd(customer_id)
5769
Crm::PromoteCustomerToVip.new(customer_id: customer_id)
5870
end

apps/rails_application/app/read_models/client_orders/configuration.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def call(event_store)
4949
event_store.subscribe(RemoveItemFromOrder, to: [Pricing::PriceItemRemoved])
5050

5151
event_store.subscribe(CreateCustomer.new, to: [Crm::CustomerRegistered])
52+
event_store.subscribe(RenameCustomer.new, to: [Crm::CustomerRenamed])
5253
event_store.subscribe(OrderHandlers::AssignCustomerToOrder, to: [Crm::CustomerAssignedToOrder])
5354

5455
event_store.subscribe(ProductHandlers::ChangeProductName, to: [ProductCatalog::ProductNamed])
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
module ClientOrders
2+
class RenameCustomer
3+
def call(event)
4+
Client.find_by(uid: event.data.fetch(:customer_id)).update!(name: event.data.fetch(:name))
5+
end
6+
end
7+
end

apps/rails_application/app/read_models/customers/configuration.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ def self.find_customer_in_store(customer_id, store_id)
1616
class Configuration
1717
def call(event_store)
1818
event_store.subscribe(RegisterCustomer.new, to: [Crm::CustomerRegistered])
19+
event_store.subscribe(RenameCustomer.new, to: [Crm::CustomerRenamed])
1920
event_store.subscribe(AssignStoreToCustomer.new, to: [Stores::CustomerRegistered])
2021
event_store.subscribe(PromoteToVip.new, to: [Crm::CustomerPromotedToVip])
2122
event_store.subscribe(UpdatePaidOrdersSummary.new, to: [Fulfillment::OrderConfirmed])
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
module Customers
2+
class RenameCustomer
3+
def call(event)
4+
Customer.find_by(id: event.data.fetch(:customer_id)).update(name: event.data.fetch(:name))
5+
end
6+
end
7+
end

apps/rails_application/app/read_models/deals/configuration.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ def call(event)
2424
find_deal(event).update!(store_id: event.data.fetch(:store_id))
2525
when Crm::CustomerRegistered
2626
Customer.find_or_create_by!(customer_id: event.data.fetch(:customer_id)).update!(name: event.data.fetch(:name))
27+
when Crm::CustomerRenamed
28+
customer = Customer.find_by!(customer_id: event.data.fetch(:customer_id))
29+
old_name = customer.name
30+
customer.update!(name: event.data.fetch(:name))
31+
Deal.where(customer_name: old_name).update_all(customer_name: event.data.fetch(:name))
2732
when Crm::CustomerAssignedToOrder
2833
find_deal(event).update!(customer_name: find_customer(event).name)
2934
when Processes::TotalOrderValueUpdated
@@ -56,6 +61,7 @@ def call(event_store)
5661
Pricing::OfferDrafted,
5762
Stores::OfferRegistered,
5863
Crm::CustomerRegistered,
64+
Crm::CustomerRenamed,
5965
Crm::CustomerAssignedToOrder,
6066
Processes::TotalOrderValueUpdated,
6167
Fulfillment::OrderRegistered,

apps/rails_application/app/read_models/order_header/configuration.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def self.draft_orders(store_id)
2222
class Configuration
2323
def call(event_store)
2424
event_store.subscribe(CreateCustomer.new, to: [Crm::CustomerRegistered])
25+
event_store.subscribe(RenameCustomer.new, to: [Crm::CustomerRenamed])
2526
event_store.subscribe(AssignStoreToOrderHeader.new, to: [Stores::OfferRegistered])
2627
event_store.subscribe(
2728
->(event) { draft_order_header(event) },
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
module OrderHeader
2+
class RenameCustomer
3+
def call(event)
4+
customer = Customer.find_by(customer_id: event.data.fetch(:customer_id))
5+
old_name = customer.name
6+
customer.update!(name: event.data.fetch(:name))
7+
Header.where(customer: old_name).update_all(customer: event.data.fetch(:name))
8+
end
9+
end
10+
end

0 commit comments

Comments
 (0)