tcb 0.5.0

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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +27 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +600 -0
  5. data/lib/generators/tcb/domain/domain_generator.rb +49 -0
  6. data/lib/generators/tcb/domain/templates/command_handler.rb.tt +11 -0
  7. data/lib/generators/tcb/domain/templates/domain_module.rb.tt +34 -0
  8. data/lib/generators/tcb/event_store/event_store_generator.rb +64 -0
  9. data/lib/generators/tcb/event_store/templates/command_handler.rb.tt +18 -0
  10. data/lib/generators/tcb/event_store/templates/domain_module.rb.tt +44 -0
  11. data/lib/generators/tcb/event_store/templates/migration.rb.tt +14 -0
  12. data/lib/generators/tcb/install/install_generator.rb +16 -0
  13. data/lib/generators/tcb/install/templates/tcb.rb.tt +18 -0
  14. data/lib/generators/tcb/shared/command_argument.rb +39 -0
  15. data/lib/tcb/command_bus.rb +26 -0
  16. data/lib/tcb/configuration.rb +118 -0
  17. data/lib/tcb/domain.rb +8 -0
  18. data/lib/tcb/domain_context.rb +29 -0
  19. data/lib/tcb/event_bus/running_strategy.rb +24 -0
  20. data/lib/tcb/event_bus/shutdown_strategy.rb +88 -0
  21. data/lib/tcb/event_bus/subscriber_registry.rb +46 -0
  22. data/lib/tcb/event_bus/termination_signal_handler.rb +55 -0
  23. data/lib/tcb/event_bus.rb +118 -0
  24. data/lib/tcb/event_bus_shutdown.rb +11 -0
  25. data/lib/tcb/event_query.rb +107 -0
  26. data/lib/tcb/event_store/active_record.rb +93 -0
  27. data/lib/tcb/event_store/event_stream_envelope.rb +13 -0
  28. data/lib/tcb/event_store/in_memory.rb +51 -0
  29. data/lib/tcb/handles_commands.rb +31 -0
  30. data/lib/tcb/handles_events.rb +44 -0
  31. data/lib/tcb/minitest_helpers.rb +37 -0
  32. data/lib/tcb/publish.rb +6 -0
  33. data/lib/tcb/record.rb +55 -0
  34. data/lib/tcb/records_events.rb +23 -0
  35. data/lib/tcb/rspec_helpers.rb +61 -0
  36. data/lib/tcb/stream_id.rb +33 -0
  37. data/lib/tcb/subscriber_invocation_failed.rb +31 -0
  38. data/lib/tcb/subscriber_metadata_extractor.rb +66 -0
  39. data/lib/tcb/test_helpers/shared.rb +29 -0
  40. data/lib/tcb/version.rb +5 -0
  41. data/lib/tcb.rb +57 -0
  42. metadata +195 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 36fd8e211b32e06207655daa3315da63d7f656327a4d16635d4a543627d65788
4
+ data.tar.gz: 1ceac959afd426cd864ee13f5cfe19bb7df320b96ec421cc73602c57bd9ed581
5
+ SHA512:
6
+ metadata.gz: 14156191e1ffb38ffc16f6dc047c4f2f1bb524727cec39420ba9685a7f24c07d511f8397349cc9979bf81d7266a10cc184a4657a0730d7ab268a234d60828ee4
7
+ data.tar.gz: 6e2f57602e4b610615a1ea0e5749f9fd7881617801f23939287552fe8b2ac2d5d446fc484fd28182656438a462866b2ba08ecbc3131e17db259fc7da13566cb7
data/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.5.0] - 2026-04-14
11
+
12
+ ### Added
13
+
14
+ - `TCB::EventBus` — thread-safe, async pub/sub bus with graceful shutdown
15
+ - `TCB::RecordsEvents` — aggregate mixin for recording domain events
16
+ - `TCB.record` — transaction boundary, returns recorded events
17
+ - `TCB.publish` — explicit, caller-controlled event publication
18
+ - `TCB.dispatch` — command bus with `validate!` convention and handler routing
19
+ - `TCB::HandlesEvents` — declarative event reactions with `on / execute` DSL
20
+ - `TCB::Configuration` — composition root, frozen after configuration
21
+ - `TCB::EventStore::InMemory` — in-memory event store for tests
22
+ - `TCB::EventStore::ActiveRecord` — ActiveRecord persistence adapter (YAML, SQLite compatible)
23
+ - `TCB::EventQuery` — fluent read API with version and time filters
24
+ - `TCB::TestHelpers` — Minitest helpers: `assert_published`, `poll_assert`
25
+ - `TCB::TestHelpers::RSpec` — RSpec matchers: `have_published`, `poll_assert`
26
+ - Rails generators: `tcb:install`, `tcb:event_store`, `tcb:domain`
27
+ - `EventBus#unsubscribe`
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Ljubomir Marković
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,600 @@
1
+ # TCB
2
+
3
+ A lightweight, thread-safe event and command runtime for Domain-Driven Design on Rails.
4
+
5
+ TCB gives Rails applications a clean domain language. Events, aggregates, and handlers are plain Ruby. No framework inheritance, no infrastructure details leaking into your domain.
6
+
7
+ TCB uses a command and event bus as an architectural coordination mechanism. Commands are decisions routed to exactly one handler. Events are facts broadcast to any number of reactions. The goal is to isolate side-effects, allow independent evolution of behaviors, and support an increasing number of business-significant events.
8
+
9
+ ## Installation
10
+
11
+ Add this line to your application's Gemfile:
12
+
13
+ ```ruby
14
+ gem 'tcb'
15
+ ```
16
+
17
+ And then execute:
18
+
19
+ $ bundle install
20
+
21
+ ---
22
+
23
+ ## Event Bus
24
+
25
+ The simplest use of TCB is a standalone pub/sub bus. Events are named in the past tense. They represent facts that have already happened:
26
+
27
+ ```ruby
28
+ UserRegistered = Data.define(:id, :email)
29
+ ```
30
+
31
+ ### Block handlers
32
+
33
+ ```ruby
34
+ bus = TCB::EventBus.new
35
+
36
+ bus.subscribe(UserRegistered) do |event|
37
+ WelcomeMailer.deliver(event.email)
38
+ end
39
+
40
+ bus.subscribe(UserRegistered) do |event|
41
+ Analytics.track("user_registered", user_id: event.id)
42
+ end
43
+
44
+ bus.publish(UserRegistered.new(id: 1, email: "alice@example.com"))
45
+ ```
46
+
47
+ ### `TCB::HandlesEvents`
48
+
49
+ 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
+
51
+ ```ruby
52
+ module Notifications
53
+ include TCB::HandlesEvents
54
+
55
+ on Auth::UserRegistered, react_with(SendWelcomeEmail, TrackRegistration)
56
+ on Orders::OrderPlaced, react_with(SendOrderConfirmation)
57
+ end
58
+
59
+ class SendWelcomeEmail
60
+ def call(event)
61
+ WelcomeMailer.deliver(event.email)
62
+ end
63
+ end
64
+
65
+ class TrackRegistration
66
+ def call(event)
67
+ Analytics.track("user_registered", user_id: event.id)
68
+ end
69
+ end
70
+ ```
71
+
72
+ Event classes can come from anywhere. `TCB::HandlesEvents` only cares that they are published on the bus. Cross-module reactions are the norm, not the exception.
73
+
74
+ Register at configuration time:
75
+
76
+ ```ruby
77
+ TCB.configure do |c|
78
+ c.event_bus = TCB::EventBus.new
79
+ c.domain_modules = [Notifications]
80
+ end
81
+
82
+ TCB.publish(Auth::UserRegistered.new(id: 1, email: "alice@example.com"))
83
+ ```
84
+
85
+ Handlers execute asynchronously in a background thread. Each handler is isolated. One failure does not prevent others from executing.
86
+
87
+ ---
88
+
89
+ ## TCB::HandlesCommands
90
+
91
+ Commands express intent. They are validated before execution and routed to an explicitly registered handler. One command, one handler. Commands are decisions, not broadcasts.
92
+
93
+ ```ruby
94
+ PlaceOrder = Data.define(:order_id, :customer) do
95
+ def validate!
96
+ raise ArgumentError, "customer required" if customer.nil?
97
+ end
98
+ end
99
+
100
+ class PlaceOrderHandler
101
+ def call(command)
102
+ # ... domain logic
103
+ end
104
+ end
105
+ ```
106
+
107
+ Use `TCB::HandlesCommands` to register the handler explicitly:
108
+
109
+ ```ruby
110
+ module Orders
111
+ include TCB::HandlesCommands
112
+
113
+ # one command, one handler
114
+ handle PlaceOrder, with(PlaceOrderHandler)
115
+ end
116
+
117
+ TCB.configure do |c|
118
+ c.event_bus = TCB::EventBus.new
119
+ c.domain_modules = [Orders]
120
+ end
121
+
122
+ TCB.dispatch(PlaceOrder.new(order_id: 42, customer: "Alice"))
123
+ ```
124
+
125
+ There is no convention-based routing. Every command handler is declared explicitly. Reading the module tells the whole story.
126
+
127
+ ---
128
+
129
+ ## Domain-Driven Design
130
+
131
+ Aggregates, persistence, and reactive handlers are where DDD gets complex. TCB keeps the domain language clean regardless. The infrastructure stays out of sight.
132
+
133
+ ### Recommended file structure
134
+
135
+ Keep domain code together. TCB convention is a single `app/domain` folder. Beyond that, structure is yours to decide.
136
+
137
+ ```
138
+ app/domain/
139
+ orders.rb # domain module, public interface
140
+ orders/
141
+ order.rb # aggregate
142
+ place_order_handler.rb
143
+ reserve_inventory.rb
144
+ charge_payment.rb
145
+ ```
146
+
147
+ ### The domain module
148
+
149
+ 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
+
151
+ ```ruby
152
+ # app/domain/orders.rb
153
+ module Orders
154
+ include TCB::Domain
155
+
156
+ # Events
157
+ OrderPlaced = Data.define(:order_id, :customer)
158
+ OrderCancelled = Data.define(:order_id, :reason)
159
+
160
+ # Commands
161
+ PlaceOrder = Data.define(:order_id, :customer) do
162
+ def validate!
163
+ raise ArgumentError, "customer required" if customer.nil?
164
+ end
165
+ end
166
+
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
+ # Persistence
174
+ persist events(
175
+ OrderPlaced,
176
+ OrderCancelled,
177
+ stream_id_from_event: :order_id
178
+ )
179
+
180
+ # Commands
181
+ handle PlaceOrder, with(PlaceOrderHandler)
182
+ handle CancelOrder, with(CancelOrderHandler)
183
+
184
+ # Reactions
185
+ on OrderPlaced, react_with(ReserveInventory, ChargePayment)
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
196
+ end
197
+ ```
198
+
199
+ `TCB::Domain` includes both `TCB::HandlesEvents` and `TCB::HandlesCommands`. The full picture is visible in one place.
200
+
201
+ The facade is the public contract. Callers get plain method calls with meaningful names. TCB stays out of sight:
202
+
203
+ ```ruby
204
+ Orders.place!(order_id: 42, customer: "Alice")
205
+ Orders.cancel!(order_id: 42, reason: "changed mind")
206
+ ```
207
+
208
+ 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.
209
+
210
+ ### Aggregate
211
+
212
+ An aggregate is the consistency boundary around your domain state. It decides what is allowed and records what happened. TCB aggregates are plain Ruby objects. No base class, no persistence concerns.
213
+
214
+ 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
+
216
+ ```ruby
217
+ # app/domain/orders/order.rb
218
+ module Orders
219
+ class Order
220
+ include TCB::RecordsEvents
221
+
222
+ attr_reader :id
223
+
224
+ def initialize(id:) = @id = id
225
+
226
+ def place(customer:)
227
+ record OrderPlaced.new(order_id: id, customer: customer)
228
+ end
229
+
230
+ def cancel(reason:)
231
+ record OrderCancelled.new(order_id: id, reason: reason)
232
+ end
233
+ end
234
+ end
235
+ ```
236
+
237
+ ### Command handler
238
+
239
+ 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
+
241
+ #### With aggregate
242
+
243
+ ```ruby
244
+ # app/domain/orders/place_order_handler.rb
245
+ module Orders
246
+ class PlaceOrderHandler
247
+ def call(command)
248
+ order = Order.new(id: command.order_id)
249
+
250
+ # within: ApplicationRecord wraps the block in a database transaction.
251
+ # If anything raises, no events are persisted and none are published.
252
+ events = TCB.record(events_from: [order], within: ApplicationRecord) do
253
+ order.place(customer: command.customer)
254
+ end
255
+
256
+ TCB.publish(*events)
257
+ end
258
+ end
259
+ end
260
+ ```
261
+
262
+ **Persistence always happens before publishing.** If an exception is raised inside the block, no events are persisted and none are published. Omitting `within:` skips the transaction. Events are still collected and returned, but not persisted to the event store.
263
+
264
+ #### Without aggregate
265
+
266
+ When there is no aggregate, pass events directly:
267
+
268
+ ```ruby
269
+ # app/domain/auth/register_handler.rb
270
+ module Auth
271
+ class RegisterHandler
272
+ def call(command)
273
+ # within: ApplicationRecord wraps persistence in a transaction.
274
+ # For a single event it is optional. But if you record multiple events,
275
+ # use within: to ensure they are all persisted or none are.
276
+ events = TCB.record(
277
+ events: [UserRegistered.new(user_id: command.user_id, email_address: command.email_address, token: command.token)],
278
+ within: ApplicationRecord
279
+ )
280
+
281
+ TCB.publish(*events)
282
+ end
283
+ end
284
+ end
285
+ ```
286
+
287
+ #### Combined
288
+
289
+ When a single operation produces events from both an aggregate and a direct fact, pass both. Everything is persisted and published atomically:
290
+
291
+ ```ruby
292
+ # app/domain/orders/place_order_handler.rb
293
+ module Orders
294
+ class PlaceOrderHandler
295
+ def call(command)
296
+ order = Order.new(id: command.order_id)
297
+
298
+ events = TCB.record(
299
+ events_from: [order],
300
+ events: [OrderingStarted.new(order_id: command.order_id, initiated_at: Time.now)],
301
+ within: ApplicationRecord
302
+ ) do
303
+ order.place(customer: command.customer)
304
+ end
305
+
306
+ TCB.publish(*events)
307
+ end
308
+ end
309
+ end
310
+ ```
311
+
312
+ **All events are persisted in a single transaction before any are published.**
313
+
314
+ ---
315
+
316
+ ## Configuration
317
+
318
+ Each domain module gets its own database table. Domains stay isolated at the persistence level. This is a step toward event sourcing. Every business-significant moment is recorded, queryable, and replayable.
319
+
320
+ | Module | Table |
321
+ |---|---|
322
+ | `Orders` | `orders_events` |
323
+ | `Payments` | `payments_events` |
324
+ | `Payments::Charges` | `payments_charges_events` |
325
+
326
+ ```ruby
327
+ TCB.configure do |c|
328
+ c.event_bus = TCB::EventBus.new
329
+ c.event_store = TCB::EventStore::ActiveRecord.new
330
+ c.domain_modules = [Orders, Payments]
331
+
332
+ # Optional: additional classes for YAML serialization
333
+ c.extra_serialization_classes = [ActiveSupport::TimeWithZone, Money]
334
+ end
335
+ ```
336
+
337
+ ---
338
+
339
+ ## Reading Events
340
+
341
+ Events are stored per aggregate stream. Query them by domain module and aggregate id. The result is always ordered by version. For large streams, `in_batches` uses keyset pagination and keeps memory usage flat.
342
+
343
+ ```ruby
344
+ # All events for an aggregate
345
+ TCB.read(Orders).stream(42).to_a
346
+
347
+ # Version filters
348
+ TCB.read(Orders).stream(42).from_version(5).to_a
349
+ TCB.read(Orders).stream(42).to_version(20).to_a
350
+ TCB.read(Orders).stream(42).between_versions(5, 20).to_a
351
+
352
+ # Time filter
353
+ TCB.read(Orders).stream(42).occurred_after(1.week.ago).to_a
354
+
355
+ # Last N events (oldest first)
356
+ TCB.read(Orders).stream(42).last(10)
357
+
358
+ # Batch processing
359
+ TCB.read(Orders).stream(42).in_batches(of: 100) do |batch|
360
+ batch.each { |envelope| replay(envelope.event) }
361
+ end
362
+
363
+ # With version bounds
364
+ TCB.read(Orders).stream(42).in_batches(of: 100, from_version: 50, to_version: 200) do |batch|
365
+ # ...
366
+ end
367
+ ```
368
+
369
+ Each result is a `TCB::Envelope`:
370
+
371
+ ```ruby
372
+ envelope.event # the domain event
373
+ envelope.event_id # UUID string
374
+ envelope.stream_id # "context|aggregate_id"
375
+ envelope.version # integer, sequential per stream
376
+ envelope.occurred_at # Time
377
+ ```
378
+
379
+ ---
380
+
381
+ ## Event Store Adapters
382
+
383
+ ### In-Memory (for tests)
384
+
385
+ ```ruby
386
+ TCB::EventStore::InMemory.new
387
+ ```
388
+
389
+ ### ActiveRecord (YAML, all databases including SQLite)
390
+
391
+ ```ruby
392
+ TCB::EventStore::ActiveRecord.new
393
+ ```
394
+
395
+ Generate migration and AR model:
396
+
397
+ ```
398
+ bin/rails generate TCB:event_store orders
399
+ ```
400
+
401
+ ---
402
+
403
+ ## Generators
404
+
405
+ TCB includes generators to scaffold domain modules, command handlers, and migrations.
406
+
407
+ ### Install
408
+
409
+ ```bash
410
+ rails generate TCB:install
411
+ ```
412
+
413
+ Creates `config/initializers/tcb.rb` with a minimal configuration template.
414
+
415
+ ### Domain module with event store
416
+
417
+ ```bash
418
+ rails generate TCB:event_store orders place_order:order_id,customer cancel_order:order_id,reason
419
+ ```
420
+
421
+ Generates:
422
+ - `app/domain/orders.rb`: domain module with commands, persistence placeholder, reactions placeholder, and facade
423
+ - `app/domain/orders/place_order_handler.rb`: command handler with `TCB.record` / `TCB.publish` scaffold
424
+ - `app/domain/orders/cancel_order_handler.rb`
425
+ - `db/migrate/TIMESTAMP_create_orders_events.rb`
426
+
427
+ ### Domain module without persistence (pub/sub only)
428
+
429
+ ```bash
430
+ rails generate TCB:domain notifications send_welcome_email:user_id,email send_verification_sms:user_id,phone
431
+ ```
432
+
433
+ Generates:
434
+ - `app/domain/notifications.rb`: domain module with commands, reactions placeholder, and facade using `TCB.publish`
435
+ - `app/domain/notifications/send_welcome_email_handler.rb`
436
+ - `app/domain/notifications/send_verification_sms_handler.rb`
437
+
438
+ ### Options
439
+
440
+ | Flag | Description |
441
+ |---|---|
442
+ | `--skip-domain` | Skip domain module and handlers |
443
+ | `--skip-migration` | Skip migration (event_store only) |
444
+ | `--no-comments` | Generate without inline guidance comments |
445
+
446
+ After generating, add your module to `config/initializers/tcb.rb`. This is the only place TCB needs to know about your domain modules. All reactions, persistence rules, and handler mappings are declared inside the module itself, not here:
447
+
448
+ ```ruby
449
+ c.domain_modules = [
450
+ Orders,
451
+ Notifications,
452
+ ]
453
+ ```
454
+
455
+ ---
456
+
457
+ ## Error Handling
458
+
459
+ Failed handlers emit a `TCB::SubscriberInvocationFailed` event:
460
+
461
+ ```ruby
462
+ TCB.config.event_bus.subscribe(TCB::SubscriberInvocationFailed) do |failure|
463
+ Rails.logger.error "#{failure.error_class}: #{failure.error_message}"
464
+ Rails.logger.error failure.error_backtrace.join("\n")
465
+ end
466
+ ```
467
+
468
+ ---
469
+
470
+ ## Graceful Shutdown
471
+
472
+ ```ruby
473
+ bus = TCB::EventBus.new(
474
+ handle_signals: true,
475
+ shutdown_timeout: 30.0
476
+ )
477
+
478
+ # Or manually
479
+ bus.shutdown(drain: true, timeout: 30.0)
480
+ bus.force_shutdown
481
+ ```
482
+
483
+ ---
484
+
485
+ ## Testing
486
+
487
+ Use `TCB::EventStore::InMemory` in tests. TCB is designed so that `TCB.reset!` fully tears down and rebuilds the configuration between tests. It shuts down the event bus, clears the event store, and re-registers all handlers.
488
+
489
+ The `TCB.configure` block is stored and replayed on every `reset!`. Each test gets a fresh event bus and a clean event store automatically.
490
+
491
+ ### Rails initializer
492
+
493
+ ```ruby
494
+ # config/initializers/tcb.rb
495
+ Rails.application.config.to_prepare do
496
+ TCB.configure do |c|
497
+ c.event_bus = TCB::EventBus.new(
498
+ handle_signals: true,
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]
504
+ end
505
+ end
506
+ ```
507
+
508
+ ### RSpec
509
+
510
+ ```ruby
511
+ # spec/support/tcb.rb
512
+ RSpec.configure do |config|
513
+ config.include TCB::RSpecHelpers
514
+
515
+ config.after(:each) do
516
+ TCB.reset!
517
+ end
518
+ end
519
+ ```
520
+
521
+ Require it from `rails_helper.rb`:
522
+
523
+ ```ruby
524
+ require "support/tcb"
525
+ ```
526
+
527
+ `TCB.reset!` replays the configure block. `Rails.env.test?` is re-evaluated each time, so `InMemory` gets a fresh instance on every reset.
528
+
529
+ #### have_published
530
+
531
+ ```ruby
532
+ expect { Orders.place!(order_id: 42, customer: "Alice") }.to have_published(Orders::OrderPlaced)
533
+ expect { Orders.place!(...) }.to have_published(Orders::OrderPlaced.new(order_id: 42, customer: "Alice"))
534
+ expect { Orders.place!(...) }.to have_published(Orders::OrderPlaced, within: 0.5)
535
+ ```
536
+
537
+ #### poll_match
538
+
539
+ ```ruby
540
+ expect { CALLED.include?(:reserve_inventory) }.to poll_match
541
+ expect { Payment.completed? }.to poll_match(within: 2.0)
542
+ ```
543
+
544
+ ### Minitest
545
+
546
+ ```ruby
547
+ class OrdersTest < Minitest::Test
548
+ include TCB::MinitestHelpers
549
+
550
+ def teardown
551
+ TCB.reset!
552
+ end
553
+ end
554
+ ```
555
+
556
+ #### assert_published
557
+
558
+ ```ruby
559
+ assert_published(Orders::OrderPlaced) { Orders.place!(order_id: 42, customer: "Alice") }
560
+ assert_published(Orders::OrderPlaced.new(order_id: 42, customer: "Alice")) { Orders.place!(...) }
561
+ assert_published(Orders::OrderPlaced, Notifications::WelcomeEmailQueued) { Orders.place!(...) }
562
+ assert_published(Orders::OrderPlaced, within: 0.5) { Orders.place!(...) }
563
+ ```
564
+
565
+ #### poll_assert
566
+
567
+ ```ruby
568
+ poll_assert("handler was called") { CALLED.include?(:reserve_inventory) }
569
+ poll_assert("payment processed", within: 2.0) { Payment.completed? }
570
+ ```
571
+
572
+ ---
573
+
574
+ ## Why `Data.define`
575
+
576
+ TCB uses Ruby's `Data.define` for events and commands throughout. This is a deliberate architectural choice, not a convention.
577
+
578
+ TCB embraces data coupling by design. Events carry only data, never behavior. This enables a reactive architecture where domain modules respond to facts rather than calling each other directly, keeping your codebase decoupled and easy to reason about.
579
+
580
+ **Immutability.** Events are facts. They cannot be changed after they happen. `Data.define` enforces this at the language level. There is no way to accidentally mutate an event in a handler.
581
+
582
+ **Explicit data coupling.** When you define `OrderPlaced = Data.define(:order_id, :customer)`, the attributes are the contract. Anyone reading the code sees exactly what an `OrderPlaced` event carries. No hidden state, no methods that obscure the data shape.
583
+
584
+ **Value semantics.** Two `OrderPlaced` events with the same attributes are equal. This makes testing straightforward. No mocks, no stubs, just plain equality assertions.
585
+
586
+ **No inheritance tax.** `Data.define` requires no base class, no framework module. Your domain events are pure Ruby. They can be used anywhere without pulling TCB along.
587
+
588
+ ---
589
+
590
+ ## Development
591
+
592
+ After checking out the repo, run `bundle install` to install dependencies. Then run `rake test` to run the tests.
593
+
594
+ ## Contributing
595
+
596
+ Bug reports and pull requests are welcome on GitHub at https://github.com/tcb-si/tcb.
597
+
598
+ ## License
599
+
600
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../shared/command_argument"
4
+
5
+ module TCB
6
+ module Generators
7
+ class DomainGenerator < Rails::Generators::Base
8
+ namespace "TCB:domain"
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ argument :module_name, type: :string
12
+ argument :commands, type: :array, default: [], banner: "command:attr1,attr2"
13
+
14
+ class_option :skip_domain, type: :boolean, default: false, desc: "Skip domain module generation"
15
+ class_option :no_comments, type: :boolean, default: false, desc: "Generate without inline comments"
16
+
17
+ def create_domain_module
18
+ return if options[:skip_domain]
19
+ template "domain_module.rb.tt", "app/domain/#{module_name.underscore}.rb"
20
+ end
21
+
22
+ def create_handlers
23
+ return if options[:skip_domain]
24
+ parsed_commands.each do |cmd|
25
+ @current_command = cmd
26
+ template "command_handler.rb.tt", cmd.handler_file_path(module_name.underscore)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def parsed_commands
33
+ @parsed_commands ||= CommandArgumentParser.parse(commands)
34
+ end
35
+
36
+ def module_class_name
37
+ module_name.camelize
38
+ end
39
+
40
+ def comments?
41
+ !options[:no_comments]
42
+ end
43
+
44
+ def current_command
45
+ @current_command
46
+ end
47
+ end
48
+ end
49
+ end