Skip to content

Commit 8eab02e

Browse files
multitenancy/ai story
1 parent f2e5574 commit 8eab02e

File tree

1 file changed

+270
-0
lines changed

1 file changed

+270
-0
lines changed
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
---
2+
created_at: 2025-12-30 12:00:00 +0100
3+
publish: true
4+
author: Andrzej Krzywda
5+
tags: [ 'ddd', 'rails', 'multitenancy' ]
6+
---
7+
8+
# Adding multi-tenancy to a DDD Rails app
9+
10+
Many businesses when they set out to create some software they need, don't know that one day, they might need multi-tenancy.
11+
12+
This is one of the features, that is not easy for programmers to add later easily. It might take months or even years.
13+
14+
Let me describe how I approached this in an ecommerce app. Essentially, the idea is to allow to create multiple stores, where previously it was one (or actually lack of any, all resources were global).
15+
16+
<!-- more -->
17+
18+
## The ecommerce project
19+
20+
The project I am talking about is called [ecommerce](https://github.com/RailsEventStore/ecommerce) and it is part of the RailsEventStore (RES) organization on github. It started as a sample application for RES but over the last 10 years it grew to some kind of utopian Rails/DDD/CQRS/Events project.
21+
22+
This project does run on [production](https://ecommerce.arkademy.dev), but it's not really a production project. It's more of a visionary/educational project to show a Rails codebase that can be highly modular in a DDD fashion.
23+
24+
In this project, there was no concept of a Store. All the main resources were global, as in `Order.all` etc.
25+
26+
Similarly, the events didn't have any data or metadata that would point to a specific store.
27+
28+
```ruby
29+
class OrderConfirmed < Infra::Event
30+
attribute :order_id, Infra::Types::UUID
31+
end
32+
```
33+
34+
Another idea related to this whole project is that those events (and wider - domains/bounded contexts) are generic in their nature. As such they can be used in other apps without changes.
35+
36+
Just to prove this point that the domains can be reused, another rails app ([pricing_catalog_rails_app](https://github.com/RailsEventStore/ecommerce/tree/master/apps/pricing_catalog_rails_app)) exists which requires (as gems) the existing bounded contexts.
37+
38+
## The obvious solution - add store_id to all commands/events
39+
40+
When I talked to people, how they would approach adding multi-tenancy here this idea repeated. You need to extend existing events and commands with store_id.
41+
42+
I don't like such invasive approaches by default.
43+
44+
Also, the domains reusability aspect - while still working, would be less elegant. Some event data would exist but never used in other apps (which don't need multi-tenancy).
45+
46+
It's definitely a concept that would work - so if you don't have such abstract needs as reusability, this may be the way to go.
47+
48+
## The different schema approach
49+
50+
Another obvious solution is to use some database concepts. Create a new schema per tenant or a new db.
51+
52+
I also excluded this from my choices - I didn't want to solve this at the infra level. It also wasn't clear to me, how would I operate on some cross-store reports which are often required for such Shopify-like platforms.
53+
54+
## What exactly is multi-tenancy?
55+
56+
For some time, I didn't have an alternative solution either. I was contemplating what it means to be multi-tenant, tried to split into smaller concepts.
57+
58+
### Filtering data
59+
60+
We need to filter data. When a specific store is shown, we are displaying only the products of this store. In my architecture, that's a read model job. Definitely, my read models would need to be extended by store_id concept. I was OK with that, even though it was an invasive change. In my book, read models are application specific and if such a big application requirement comes, the read models need to adjust.
61+
62+
### Authorization
63+
64+
We need to authorize access to data.
65+
66+
This is where good old Rails controllers come handy. We need some concept of current_store and then pass the store_id to the read models.
67+
68+
### Admin panel
69+
70+
We need some admin panel, where stores can be created, deleted and listed.
71+
In my case that's a new read model but also a new "namespace/route" in the Rails app.
72+
73+
### CQRS - write
74+
75+
So far, we have discussed the reads parts.
76+
In our CQRS split between reads and writes - how do we handle writes?
77+
78+
We have commands like this:
79+
80+
```ruby
81+
module ProductCatalog
82+
class RegisterProduct < Infra::Command
83+
attribute :product_id, Infra::Types::UUID
84+
end
85+
end
86+
```
87+
88+
Similarly as with events, the commands are part of the BCs and shouldn't need to change.
89+
90+
Still, we need some way of saying that this Product is registered within a store. There's no way around it.
91+
92+
How can we do it, without changing the existing command definitions?
93+
94+
What I wanted was a solution that:
95+
- doesn’t change existing BC APIs
96+
- keeps domains reusable
97+
- keeps multi-tenancy domain and app level, not infra
98+
99+
With these constraints in mind, the solution I arrived at looks almost obvious in hindsight.
100+
101+
## My solution
102+
103+
I'm still polishing the edges here, but overall my attempt seems to work.
104+
105+
The main idea is to create a new Bounded Context - `Stores`. This is the home for a new kind of events. The events are tiny (I like them this way) and they are just registering the main resources within the Store.
106+
107+
So, they look like this:
108+
109+
```ruby
110+
111+
class ProductRegistered < Infra::Event
112+
attribute :store_id, Infra::Types::UUID
113+
attribute :product_id, Infra::Types::UUID
114+
end
115+
116+
class CustomerRegistered < Infra::Event
117+
attribute :store_id, Infra::Types::UUID
118+
attribute :customer_id, Infra::Types::UUID
119+
end
120+
121+
class OfferRegistered < Infra::Event
122+
attribute :store_id, Infra::Types::UUID
123+
attribute :offer_id, Infra::Types::UUID
124+
end
125+
```
126+
127+
There's not really much logic around it, though. I did solve the problem of making this change non-invasive, but I do admit, the concept of such repetitive events is not super convincing either.
128+
129+
So, yeah, that's the drawback.
130+
131+
But there are more things that I like here.
132+
133+
First of all, None of other BCs had to change in any way. Maybe one of the existing process managers had to change to include Store registration.
134+
135+
Plenty of read models had to change, but that was expected. They all need to subscribe to the one new event. They persist the store_id and they know how to filter.
136+
137+
```ruby
138+
def call(event_store)
139+
event_store.subscribe(DraftOrder.new, to: [Pricing::OfferDrafted])
140+
event_store.subscribe(AssignStoreToOrder.new, to: [Stores::OfferRegistered])
141+
```
142+
143+
```ruby
144+
module Orders
145+
class AssignStoreToOrder
146+
def call(event)
147+
Order.
148+
find_by!(uid: event.data.fetch(:order_id)).
149+
update!(store_id: event.data.fetch(:store_id))
150+
end
151+
end
152+
end
153+
```
154+
155+
In the controllers, we now need to filter and authorize data:
156+
157+
```ruby
158+
class InvoicesController < ApplicationController
159+
def show
160+
@invoice = Invoices.find_invoice_in_store(params[:id], current_store_id)
161+
not_found unless @invoice
162+
end
163+
```
164+
165+
Also, in the controller, when we "create" new resources, we issue two commands, one for the original BC, the other one for Stores BC:
166+
167+
```ruby
168+
class CouponsController < ApplicationController
169+
def create
170+
coupon_id = params[:coupon_id]
171+
172+
ActiveRecord::Base.transaction do
173+
create_coupon(coupon_id)
174+
end
175+
rescue Pricing::Coupon::AlreadyRegistered
176+
flash[:notice] = "Coupon is already registered"
177+
render "new"
178+
else
179+
redirect_to coupons_path, notice: "Coupon was successfully created"
180+
end
181+
182+
private
183+
184+
def create_coupon(coupon_id)
185+
command_bus.(
186+
Pricing::RegisterCoupon.new(
187+
coupon_id: coupon_id,
188+
name: params[:name],
189+
code: params[:code],
190+
discount: params[:discount]
191+
)
192+
)
193+
command_bus.(
194+
Stores::RegisterCoupon.new(
195+
coupon_id: coupon_id,
196+
store_id: current_store_id
197+
)
198+
)
199+
end
200+
201+
end
202+
```
203+
204+
This coupling is intentional and happens only at the application boundary, not inside BCs.
205+
206+
It was also a nice opportunity to revise all the 16 existing read models and make some long-needed cleanups too.
207+
208+
## How Claude Code helped me here
209+
210+
Hard to admit, but I wrote maybe 10% of the code changes in this whole implementation of multi-tenancy.
211+
212+
I assisted Claude in the original read model change - `Orders`. Then, I was shocked how well Claude worked with all other places. It knew the patterns and just repeated them.
213+
214+
It wouldn't work, though, if not for the mutation test coverage.
215+
216+
Honestly, in all cases, when I followed the reasoning and the steps made by the AI, there were tiny hallucinations or tiny weird solutions, or tiny commenting out code.
217+
218+
Which all was caught by [mutant](https://github.com/mbj/mutant), the main quality guard I have here against AI.
219+
220+
If not for mutant, I'd have to be more in control and the constant code reviews would drive me crazy. With mutant in place I was much more confident - and faster!
221+
222+
I'm gonna write more about this AI experience in other blogposts. This story is already a bit weird - starting from DDD, via events, to multi-tenancy, to read models, to Claude Code. Thanks for bearing with me here.
223+
224+
AI was crucial here, though. If not for AI, I'd hate myself by the 3rd of the 16 read models with the boring repetitive work.
225+
226+
If not for mutant, I'd hate myself for verifying AI in all 16 modules.
227+
228+
**To be honest, I don't know how people work with agents without mutation testing coverage.**
229+
230+
Without mutant it would be like working with juniors with short attention span. Actually, agents are now senior level sometimes, but it's seniors with dementia.
231+
232+
Here is an example mutant output, when AI worked on the `Shipments` read model. It ran mutant by itself, as it knows (CLAUDE.md) that it is required.
233+
234+
```
235+
progress: 440/447 alive: 0 runtime: 34.21s killtime: 123.81s mutations/s: 12.86
236+
Mutant environment:
237+
Usage: opensource
238+
Matcher: #<Mutant::Matcher::Config ignore: [] subjects: [Shipments*]>
239+
Integration: minitest
240+
Jobs: 4
241+
Includes: ["test"]
242+
Requires: ["./config/environment"]
243+
Operators: light
244+
MutationTimeout: 10
245+
Subjects: 11
246+
All-Tests: 359
247+
Available-Tests: 359
248+
Selected-Tests: 19
249+
Tests/Subject: 1.73 avg
250+
Mutations: 447
251+
Results: 447
252+
Kills: 447
253+
Alive: 0
254+
Timeouts: 0
255+
Runtime: 34.73s
256+
Killtime: 126.85s
257+
Efficiency: 365.22%
258+
Mutations/s: 12.87
259+
Coverage: 100.00%
260+
```
261+
262+
This was a successful run, but often it would catch its own hallucinations and thanks to mutant it fixed itself.
263+
264+
## Summary
265+
266+
To summarize - it's still too early to evaluate the solution, I still need to finish some reviews. But it does seem promising to me and I'm happy with the outcome, despite the drawbacks.
267+
268+
The key lesson for me wasn’t multi-tenancy itself, but that strong architectural foundation + mutation testing make large-scale AI-assisted refactors possible.
269+
270+

0 commit comments

Comments
 (0)