tcb 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -1
- data/README.md +290 -133
- data/lib/generators/tcb/domain/domain_generator.rb +1 -1
- data/lib/generators/tcb/event_store/event_store_generator.rb +1 -1
- data/lib/generators/tcb/event_store/templates/migration.rb.tt +11 -7
- data/lib/generators/tcb/install/install_generator.rb +1 -1
- data/lib/generators/tcb/install/templates/tcb.rb.tt +12 -10
- data/lib/tcb/command_bus.rb +5 -1
- data/lib/tcb/configuration.rb +13 -7
- data/lib/tcb/correlation_query.rb +32 -0
- data/lib/tcb/envelope.rb +31 -0
- data/lib/tcb/event_bus/queue_pressure_monitor.rb +35 -0
- data/lib/tcb/event_bus/running_strategy.rb +32 -2
- data/lib/tcb/event_bus/shutdown_strategy.rb +4 -0
- data/lib/tcb/event_bus.rb +40 -38
- data/lib/tcb/event_bus_queue_pressure.rb +10 -0
- data/lib/tcb/event_store/active_record.rb +69 -19
- data/lib/tcb/event_store/in_memory.rb +17 -7
- data/lib/tcb/publish.rb +7 -4
- data/lib/tcb/record.rb +47 -11
- data/lib/tcb/test_helpers/shared.rb +2 -2
- data/lib/tcb/version.rb +1 -1
- data/lib/tcb.rb +44 -11
- metadata +6 -3
- data/lib/tcb/event_store/event_stream_envelope.rb +0 -13
data/README.md
CHANGED
|
@@ -44,45 +44,117 @@ end
|
|
|
44
44
|
bus.publish(UserRegistered.new(id: 1, email: "alice@example.com"))
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
###
|
|
47
|
+
### Execution model
|
|
48
|
+
|
|
49
|
+
TCB::EventBus uses a single background thread to process events. Publishing is non-blocking — the event is placed on a queue and control returns to the caller immediately. The dispatcher thread processes events in FIFO order. Handlers for a given event execute sequentially within the dispatcher thread.
|
|
50
|
+
|
|
51
|
+
This design favors determinism and simplicity: events are always processed in the order they were published, and handlers cannot race with each other.
|
|
52
|
+
|
|
53
|
+
For tests and simple use cases, `sync: true` executes handlers in the caller thread immediately — no background thread, no queue:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
bus = TCB::EventBus.new(sync: true)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Delivery guarantees
|
|
60
|
+
|
|
61
|
+
TCB guarantees:
|
|
62
|
+
- Events published to the bus will be dispatched to all registered handlers, in the order they were published, as long as the process remains alive.
|
|
63
|
+
- If `TCB.record` is used before `TCB.publish`, events are persisted to the event store before any handler runs.
|
|
64
|
+
|
|
65
|
+
TCB does not guarantee:
|
|
66
|
+
- That published events will be processed if the process crashes after publish but before handlers complete.
|
|
67
|
+
- At-least-once, at-most-once, or exactly-once delivery.
|
|
68
|
+
- Retry on handler failure. Failed handlers emit `TCB::SubscriberInvocationFailed`. Retry is the responsibility of the application.
|
|
69
|
+
|
|
70
|
+
If stronger delivery guarantees are required, use a durable external queue (SolidQueue, Sidekiq, etc.) and trigger TCB handlers from jobs.
|
|
71
|
+
|
|
72
|
+
### Backpressure
|
|
73
|
+
|
|
74
|
+
The event queue is unbounded by default. If handlers are slower than the rate of publishing, the queue will grow without limit. For production systems under sustained load, set `max_queue_size:` to apply backpressure:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
TCB::EventBus.new(max_queue_size: 10_000)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
When the queue is full, `publish` blocks until space is available. The right value depends on your event volume and handler latency — measure before deciding.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## TCB::HandlesEvents
|
|
48
85
|
|
|
49
86
|
Instead of block handlers, reactions can be declared as classes inside a module. This keeps handlers close to the domain and readable at a glance.
|
|
50
87
|
|
|
51
88
|
```ruby
|
|
52
|
-
|
|
53
|
-
|
|
89
|
+
# app/domain/warehouse.rb
|
|
90
|
+
module Warehouse
|
|
91
|
+
include TCB::Domain
|
|
92
|
+
|
|
93
|
+
StockReserved = Data.define(:order_id)
|
|
94
|
+
|
|
95
|
+
persist events(
|
|
96
|
+
StockReserved,
|
|
97
|
+
stream_id_from_event: :order_id
|
|
98
|
+
)
|
|
54
99
|
|
|
55
|
-
on
|
|
56
|
-
on Orders::OrderPlaced, react_with(SendOrderConfirmation)
|
|
100
|
+
on Sales::OrderPlaced, react_with(ReserveStock)
|
|
57
101
|
end
|
|
58
102
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
103
|
+
# app/domain/warehouse/reserve_stock.rb
|
|
104
|
+
module Warehouse
|
|
105
|
+
class ReserveStock
|
|
106
|
+
def call(event)
|
|
107
|
+
stock = Stock.new(id: event.order_id)
|
|
108
|
+
|
|
109
|
+
events = TCB.record(events_from: [stock], within: ApplicationRecord) do
|
|
110
|
+
stock.reserve
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
TCB.publish(*events)
|
|
114
|
+
end
|
|
62
115
|
end
|
|
63
116
|
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
# app/domain/notifications.rb
|
|
121
|
+
module Notifications
|
|
122
|
+
include TCB::HandlesEvents
|
|
64
123
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
124
|
+
on Warehouse::StockReserved, react_with(NotifyCustomer)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# app/domain/notifications/notify_customer.rb
|
|
128
|
+
module Notifications
|
|
129
|
+
class NotifyCustomer
|
|
130
|
+
def call(event)
|
|
131
|
+
events = TCB.record(events: [CustomerNotified.new(order_id: event.order_id)])
|
|
132
|
+
TCB.publish(*events)
|
|
133
|
+
end
|
|
68
134
|
end
|
|
135
|
+
|
|
136
|
+
CustomerNotified = Data.define(:order_id)
|
|
69
137
|
end
|
|
70
138
|
```
|
|
71
139
|
|
|
72
|
-
Event classes can come from anywhere.
|
|
140
|
+
Event classes can come from anywhere. Cross-module reactions are the norm, not the exception. Each handler is isolated. Ine failure does not prevent others from executing.
|
|
73
141
|
|
|
74
|
-
|
|
142
|
+
Domain modules are declared once at the top level, before infrastructure is configured.
|
|
143
|
+
This is the only place TCB needs to know about your bounded contexts — all reactions,
|
|
144
|
+
persistence rules, and handler mappings live inside each module itself.
|
|
75
145
|
|
|
76
146
|
```ruby
|
|
147
|
+
TCB.domain_modules = [Sales, Warehouse, Notifications]
|
|
148
|
+
|
|
77
149
|
TCB.configure do |c|
|
|
78
|
-
c.event_bus
|
|
79
|
-
c.
|
|
150
|
+
c.event_bus = TCB::EventBus.new
|
|
151
|
+
c.event_store = TCB::EventStore::ActiveRecord.new
|
|
80
152
|
end
|
|
81
|
-
|
|
82
|
-
TCB.publish(Auth::UserRegistered.new(id: 1, email: "alice@example.com"))
|
|
83
153
|
```
|
|
84
154
|
|
|
85
|
-
|
|
155
|
+
`TCB.domain_modules=` wires up subscriptions and command routing from all modules.
|
|
156
|
+
`TCB.configure` provides the infrastructure they run on. The two are intentionally
|
|
157
|
+
separate — domain modules don't change between environments, infrastructure does.
|
|
86
158
|
|
|
87
159
|
---
|
|
88
160
|
|
|
@@ -114,9 +186,9 @@ module Orders
|
|
|
114
186
|
handle PlaceOrder, with(PlaceOrderHandler)
|
|
115
187
|
end
|
|
116
188
|
|
|
189
|
+
TCB.domain_modules = [ Orders ]
|
|
117
190
|
TCB.configure do |c|
|
|
118
|
-
c.event_bus
|
|
119
|
-
c.domain_modules = [Orders]
|
|
191
|
+
c.event_bus = TCB::EventBus.new
|
|
120
192
|
end
|
|
121
193
|
|
|
122
194
|
TCB.dispatch(PlaceOrder.new(order_id: 42, customer: "Alice"))
|
|
@@ -136,12 +208,16 @@ Keep domain code together. TCB convention is a single `app/domain` folder. Beyon
|
|
|
136
208
|
|
|
137
209
|
```
|
|
138
210
|
app/domain/
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
order.rb
|
|
211
|
+
sales.rb # domain module, public interface
|
|
212
|
+
sales/
|
|
213
|
+
order.rb # aggregate
|
|
142
214
|
place_order_handler.rb
|
|
143
|
-
|
|
144
|
-
|
|
215
|
+
warehouse.rb
|
|
216
|
+
warehouse/
|
|
217
|
+
reserve_stock.rb
|
|
218
|
+
notifications.rb
|
|
219
|
+
notifications/
|
|
220
|
+
notify_customer.rb
|
|
145
221
|
```
|
|
146
222
|
|
|
147
223
|
### The domain module
|
|
@@ -149,50 +225,36 @@ app/domain/
|
|
|
149
225
|
The domain module is a boundary. Everything inside speaks the domain language. Nothing leaks out, nothing bleeds in. Keep everything that belongs together, together. Events, commands, persistence rules, and reactions are all declared in one place:
|
|
150
226
|
|
|
151
227
|
```ruby
|
|
152
|
-
# app/domain/
|
|
153
|
-
module
|
|
228
|
+
# app/domain/sales.rb
|
|
229
|
+
module Sales
|
|
154
230
|
include TCB::Domain
|
|
155
231
|
|
|
232
|
+
# Facade
|
|
233
|
+
def self.place!(order_id:, customer:)
|
|
234
|
+
TCB.dispatch(PlaceOrder.new(order_id: order_id, customer: customer))
|
|
235
|
+
end
|
|
236
|
+
|
|
156
237
|
# Events
|
|
157
|
-
OrderPlaced
|
|
158
|
-
OrderCancelled = Data.define(:order_id, :reason)
|
|
238
|
+
OrderPlaced = Data.define(:order_id, :customer)
|
|
159
239
|
|
|
160
240
|
# Commands
|
|
161
|
-
PlaceOrder
|
|
241
|
+
PlaceOrder = Data.define(:order_id, :customer) do
|
|
162
242
|
def validate!
|
|
163
243
|
raise ArgumentError, "customer required" if customer.nil?
|
|
164
244
|
end
|
|
165
245
|
end
|
|
166
246
|
|
|
167
|
-
CancelOrder = Data.define(:order_id, :reason) do
|
|
168
|
-
def validate!
|
|
169
|
-
raise ArgumentError, "reason required" if reason.nil?
|
|
170
|
-
end
|
|
171
|
-
end
|
|
172
|
-
|
|
173
247
|
# Persistence
|
|
174
248
|
persist events(
|
|
175
249
|
OrderPlaced,
|
|
176
|
-
OrderCancelled,
|
|
177
250
|
stream_id_from_event: :order_id
|
|
178
251
|
)
|
|
179
252
|
|
|
180
253
|
# Commands
|
|
181
|
-
handle PlaceOrder,
|
|
182
|
-
handle CancelOrder, with(CancelOrderHandler)
|
|
254
|
+
handle PlaceOrder, with(PlaceOrderHandler)
|
|
183
255
|
|
|
184
256
|
# Reactions
|
|
185
|
-
on OrderPlaced,
|
|
186
|
-
on OrderCancelled, react_with(RefundPayment)
|
|
187
|
-
|
|
188
|
-
# Facade
|
|
189
|
-
def self.place!(order_id:, customer:)
|
|
190
|
-
TCB.dispatch(PlaceOrder.new(order_id: order_id, customer: customer))
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
def self.cancel!(order_id:, reason:)
|
|
194
|
-
TCB.dispatch(CancelOrder.new(order_id: order_id, reason: reason))
|
|
195
|
-
end
|
|
257
|
+
on OrderPlaced, react_with(Warehouse::ReserveStock)
|
|
196
258
|
end
|
|
197
259
|
```
|
|
198
260
|
|
|
@@ -201,8 +263,7 @@ end
|
|
|
201
263
|
The facade is the public contract. Callers get plain method calls with meaningful names. TCB stays out of sight:
|
|
202
264
|
|
|
203
265
|
```ruby
|
|
204
|
-
|
|
205
|
-
Orders.cancel!(order_id: 42, reason: "changed mind")
|
|
266
|
+
Sales.place!(order_id: 42, customer: "Alice")
|
|
206
267
|
```
|
|
207
268
|
|
|
208
269
|
Facade methods use the bang convention. `TCB.dispatch` calls `validate!` on the command before routing it to the handler and raises if validation fails. Naming facade methods with `!` signals to callers that exceptions are expected.
|
|
@@ -214,8 +275,8 @@ An aggregate is the consistency boundary around your domain state. It decides wh
|
|
|
214
275
|
The `TCB.record` block is the transactional boundary. Pass one aggregate or many. Either everything is persisted as it should be, or nothing is. The domain stays in a valid state.
|
|
215
276
|
|
|
216
277
|
```ruby
|
|
217
|
-
# app/domain/
|
|
218
|
-
module
|
|
278
|
+
# app/domain/sales/order.rb
|
|
279
|
+
module Sales
|
|
219
280
|
class Order
|
|
220
281
|
include TCB::RecordsEvents
|
|
221
282
|
|
|
@@ -226,10 +287,6 @@ module Orders
|
|
|
226
287
|
def place(customer:)
|
|
227
288
|
record OrderPlaced.new(order_id: id, customer: customer)
|
|
228
289
|
end
|
|
229
|
-
|
|
230
|
-
def cancel(reason:)
|
|
231
|
-
record OrderCancelled.new(order_id: id, reason: reason)
|
|
232
|
-
end
|
|
233
290
|
end
|
|
234
291
|
end
|
|
235
292
|
```
|
|
@@ -238,17 +295,13 @@ end
|
|
|
238
295
|
|
|
239
296
|
The command handler is the entry point into the domain. This is where you ensure the domain stays in a valid state before announcing anything to the rest of the system. Persist first, publish after.
|
|
240
297
|
|
|
241
|
-
#### With aggregate
|
|
242
|
-
|
|
243
298
|
```ruby
|
|
244
|
-
# app/domain/
|
|
245
|
-
module
|
|
299
|
+
# app/domain/sales/place_order_handler.rb
|
|
300
|
+
module Sales
|
|
246
301
|
class PlaceOrderHandler
|
|
247
302
|
def call(command)
|
|
248
303
|
order = Order.new(id: command.order_id)
|
|
249
304
|
|
|
250
|
-
# within: ApplicationRecord wraps the block in a database transaction.
|
|
251
|
-
# If anything raises, no events are persisted and none are published.
|
|
252
305
|
events = TCB.record(events_from: [order], within: ApplicationRecord) do
|
|
253
306
|
order.place(customer: command.customer)
|
|
254
307
|
end
|
|
@@ -315,25 +368,73 @@ end
|
|
|
315
368
|
|
|
316
369
|
## Configuration
|
|
317
370
|
|
|
318
|
-
|
|
371
|
+
### Domain modules
|
|
319
372
|
|
|
320
|
-
|
|
321
|
-
|---|---|
|
|
322
|
-
| `Orders` | `orders_events` |
|
|
323
|
-
| `Payments` | `payments_events` |
|
|
324
|
-
| `Payments::Charges` | `payments_charges_events` |
|
|
373
|
+
Domain modules are the bounded contexts of your application. Declare them once, at the top level — before infrastructure is configured:
|
|
325
374
|
|
|
326
375
|
```ruby
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
376
|
+
# config/initializers/tcb.rb
|
|
377
|
+
TCB.domain_modules = [Orders, Notifications]
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
This is the only place TCB needs to know about your domain modules. All reactions, persistence rules, and handler mappings are declared inside each module itself.
|
|
381
|
+
|
|
382
|
+
### Infrastructure
|
|
383
|
+
|
|
384
|
+
Infrastructure is environment-specific. Configure it in each environment file so the differences are explicit and co-located with other environment settings:
|
|
385
|
+
|
|
386
|
+
```ruby
|
|
387
|
+
# config/environments/development.rb
|
|
388
|
+
Rails.application.config.to_prepare do
|
|
389
|
+
TCB.reset!
|
|
390
|
+
TCB.configure do |c|
|
|
391
|
+
c.event_bus = TCB::EventBus.new(
|
|
392
|
+
handle_signals: false, # Rails manages process signals in development
|
|
393
|
+
shutdown_timeout: 10.0
|
|
394
|
+
)
|
|
395
|
+
c.event_store = TCB::EventStore::ActiveRecord.new
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
`to_prepare` runs after every Rails reload. `TCB.reset!` shuts down the previous bus before configuring a new one. Without it, each reload would leak a dispatcher thread.
|
|
401
|
+
|
|
402
|
+
```ruby
|
|
403
|
+
# config/environments/production.rb
|
|
404
|
+
Rails.application.config.to_prepare do
|
|
405
|
+
TCB.reset!(graceful_shutdown_time: 10.0)
|
|
406
|
+
TCB.configure do |c|
|
|
407
|
+
c.event_bus = TCB::EventBus.new(
|
|
408
|
+
handle_signals: true,
|
|
409
|
+
shutdown_timeout: 30.0
|
|
410
|
+
)
|
|
411
|
+
c.event_store = TCB::EventStore::ActiveRecord.new
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
```
|
|
331
415
|
|
|
332
|
-
|
|
333
|
-
|
|
416
|
+
`handle_signals: true` installs SIGTERM/SIGINT handlers for graceful shutdown. `graceful_shutdown_time` on `reset!` gives the previous bus time to drain before replacing it.
|
|
417
|
+
|
|
418
|
+
```ruby
|
|
419
|
+
# config/environments/test.rb
|
|
420
|
+
Rails.application.config.after_initialize do
|
|
421
|
+
TCB.configure do |c|
|
|
422
|
+
c.event_bus = TCB::EventBus.new(sync: true)
|
|
423
|
+
c.event_store = TCB::EventStore::InMemory.new
|
|
424
|
+
end
|
|
334
425
|
end
|
|
335
426
|
```
|
|
336
427
|
|
|
428
|
+
`sync: true` executes handlers in the caller thread — no background thread, no polling. `after_initialize` runs once at boot. Between tests, call `TCB.reset!` to get a fresh bus and store.
|
|
429
|
+
|
|
430
|
+
Each domain module gets its own database table. Domains stay isolated at the persistence level:
|
|
431
|
+
|
|
432
|
+
| Module | Table |
|
|
433
|
+
|---|---|
|
|
434
|
+
| `Orders` | `orders_events` |
|
|
435
|
+
| `Payments` | `payments_events` |
|
|
436
|
+
| `Payments::Charges` | `payments_charges_events` |
|
|
437
|
+
|
|
337
438
|
---
|
|
338
439
|
|
|
339
440
|
## Reading Events
|
|
@@ -369,13 +470,67 @@ end
|
|
|
369
470
|
Each result is a `TCB::Envelope`:
|
|
370
471
|
|
|
371
472
|
```ruby
|
|
372
|
-
envelope.event
|
|
373
|
-
envelope.event_id
|
|
374
|
-
envelope.stream_id
|
|
375
|
-
envelope.version
|
|
376
|
-
envelope.occurred_at
|
|
473
|
+
envelope.event # the domain event
|
|
474
|
+
envelope.event_id # UUID string
|
|
475
|
+
envelope.stream_id # "context|aggregate_id"
|
|
476
|
+
envelope.version # integer, sequential per stream
|
|
477
|
+
envelope.occurred_at # Time
|
|
478
|
+
envelope.correlation_id # UUID string, shared across all events in a dispatch chain
|
|
479
|
+
envelope.causation_id # UUID string, event_id of the triggering event; nil for the first event
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
---
|
|
483
|
+
|
|
484
|
+
## Correlation and causation tracking
|
|
485
|
+
|
|
486
|
+
Every `TCB.dispatch` generates a `correlation_id` and returns it to the caller. All events produced within that dispatch chain share the same `correlation_id`, regardless of how deep the reactive chain goes. `causation_id` identifies the direct cause — the `event_id` of the envelope that triggered the handler.
|
|
487
|
+
|
|
488
|
+
```
|
|
489
|
+
Sales.place!(order_id: 42, customer: "Alice")
|
|
490
|
+
└─ Sales::OrderPlaced correlation_id: "req-abc", causation_id: nil
|
|
491
|
+
└─ Warehouse::StockReserved correlation_id: "req-abc", causation_id: OrderPlaced.event_id
|
|
492
|
+
└─ Notifications::CustomerNotified correlation_id: "req-abc", causation_id: StockReserved.event_id
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
`correlation_id` can be provided externally. That's useful for tying a dispatch to an incoming HTTP request:
|
|
496
|
+
|
|
497
|
+
```ruby
|
|
498
|
+
correlation_id = TCB.dispatch(
|
|
499
|
+
Sales::PlaceOrder.new(order_id: 42, customer: "Alice"),
|
|
500
|
+
correlation_id: request.uuid
|
|
501
|
+
)
|
|
502
|
+
response.set_header("X-Correlation-ID", correlation_id)
|
|
377
503
|
```
|
|
378
504
|
|
|
505
|
+
`correlation_id` and `causation_id` are set by `TCB.record`, not by `TCB.publish`. A handler that calls `TCB.record` within a reactive chain will always have both fields populated. A handler that only calls `TCB.publish` without `TCB.record` produces envelopes without these fields. There is no event store context to propagate from.
|
|
506
|
+
|
|
507
|
+
Handler interfaces are unchanged. `def call(event)` receives the domain event as always. Correlation and causation travel in the envelope, not in your domain code.
|
|
508
|
+
|
|
509
|
+
Events recorded outside a dispatch context (directly via `TCB.record` without a preceding `TCB.dispatch`) have `nil` for both fields. This is expected: there is no dispatch to correlate them to.
|
|
510
|
+
|
|
511
|
+
---
|
|
512
|
+
|
|
513
|
+
## Reading a correlation chain
|
|
514
|
+
|
|
515
|
+
To query all events across domains that share a `correlation_id`:
|
|
516
|
+
|
|
517
|
+
```ruby
|
|
518
|
+
# All domains with persistence
|
|
519
|
+
TCB.read_correlation("req-abc").to_a
|
|
520
|
+
|
|
521
|
+
# Specific domains
|
|
522
|
+
TCB.read_correlation("req-abc", across: [Sales, Warehouse, Notifications]).to_a
|
|
523
|
+
|
|
524
|
+
# With time filters
|
|
525
|
+
TCB.read_correlation("req-abc").occurred_after(1.hour.ago).to_a
|
|
526
|
+
TCB.read_correlation("req-abc").occurred_before(Time.now).to_a
|
|
527
|
+
TCB.read_correlation("req-abc").between(1.hour.ago, Time.now).to_a
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
Results are ordered by `occurred_at` across all domains. Each result is a `TCB::Envelope` with `correlation_id` and `causation_id` populated.
|
|
531
|
+
|
|
532
|
+
`across:` defaults to all configured domain modules that have persistence registrations. Domains without persistence — like `Notifications` in the example above — are excluded automatically.
|
|
533
|
+
|
|
379
534
|
---
|
|
380
535
|
|
|
381
536
|
## Event Store Adapters
|
|
@@ -395,7 +550,7 @@ TCB::EventStore::ActiveRecord.new
|
|
|
395
550
|
Generate migration and AR model:
|
|
396
551
|
|
|
397
552
|
```
|
|
398
|
-
bin/rails generate
|
|
553
|
+
bin/rails generate tcb:event_store orders
|
|
399
554
|
```
|
|
400
555
|
|
|
401
556
|
---
|
|
@@ -407,7 +562,7 @@ TCB includes generators to scaffold domain modules, command handlers, and migrat
|
|
|
407
562
|
### Install
|
|
408
563
|
|
|
409
564
|
```bash
|
|
410
|
-
rails generate
|
|
565
|
+
rails generate tcb:install
|
|
411
566
|
```
|
|
412
567
|
|
|
413
568
|
Creates `config/initializers/tcb.rb` with a minimal configuration template.
|
|
@@ -415,7 +570,7 @@ Creates `config/initializers/tcb.rb` with a minimal configuration template.
|
|
|
415
570
|
### Domain module with event store
|
|
416
571
|
|
|
417
572
|
```bash
|
|
418
|
-
rails generate
|
|
573
|
+
rails generate tcb:event_store orders place_order:order_id,customer cancel_order:order_id,reason
|
|
419
574
|
```
|
|
420
575
|
|
|
421
576
|
Generates:
|
|
@@ -427,7 +582,7 @@ Generates:
|
|
|
427
582
|
### Domain module without persistence (pub/sub only)
|
|
428
583
|
|
|
429
584
|
```bash
|
|
430
|
-
rails generate
|
|
585
|
+
rails generate tcb:domain notifications send_welcome_email:user_id,email send_verification_sms:user_id,phone
|
|
431
586
|
```
|
|
432
587
|
|
|
433
588
|
Generates:
|
|
@@ -443,12 +598,14 @@ Generates:
|
|
|
443
598
|
| `--skip-migration` | Skip migration (event_store only) |
|
|
444
599
|
| `--no-comments` | Generate without inline guidance comments |
|
|
445
600
|
|
|
446
|
-
After generating, add your module to
|
|
601
|
+
After generating, add your module to config/initializers/tcb.rb. Domain modules don't change between environments — infrastructure does. Keeping them separate means your bounded contexts are declared once, while the bus and store are configured per environment:
|
|
447
602
|
|
|
448
603
|
```ruby
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
604
|
+
# config/initializers/tcb.rb
|
|
605
|
+
TCB.domain_modules = [
|
|
606
|
+
Sales,
|
|
607
|
+
Warehouse,
|
|
608
|
+
Notifications
|
|
452
609
|
]
|
|
453
610
|
```
|
|
454
611
|
|
|
@@ -484,89 +641,89 @@ bus.force_shutdown
|
|
|
484
641
|
|
|
485
642
|
## Testing
|
|
486
643
|
|
|
487
|
-
|
|
644
|
+
### Setup
|
|
488
645
|
|
|
489
|
-
|
|
646
|
+
Configure TCB once at boot in `config/environments/test.rb` (see Configuration above). Between tests, call `TCB.reset!` to get a fresh event bus and a clean event store.
|
|
490
647
|
|
|
491
|
-
|
|
648
|
+
`TCB.reset!` shuts down the current bus, clears the event store, and clears all subscriptions. The next test starts with a clean slate. Domain modules do not need to be re-declared — they are set once at the top level and persist across resets.
|
|
649
|
+
|
|
650
|
+
### Synchronous mode
|
|
651
|
+
|
|
652
|
+
With `sync: true`, handlers execute in the caller thread immediately after `publish`. No background thread, no polling, no timing concerns. Tests are faster and deterministic. This is the recommended approach.
|
|
492
653
|
|
|
493
654
|
```ruby
|
|
494
|
-
# config/
|
|
495
|
-
Rails.application.config.
|
|
655
|
+
# config/environments/test.rb
|
|
656
|
+
Rails.application.config.after_initialize do
|
|
496
657
|
TCB.configure do |c|
|
|
497
|
-
c.event_bus = TCB::EventBus.new(
|
|
498
|
-
|
|
499
|
-
shutdown_timeout: 10.0
|
|
500
|
-
)
|
|
501
|
-
c.event_store = Rails.env.test? ? TCB::EventStore::InMemory.new
|
|
502
|
-
: TCB::EventStore::ActiveRecord.new
|
|
503
|
-
c.domain_modules = [Orders, Notifications]
|
|
658
|
+
c.event_bus = TCB::EventBus.new(sync: true)
|
|
659
|
+
c.event_store = TCB::EventStore::InMemory.new
|
|
504
660
|
end
|
|
505
661
|
end
|
|
506
662
|
```
|
|
507
663
|
|
|
508
|
-
###
|
|
664
|
+
### Minitest
|
|
509
665
|
|
|
510
666
|
```ruby
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
667
|
+
class OrdersTest < Minitest::Test
|
|
668
|
+
include TCB::MinitestHelpers
|
|
669
|
+
def teardown = TCB.reset!
|
|
514
670
|
|
|
515
|
-
|
|
516
|
-
|
|
671
|
+
def test_placing_order_publishes_event
|
|
672
|
+
assert_published(Orders::OrderPlaced) do
|
|
673
|
+
Orders.place!(order_id: 42, customer: "Alice")
|
|
674
|
+
end
|
|
517
675
|
end
|
|
518
676
|
end
|
|
519
677
|
```
|
|
520
678
|
|
|
521
|
-
|
|
679
|
+
#### assert_published
|
|
522
680
|
|
|
523
681
|
```ruby
|
|
524
|
-
|
|
682
|
+
assert_published(Orders::OrderPlaced) { Orders.place!(order_id: 42, customer: "Alice") }
|
|
683
|
+
assert_published(Orders::OrderPlaced.new(order_id: 42, customer: "Alice")) { Orders.place!(...) }
|
|
684
|
+
assert_published(Orders::OrderPlaced, Notifications::WelcomeEmailQueued) { Orders.place!(...) }
|
|
685
|
+
assert_published(Orders::OrderPlaced, within: 0.5) { Orders.place!(...) }
|
|
525
686
|
```
|
|
526
687
|
|
|
527
|
-
|
|
688
|
+
#### poll_assert
|
|
528
689
|
|
|
529
|
-
|
|
690
|
+
Only needed when using an async bus. With `TCB::EventBus.new(sync: true)`, handlers execute in the caller thread and results are available immediately — no polling required.
|
|
530
691
|
|
|
531
692
|
```ruby
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
expect { Orders.place!(...) }.to have_published(Orders::OrderPlaced, within: 0.5)
|
|
693
|
+
poll_assert("reserve inventory called") { CALLED.include?(:reserve_inventory) }
|
|
694
|
+
poll_assert("payment processed", within: 2.0) { Payment.completed? }
|
|
535
695
|
```
|
|
536
696
|
|
|
537
|
-
|
|
697
|
+
### RSpec
|
|
538
698
|
|
|
539
699
|
```ruby
|
|
540
|
-
|
|
541
|
-
|
|
700
|
+
# spec/support/tcb.rb
|
|
701
|
+
RSpec.configure do |config|
|
|
702
|
+
config.after(:each) { TCB.reset! }
|
|
703
|
+
end
|
|
542
704
|
```
|
|
543
705
|
|
|
544
|
-
|
|
706
|
+
Require it from `rails_helper.rb`:
|
|
545
707
|
|
|
546
708
|
```ruby
|
|
547
|
-
|
|
548
|
-
include TCB::MinitestHelpers
|
|
549
|
-
|
|
550
|
-
def teardown
|
|
551
|
-
TCB.reset!
|
|
552
|
-
end
|
|
553
|
-
end
|
|
709
|
+
require "support/tcb"
|
|
554
710
|
```
|
|
555
711
|
|
|
556
|
-
####
|
|
712
|
+
#### have_published
|
|
557
713
|
|
|
558
714
|
```ruby
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
assert_published(Orders::OrderPlaced, within: 0.5) { Orders.place!(...) }
|
|
715
|
+
expect { Orders.place!(order_id: 42, customer: "Alice") }.to have_published(Orders::OrderPlaced)
|
|
716
|
+
expect { Orders.place!(...) }.to have_published(Orders::OrderPlaced.new(order_id: 42, customer: "Alice"))
|
|
717
|
+
expect { Orders.place!(...) }.to have_published(Orders::OrderPlaced, within: 0.5)
|
|
563
718
|
```
|
|
564
719
|
|
|
565
|
-
####
|
|
720
|
+
#### poll_match
|
|
721
|
+
|
|
722
|
+
Only needed with an async bus.
|
|
566
723
|
|
|
567
724
|
```ruby
|
|
568
|
-
|
|
569
|
-
|
|
725
|
+
expect { CALLED.include?(:reserve_inventory) }.to poll_match
|
|
726
|
+
expect { Payment.completed? }.to poll_match(within: 2.0)
|
|
570
727
|
```
|
|
571
728
|
|
|
572
729
|
---
|
|
@@ -5,7 +5,7 @@ require_relative "../shared/command_argument"
|
|
|
5
5
|
module TCB
|
|
6
6
|
module Generators
|
|
7
7
|
class DomainGenerator < Rails::Generators::Base
|
|
8
|
-
namespace "
|
|
8
|
+
namespace "tcb:domain"
|
|
9
9
|
source_root File.expand_path("templates", __dir__)
|
|
10
10
|
|
|
11
11
|
argument :module_name, type: :string
|
|
@@ -5,7 +5,7 @@ require_relative "../shared/command_argument"
|
|
|
5
5
|
module TCB
|
|
6
6
|
module Generators
|
|
7
7
|
class EventStoreGenerator < Rails::Generators::Base
|
|
8
|
-
namespace "
|
|
8
|
+
namespace "tcb:event_store"
|
|
9
9
|
source_root File.expand_path("templates", __dir__)
|
|
10
10
|
|
|
11
11
|
argument :module_name, type: :string
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
class <%= migration_class_name %> < ActiveRecord::Migration[8.0]
|
|
2
2
|
def change
|
|
3
3
|
create_table :<%= table_name %> do |t|
|
|
4
|
-
t.string :event_id,
|
|
5
|
-
t.string :stream_id,
|
|
6
|
-
t.integer :version,
|
|
7
|
-
t.string :event_type,
|
|
8
|
-
t.text :payload,
|
|
9
|
-
t.datetime :occurred_at,
|
|
4
|
+
t.string :event_id, null: false
|
|
5
|
+
t.string :stream_id, null: false
|
|
6
|
+
t.integer :version, null: false
|
|
7
|
+
t.string :event_type, null: false
|
|
8
|
+
t.text :payload, null: false
|
|
9
|
+
t.datetime :occurred_at, null: false
|
|
10
|
+
t.string :correlation_id
|
|
11
|
+
t.string :causation_id
|
|
10
12
|
end
|
|
11
13
|
|
|
12
14
|
add_index :<%= table_name %>, [:stream_id, :version], unique: true
|
|
15
|
+
add_index :<%= table_name %>, :event_id, unique: true
|
|
16
|
+
add_index :<%= table_name %>, :correlation_id
|
|
13
17
|
end
|
|
14
|
-
end
|
|
18
|
+
end
|