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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +27 -0
- data/LICENSE.txt +21 -0
- data/README.md +600 -0
- data/lib/generators/tcb/domain/domain_generator.rb +49 -0
- data/lib/generators/tcb/domain/templates/command_handler.rb.tt +11 -0
- data/lib/generators/tcb/domain/templates/domain_module.rb.tt +34 -0
- data/lib/generators/tcb/event_store/event_store_generator.rb +64 -0
- data/lib/generators/tcb/event_store/templates/command_handler.rb.tt +18 -0
- data/lib/generators/tcb/event_store/templates/domain_module.rb.tt +44 -0
- data/lib/generators/tcb/event_store/templates/migration.rb.tt +14 -0
- data/lib/generators/tcb/install/install_generator.rb +16 -0
- data/lib/generators/tcb/install/templates/tcb.rb.tt +18 -0
- data/lib/generators/tcb/shared/command_argument.rb +39 -0
- data/lib/tcb/command_bus.rb +26 -0
- data/lib/tcb/configuration.rb +118 -0
- data/lib/tcb/domain.rb +8 -0
- data/lib/tcb/domain_context.rb +29 -0
- data/lib/tcb/event_bus/running_strategy.rb +24 -0
- data/lib/tcb/event_bus/shutdown_strategy.rb +88 -0
- data/lib/tcb/event_bus/subscriber_registry.rb +46 -0
- data/lib/tcb/event_bus/termination_signal_handler.rb +55 -0
- data/lib/tcb/event_bus.rb +118 -0
- data/lib/tcb/event_bus_shutdown.rb +11 -0
- data/lib/tcb/event_query.rb +107 -0
- data/lib/tcb/event_store/active_record.rb +93 -0
- data/lib/tcb/event_store/event_stream_envelope.rb +13 -0
- data/lib/tcb/event_store/in_memory.rb +51 -0
- data/lib/tcb/handles_commands.rb +31 -0
- data/lib/tcb/handles_events.rb +44 -0
- data/lib/tcb/minitest_helpers.rb +37 -0
- data/lib/tcb/publish.rb +6 -0
- data/lib/tcb/record.rb +55 -0
- data/lib/tcb/records_events.rb +23 -0
- data/lib/tcb/rspec_helpers.rb +61 -0
- data/lib/tcb/stream_id.rb +33 -0
- data/lib/tcb/subscriber_invocation_failed.rb +31 -0
- data/lib/tcb/subscriber_metadata_extractor.rb +66 -0
- data/lib/tcb/test_helpers/shared.rb +29 -0
- data/lib/tcb/version.rb +5 -0
- data/lib/tcb.rb +57 -0
- 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
|