rails_simple_event_sourcing 1.0.0 → 1.0.2
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/README.md +580 -102
- data/app/models/concerns/rails_simple_event_sourcing/aggregate_configuration.rb +23 -0
- data/app/models/concerns/rails_simple_event_sourcing/event_attributes.rb +29 -0
- data/app/models/rails_simple_event_sourcing/event.rb +25 -59
- data/lib/rails_simple_event_sourcing/aggregate_repository.rb +32 -0
- data/lib/rails_simple_event_sourcing/command_handler.rb +7 -1
- data/lib/rails_simple_event_sourcing/command_handlers/base.rb +8 -0
- data/lib/rails_simple_event_sourcing/engine.rb +5 -2
- data/lib/rails_simple_event_sourcing/event_applicator.rb +32 -0
- data/lib/rails_simple_event_sourcing/event_player.rb +26 -0
- data/lib/rails_simple_event_sourcing/version.rb +1 -1
- metadata +8 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: baad400eca4ab6e7629c1bc7a9d30f66841d2da8ee1d3ab1dd3225333bf9ea10
|
|
4
|
+
data.tar.gz: dd2e881c3f8693aadae17ed44343acbb2633465eaf36d010934173793b91daf3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c1ed4111dd95c2e6f0bba524606749e98d22c78d7803ea0b1dc2f0f03a27ef39e070d868d25b5ac2491389db08c154221432c2374d647d343299cd2469eb0656
|
|
7
|
+
data.tar.gz: 44764791b5a57ad810562f1dbf32f5b17f59ee19c047092c19751db5470498cb56797e6c82aece230ddae68ac820dd6505a5ab8e86433d582bb002fef984c7a4
|
data/README.md
CHANGED
|
@@ -1,20 +1,100 @@
|
|
|
1
1
|
# RailsSimpleEventSourcing  
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A minimalist implementation of the event sourcing pattern for Rails applications. This gem provides a simple, opinionated approach to event sourcing without the complexity of full-featured frameworks.
|
|
4
|
+
|
|
5
|
+
If you need a more comprehensive solution, check out:
|
|
4
6
|
- https://www.sequent.io
|
|
5
7
|
- https://railseventstore.org
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
## Table of Contents
|
|
10
|
+
- [Features](#features)
|
|
11
|
+
- [Requirements](#requirements)
|
|
12
|
+
- [Installation](#installation)
|
|
13
|
+
- [Usage](#usage)
|
|
14
|
+
- [Directory Structure](#directory-structure)
|
|
15
|
+
- [Commands](#commands)
|
|
16
|
+
- [Command Handlers](#command-handlers)
|
|
17
|
+
- [Events](#events)
|
|
18
|
+
- [Controller Integration](#controller-integration)
|
|
19
|
+
- [Update and Delete Operations](#update-and-delete-operations)
|
|
20
|
+
- [Metadata Tracking](#metadata-tracking)
|
|
21
|
+
- [Event Querying](#event-querying)
|
|
22
|
+
- [Testing](#testing)
|
|
23
|
+
- [Limitations](#limitations)
|
|
24
|
+
- [Troubleshooting](#troubleshooting)
|
|
25
|
+
- [Contributing](#contributing)
|
|
26
|
+
- [License](#license)
|
|
27
|
+
|
|
28
|
+
## Features
|
|
29
|
+
|
|
30
|
+
- **Immutable Event Log** - All changes stored as immutable events with full audit trail
|
|
31
|
+
- **Automatic Aggregate Reconstruction** - Rebuild model state by replaying events
|
|
32
|
+
- **Built-in Metadata Tracking** - Captures request context (IP, user agent, params, etc.)
|
|
33
|
+
- **Read-only Model Protection** - Prevents accidental direct model modifications
|
|
34
|
+
- **Simple Command Pattern** - Clear command → handler → event flow
|
|
35
|
+
- **PostgreSQL JSONB Storage** - Efficient JSON storage for event payloads and metadata
|
|
36
|
+
- **Minimal Configuration** - Convention over configuration approach
|
|
37
|
+
|
|
38
|
+
## Requirements
|
|
39
|
+
|
|
40
|
+
- **Ruby**: 2.7 or higher
|
|
41
|
+
- **Rails**: 6.0 or higher
|
|
42
|
+
- **Database**: PostgreSQL 9.4+ (requires JSONB support)
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
Add this line to your application's Gemfile:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
gem "rails_simple_event_sourcing"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
And then execute:
|
|
53
|
+
```bash
|
|
54
|
+
$ bundle
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Or install it yourself as:
|
|
58
|
+
```bash
|
|
59
|
+
$ gem install rails_simple_event_sourcing
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Copy migration to your app:
|
|
63
|
+
```bash
|
|
64
|
+
rails rails_simple_event_sourcing:install:migrations
|
|
65
|
+
```
|
|
8
66
|
|
|
9
|
-
|
|
67
|
+
Run the migration to create the events table:
|
|
68
|
+
```bash
|
|
69
|
+
rake db:migrate
|
|
70
|
+
```
|
|
10
71
|
|
|
11
|
-
This
|
|
72
|
+
This creates the `rails_simple_event_sourcing_events` table that stores your event log.
|
|
12
73
|
|
|
13
74
|
## Usage
|
|
14
75
|
|
|
15
|
-
|
|
76
|
+
### Architecture Overview
|
|
77
|
+
|
|
78
|
+
The event sourcing flow follows this pattern:
|
|
16
79
|
|
|
17
|
-
|
|
80
|
+
```
|
|
81
|
+
HTTP Request → Controller → Command → CommandHandler → Event → Aggregate (Model)
|
|
82
|
+
↓ ↓ ↓ ↓ ↓
|
|
83
|
+
Pass data Parameters Validation + Immutable Database
|
|
84
|
+
+ Validation Business Storage
|
|
85
|
+
Rules Logic
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Flow breakdown:**
|
|
89
|
+
1. **Controller** - Receives request, creates command with params
|
|
90
|
+
2. **Command** - Defines parameters and validation rules (ActiveModel)
|
|
91
|
+
3. **CommandHandler** - Validates command, executes business logic, creates event
|
|
92
|
+
4. **Event** - Immutable record of what happened
|
|
93
|
+
5. **Aggregate** - Model updated via event
|
|
94
|
+
|
|
95
|
+
### Directory Structure
|
|
96
|
+
|
|
97
|
+
Let's start with the recommended directory structure:
|
|
18
98
|
|
|
19
99
|
```
|
|
20
100
|
app/
|
|
@@ -28,17 +108,18 @@ app/
|
|
|
28
108
|
│ │ │ ├─ create.rb
|
|
29
109
|
```
|
|
30
110
|
|
|
31
|
-
The
|
|
32
|
-
|
|
33
|
-
Based on the example above, the usage looks like this
|
|
111
|
+
**Note:** The top directory name (`domain/`) can be different - Rails doesn't enforce this namespace.
|
|
34
112
|
|
|
35
|
-
|
|
113
|
+
### Commands
|
|
36
114
|
|
|
37
|
-
|
|
115
|
+
Commands represent **intentions** to perform actions in your system. They are responsible for:
|
|
116
|
+
- Encapsulating action parameters
|
|
117
|
+
- Validating input data (using ActiveModel validations)
|
|
118
|
+
- Being immutable value objects
|
|
38
119
|
|
|
39
|
-
|
|
120
|
+
Think of commands as "requests to do something" - they describe what you want to happen, not how it happens.
|
|
40
121
|
|
|
41
|
-
Example
|
|
122
|
+
**Example - Create Command:**
|
|
42
123
|
|
|
43
124
|
```ruby
|
|
44
125
|
class Customer
|
|
@@ -54,14 +135,53 @@ class Customer
|
|
|
54
135
|
end
|
|
55
136
|
```
|
|
56
137
|
|
|
57
|
-
|
|
138
|
+
### Command Handlers
|
|
139
|
+
|
|
140
|
+
Command handlers contain the **business logic** for executing commands. They:
|
|
141
|
+
- Automatically validate the command before execution
|
|
142
|
+
- Perform business logic and API calls
|
|
143
|
+
- Create events when successful
|
|
144
|
+
- Handle errors gracefully
|
|
145
|
+
- Return a `RailsSimpleEventSourcing::Result` object
|
|
146
|
+
|
|
147
|
+
**Result Object:**
|
|
148
|
+
The `Result` struct has three fields:
|
|
149
|
+
- `success?` - Boolean indicating if the operation succeeded
|
|
150
|
+
- `data` - Data to return (usually the aggregate/model instance)
|
|
151
|
+
- `errors` - Array or hash of error messages when `success?` is false
|
|
152
|
+
|
|
153
|
+
**Helper Methods:**
|
|
154
|
+
The base class provides convenience methods:
|
|
155
|
+
- `success_result(data:)` - Creates a successful result
|
|
156
|
+
- `failure_result(errors:)` - Creates a failed result
|
|
157
|
+
|
|
158
|
+
**Example - Basic Handler:**
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
class Customer
|
|
162
|
+
module CommandHandlers
|
|
163
|
+
class Create < RailsSimpleEventSourcing::CommandHandlers::Base
|
|
164
|
+
def call
|
|
165
|
+
event = Customer::Events::CustomerCreated.create(
|
|
166
|
+
first_name: @command.first_name,
|
|
167
|
+
last_name: @command.last_name,
|
|
168
|
+
email: @command.email,
|
|
169
|
+
created_at: Time.zone.now,
|
|
170
|
+
updated_at: Time.zone.now
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Using helper method (recommended)
|
|
174
|
+
success_result(data: event.aggregate)
|
|
58
175
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
176
|
+
# Or create Result directly
|
|
177
|
+
# RailsSimpleEventSourcing::Result.new(success?: true, data: event.aggregate)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
```
|
|
63
183
|
|
|
64
|
-
Example
|
|
184
|
+
**Example - Handler with Error Handling:**
|
|
65
185
|
|
|
66
186
|
```ruby
|
|
67
187
|
class Customer
|
|
@@ -76,42 +196,99 @@ class Customer
|
|
|
76
196
|
updated_at: Time.zone.now
|
|
77
197
|
)
|
|
78
198
|
|
|
79
|
-
|
|
199
|
+
success_result(data: event.aggregate)
|
|
200
|
+
rescue ActiveRecord::RecordNotUnique
|
|
201
|
+
failure_result(errors: ["Email has already been taken"])
|
|
202
|
+
rescue StandardError => e
|
|
203
|
+
failure_result(errors: ["An error occurred: #{e.message}"])
|
|
80
204
|
end
|
|
81
205
|
end
|
|
82
206
|
end
|
|
83
207
|
end
|
|
84
208
|
```
|
|
85
209
|
|
|
86
|
-
|
|
210
|
+
### Events
|
|
211
|
+
|
|
212
|
+
Events represent **facts** - things that have already happened in your system. They:
|
|
213
|
+
- Store immutable data about state changes
|
|
214
|
+
- Use past tense naming (e.g., `CustomerCreated`, not `CreateCustomer`)
|
|
215
|
+
- Define which aggregate (model) they apply to
|
|
216
|
+
- Are stored permanently in the event log
|
|
217
|
+
|
|
218
|
+
**Key Concepts:**
|
|
219
|
+
- `aggregate_class` - The model this event applies to (optional - some events may not modify models)
|
|
220
|
+
- `event_attributes` - Fields stored in the event payload
|
|
221
|
+
- `apply(aggregate)` - **Optional** method that applies the event to an aggregate instance
|
|
222
|
+
- `aggregate_id` - Links the event to a specific aggregate instance
|
|
223
|
+
|
|
224
|
+
**Example - Basic Event (Automatic Application):**
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
class Customer
|
|
228
|
+
module Events
|
|
229
|
+
class CustomerCreated < RailsSimpleEventSourcing::Event
|
|
230
|
+
aggregate_class Customer
|
|
231
|
+
event_attributes :first_name, :last_name, :email, :created_at, :updated_at
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**Automatic Attribute Application:**
|
|
87
238
|
|
|
88
|
-
|
|
239
|
+
By default, all attributes declared in `event_attributes` will be **automatically applied** to the aggregate. You don't need to implement the `apply` method unless you have custom logic requirements.
|
|
240
|
+
|
|
241
|
+
The default implementation sets each event attribute on the aggregate:
|
|
242
|
+
```ruby
|
|
243
|
+
# This happens automatically
|
|
244
|
+
aggregate.first_name = first_name
|
|
245
|
+
aggregate.last_name = last_name
|
|
246
|
+
aggregate.email = email
|
|
247
|
+
# ... and so on for all event_attributes
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
**Example - Custom Apply Method (When Needed):**
|
|
251
|
+
|
|
252
|
+
You may still need to implement a custom `apply` method in certain cases:
|
|
253
|
+
- Setting computed or derived values
|
|
254
|
+
- Complex business logic during application
|
|
255
|
+
- Handling nested objects or special data transformations
|
|
256
|
+
- Setting the aggregate ID explicitly (though this is usually handled automatically)
|
|
89
257
|
|
|
90
258
|
```ruby
|
|
91
259
|
class Customer
|
|
92
260
|
module Events
|
|
93
261
|
class CustomerCreated < RailsSimpleEventSourcing::Event
|
|
94
|
-
|
|
262
|
+
aggregate_class Customer
|
|
95
263
|
event_attributes :first_name, :last_name, :email, :created_at, :updated_at
|
|
96
264
|
|
|
97
265
|
def apply(aggregate)
|
|
266
|
+
# Custom logic example
|
|
98
267
|
aggregate.id = aggregate_id
|
|
99
|
-
aggregate.
|
|
100
|
-
aggregate.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
268
|
+
aggregate.full_name = "#{first_name} #{last_name}" # Computed value
|
|
269
|
+
aggregate.email_normalized = email.downcase.strip # Transformation
|
|
270
|
+
|
|
271
|
+
# You can still call super to apply remaining attributes automatically
|
|
272
|
+
super
|
|
104
273
|
end
|
|
105
274
|
end
|
|
106
275
|
end
|
|
107
276
|
end
|
|
108
277
|
```
|
|
109
278
|
|
|
110
|
-
|
|
111
|
-
- `
|
|
112
|
-
- `event_attributes` -
|
|
279
|
+
**Understanding the Event Structure:**
|
|
280
|
+
- `aggregate_class Customer` - Specifies which model this event modifies
|
|
281
|
+
- `event_attributes` - Defines what data gets stored in the event's JSON payload and what will be automatically applied
|
|
282
|
+
- `apply(aggregate)` - Optional method; only implement if you need custom logic beyond automatic attribute assignment
|
|
283
|
+
- `aggregate_id` - Auto-generated for creates, must be provided for updates/deletes
|
|
113
284
|
|
|
114
|
-
|
|
285
|
+
**Note on aggregate_class:**
|
|
286
|
+
- Optional - you can have events without an aggregate (e.g., `UserLoginFailed` for logging only)
|
|
287
|
+
- The corresponding model should include `RailsSimpleEventSourcing::Events` for read-only protection
|
|
288
|
+
|
|
289
|
+
### Controller Integration
|
|
290
|
+
|
|
291
|
+
Here's how to wire everything together in a controller:
|
|
115
292
|
|
|
116
293
|
```ruby
|
|
117
294
|
class CustomersController < ApplicationController
|
|
@@ -132,15 +309,60 @@ class CustomersController < ApplicationController
|
|
|
132
309
|
end
|
|
133
310
|
```
|
|
134
311
|
|
|
135
|
-
|
|
312
|
+
### Update and Delete Operations
|
|
313
|
+
|
|
314
|
+
**Update Example:**
|
|
315
|
+
|
|
316
|
+
```ruby
|
|
317
|
+
class CustomersController < ApplicationController
|
|
318
|
+
def update
|
|
319
|
+
cmd = Customer::Commands::Update.new(
|
|
320
|
+
aggregate_id: params[:id],
|
|
321
|
+
first_name: params[:first_name],
|
|
322
|
+
last_name: params[:last_name],
|
|
323
|
+
email: params[:email]
|
|
324
|
+
)
|
|
325
|
+
handler = RailsSimpleEventSourcing::CommandHandler.new(cmd).call
|
|
326
|
+
|
|
327
|
+
if handler.success?
|
|
328
|
+
render json: handler.data
|
|
329
|
+
else
|
|
330
|
+
render json: { errors: handler.errors }, status: :unprocessable_entity
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
**Delete Example:**
|
|
337
|
+
|
|
338
|
+
```ruby
|
|
339
|
+
class CustomersController < ApplicationController
|
|
340
|
+
def destroy
|
|
341
|
+
cmd = Customer::Commands::Delete.new(aggregate_id: params[:id])
|
|
342
|
+
handler = RailsSimpleEventSourcing::CommandHandler.new(cmd).call
|
|
343
|
+
|
|
344
|
+
if handler.success?
|
|
345
|
+
head :no_content
|
|
346
|
+
else
|
|
347
|
+
render json: { errors: handler.errors }, status: :unprocessable_entity
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
**Important:** For update and delete operations, you must pass `aggregate_id` to identify which record to modify. See the full examples in `test/dummy/app/domain/customer/`.
|
|
354
|
+
|
|
355
|
+
### Testing the API
|
|
356
|
+
|
|
357
|
+
Create a customer using curl:
|
|
136
358
|
|
|
137
359
|
```sh
|
|
138
360
|
curl -X POST http://localhost:3000/customers \
|
|
139
361
|
-H 'Content-Type: application/json' \
|
|
140
|
-
-d '{ "first_name": "John", "last_name": "Doe" }' | jq
|
|
362
|
+
-d '{ "first_name": "John", "last_name": "Doe", "email": "john@example.com" }' | jq
|
|
141
363
|
```
|
|
142
364
|
|
|
143
|
-
|
|
365
|
+
Response:
|
|
144
366
|
|
|
145
367
|
```json
|
|
146
368
|
{
|
|
@@ -152,117 +374,373 @@ You will get the response:
|
|
|
152
374
|
}
|
|
153
375
|
```
|
|
154
376
|
|
|
155
|
-
|
|
377
|
+
### Event Querying
|
|
378
|
+
|
|
379
|
+
Open the Rails console (`rails c`) to explore the event log:
|
|
380
|
+
|
|
381
|
+
```ruby
|
|
382
|
+
# Get the customer
|
|
383
|
+
customer = Customer.last
|
|
384
|
+
# => #<Customer id: 1, first_name: "John", last_name: "Doe", ...>
|
|
385
|
+
|
|
386
|
+
# Access all events for this customer
|
|
387
|
+
customer.events
|
|
388
|
+
# => [#<Customer::Events::CustomerCreated...>]
|
|
389
|
+
|
|
390
|
+
# Get specific event details
|
|
391
|
+
event = customer.events.first
|
|
392
|
+
event.payload
|
|
393
|
+
# => {"first_name"=>"John", "last_name"=>"Doe", "email"=>"john@example.com", ...}
|
|
394
|
+
|
|
395
|
+
event.metadata
|
|
396
|
+
# => {"request_id"=>"2a40d4f9-509b-4b49-a39f-d978679fa5ef",
|
|
397
|
+
# "request_ip"=>"::1",
|
|
398
|
+
# "request_user_agent"=>"curl/8.6.0", ...}
|
|
399
|
+
|
|
400
|
+
# Query events by type
|
|
401
|
+
RailsSimpleEventSourcing::Event.where(event_type: "Customer::Events::CustomerCreated")
|
|
402
|
+
|
|
403
|
+
# Get events in a date range
|
|
404
|
+
customer.events.where(created_at: 1.week.ago..Time.now)
|
|
405
|
+
|
|
406
|
+
# Get all events for a specific aggregate
|
|
407
|
+
RailsSimpleEventSourcing::Event.where(eventable_type: "Customer", eventable_id: 1)
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
**Event Structure:**
|
|
411
|
+
- `payload` - Contains the event attributes you defined (as JSON)
|
|
412
|
+
- `metadata` - Contains request context (request ID, IP, user agent, params)
|
|
413
|
+
- `event_type` - The event class name
|
|
414
|
+
- `aggregate_id` - Links to the aggregate instance
|
|
415
|
+
- `eventable` - Polymorphic relation to the aggregate
|
|
416
|
+
|
|
417
|
+
### Metadata Tracking
|
|
418
|
+
|
|
419
|
+
To automatically capture request metadata (IP address, user agent, request ID, etc.), include the concern in your ApplicationController:
|
|
420
|
+
|
|
421
|
+
**Setup:**
|
|
156
422
|
|
|
157
423
|
```ruby
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
event_type: "Customer::Events::CustomerCreated",
|
|
171
|
-
aggregate_id: "1",
|
|
172
|
-
eventable_type: "Customer",
|
|
173
|
-
eventable_id: 1,
|
|
174
|
-
payload: {"last_name"=>"Doe", "created_at"=>"2024-08-03T16:58:59.952Z", "first_name"=>"John", "updated_at"=>"2024-08-03T16:58:59.952Z"},
|
|
175
|
-
metadata:
|
|
176
|
-
{"request_id"=>"2a40d4f9-509b-4b49-a39f-d978679fa5ef",
|
|
177
|
-
"request_ip"=>"::1",
|
|
178
|
-
"request_params"=>{"action"=>"create", "customer"=>{"last_name"=>"Doe", "first_name"=>"John"}, "last_name"=>"Doe", "controller"=>"customers", "first_name"=>"John"},
|
|
179
|
-
"request_user_agent"=>"curl/8.6.0"},
|
|
180
|
-
created_at: Sat, 03 Aug 2024 16:58:59.973815000 UTC +00:00,
|
|
181
|
-
updated_at: Sat, 03 Aug 2024 16:58:59.973815000 UTC +00:00>]
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
As you can see, customer has been created and if you check its `.events` relationship, you should see an event that created it.
|
|
185
|
-
This event has the same attributes in the payload as you set using the `event_attributes` method of the `Customer::Events::CustomerCreated` class.
|
|
186
|
-
There is also a metadata field, which is also defined as JSON, and you can store additional things in this field (this is just for information).
|
|
187
|
-
|
|
188
|
-
To have these metadata fields populated automatically, you need to include `RailsSimpleEventSourcing::SetCurrentRequestDetails` in your ApplicationController.
|
|
189
|
-
|
|
190
|
-
Example:
|
|
424
|
+
class ApplicationController < ActionController::Base
|
|
425
|
+
include RailsSimpleEventSourcing::SetCurrentRequestDetails
|
|
426
|
+
end
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
**Default Metadata:**
|
|
430
|
+
By default, the following is captured:
|
|
431
|
+
- `request_id` - Unique request identifier
|
|
432
|
+
- `request_user_agent` - Client user agent
|
|
433
|
+
- `request_referer` - HTTP referer
|
|
434
|
+
- `request_ip` - Client IP address
|
|
435
|
+
- `request_params` - Request parameters (filtered using Rails parameter filter)
|
|
191
436
|
|
|
437
|
+
**Customizing Metadata:**
|
|
438
|
+
Override the `event_metadata` method in your controller:
|
|
192
439
|
|
|
193
440
|
```ruby
|
|
194
441
|
class ApplicationController < ActionController::Base
|
|
195
442
|
include RailsSimpleEventSourcing::SetCurrentRequestDetails
|
|
443
|
+
|
|
444
|
+
def event_metadata
|
|
445
|
+
parameter_filter = ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters)
|
|
446
|
+
|
|
447
|
+
{
|
|
448
|
+
request_id: request.uuid,
|
|
449
|
+
request_user_agent: request.user_agent,
|
|
450
|
+
request_ip: request.ip,
|
|
451
|
+
request_params: parameter_filter.filter(request.params),
|
|
452
|
+
current_user_id: current_user&.id, # Add custom fields
|
|
453
|
+
tenant_id: current_tenant&.id
|
|
454
|
+
}
|
|
455
|
+
end
|
|
196
456
|
end
|
|
197
457
|
```
|
|
198
458
|
|
|
199
|
-
|
|
459
|
+
**Metadata Outside HTTP Requests:**
|
|
460
|
+
When events are created outside HTTP requests (background jobs, console, tests), metadata will be empty unless you manually set it using `CurrentRequest.metadata = {...}`.
|
|
461
|
+
|
|
462
|
+
### Model Configuration
|
|
200
463
|
|
|
201
|
-
|
|
464
|
+
Models that use event sourcing should include the `RailsSimpleEventSourcing::Events` module:
|
|
202
465
|
|
|
203
466
|
```ruby
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
{
|
|
208
|
-
request_id: request.uuid,
|
|
209
|
-
request_user_agent: request.user_agent,
|
|
210
|
-
request_referer: request.referer,
|
|
211
|
-
request_ip: request.ip,
|
|
212
|
-
request_params: parameter_filter.filter(request.params)
|
|
213
|
-
}
|
|
467
|
+
class Customer < ApplicationRecord
|
|
468
|
+
include RailsSimpleEventSourcing::Events
|
|
214
469
|
end
|
|
215
470
|
```
|
|
216
471
|
|
|
217
|
-
|
|
472
|
+
**This provides:**
|
|
473
|
+
- `.events` association - Access all events for this aggregate
|
|
474
|
+
- Read-only protection - Prevents accidental direct modifications
|
|
475
|
+
- Event replay capability - Reconstruct state from events
|
|
476
|
+
|
|
477
|
+
### Immutability and Read-Only Protection
|
|
478
|
+
|
|
479
|
+
**Important Principles:**
|
|
480
|
+
- **Events are immutable** - Once created, events should never be modified
|
|
481
|
+
- **Models are read-only** - Aggregates should only be modified through events
|
|
482
|
+
- Both have built-in protection against accidental changes
|
|
483
|
+
|
|
484
|
+
### Soft Deletes
|
|
218
485
|
|
|
219
|
-
|
|
486
|
+
**Recommendation:** Use soft deletes instead of hard deletes to preserve event history.
|
|
220
487
|
|
|
221
|
-
|
|
488
|
+
**Why?**
|
|
489
|
+
- Events are linked to aggregates via foreign keys
|
|
490
|
+
- Hard deleting a record can orphan its events
|
|
491
|
+
- Event log becomes incomplete
|
|
492
|
+
- Cannot reconstruct historical state
|
|
222
493
|
|
|
223
|
-
|
|
494
|
+
**How to implement:**
|
|
224
495
|
|
|
225
496
|
```ruby
|
|
497
|
+
# Migration
|
|
498
|
+
class AddDeletedAtToCustomers < ActiveRecord::Migration[7.0]
|
|
499
|
+
def change
|
|
500
|
+
add_column :customers, :deleted_at, :datetime
|
|
501
|
+
add_index :customers, :deleted_at
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Model
|
|
226
506
|
class Customer < ApplicationRecord
|
|
227
507
|
include RailsSimpleEventSourcing::Events
|
|
508
|
+
|
|
509
|
+
scope :active, -> { where(deleted_at: nil) }
|
|
510
|
+
scope :deleted, -> { where.not(deleted_at: nil) }
|
|
511
|
+
|
|
512
|
+
def soft_delete
|
|
513
|
+
update(deleted_at: Time.current)
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
# Event
|
|
518
|
+
class Customer::Events::CustomerDeleted < RailsSimpleEventSourcing::Event
|
|
519
|
+
aggregate_class Customer
|
|
520
|
+
event_attributes :deleted_at
|
|
521
|
+
|
|
522
|
+
# No need to implement apply - deleted_at will be automatically set on the aggregate
|
|
228
523
|
end
|
|
229
524
|
```
|
|
230
525
|
|
|
231
|
-
|
|
526
|
+
## Testing
|
|
232
527
|
|
|
233
|
-
|
|
528
|
+
### Testing Commands
|
|
234
529
|
|
|
235
|
-
|
|
530
|
+
```ruby
|
|
531
|
+
require "test_helper"
|
|
236
532
|
|
|
237
|
-
|
|
238
|
-
|
|
533
|
+
class Customer::Commands::CreateTest < ActiveSupport::TestCase
|
|
534
|
+
test "valid command" do
|
|
535
|
+
cmd = Customer::Commands::Create.new(
|
|
536
|
+
first_name: "John",
|
|
537
|
+
last_name: "Doe",
|
|
538
|
+
email: "john@example.com"
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
assert cmd.valid?
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
test "invalid without email" do
|
|
545
|
+
cmd = Customer::Commands::Create.new(
|
|
546
|
+
first_name: "John",
|
|
547
|
+
last_name: "Doe"
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
assert_not cmd.valid?
|
|
551
|
+
assert_includes cmd.errors[:email], "can't be blank"
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
### Testing Command Handlers
|
|
239
557
|
|
|
240
558
|
```ruby
|
|
241
|
-
|
|
559
|
+
require "test_helper"
|
|
560
|
+
|
|
561
|
+
class Customer::CommandHandlers::CreateTest < ActiveSupport::TestCase
|
|
562
|
+
test "creates customer and event" do
|
|
563
|
+
cmd = Customer::Commands::Create.new(
|
|
564
|
+
first_name: "John",
|
|
565
|
+
last_name: "Doe",
|
|
566
|
+
email: "john@example.com"
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
result = RailsSimpleEventSourcing::CommandHandler.new(cmd).call
|
|
570
|
+
|
|
571
|
+
assert result.success?
|
|
572
|
+
assert_instance_of Customer, result.data
|
|
573
|
+
assert_equal "John", result.data.first_name
|
|
574
|
+
assert_equal 1, result.data.events.count
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
test "handles duplicate email" do
|
|
578
|
+
# Create first customer
|
|
579
|
+
Customer::Events::CustomerCreated.create(
|
|
580
|
+
first_name: "Jane",
|
|
581
|
+
last_name: "Doe",
|
|
582
|
+
email: "john@example.com"
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
# Try to create duplicate
|
|
586
|
+
cmd = Customer::Commands::Create.new(
|
|
587
|
+
first_name: "John",
|
|
588
|
+
last_name: "Doe",
|
|
589
|
+
email: "john@example.com"
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
result = RailsSimpleEventSourcing::CommandHandler.new(cmd).call
|
|
593
|
+
|
|
594
|
+
assert_not result.success?
|
|
595
|
+
assert_includes result.errors, "Email has already been taken"
|
|
596
|
+
end
|
|
597
|
+
end
|
|
242
598
|
```
|
|
243
599
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
600
|
+
### Testing in Controllers
|
|
601
|
+
|
|
602
|
+
```ruby
|
|
603
|
+
require "test_helper"
|
|
604
|
+
|
|
605
|
+
class CustomersControllerTest < ActionDispatch::IntegrationTest
|
|
606
|
+
test "creates customer" do
|
|
607
|
+
post customers_url, params: {
|
|
608
|
+
first_name: "John",
|
|
609
|
+
last_name: "Doe",
|
|
610
|
+
email: "john@example.com"
|
|
611
|
+
}, as: :json
|
|
612
|
+
|
|
613
|
+
assert_response :success
|
|
614
|
+
assert_equal "John", JSON.parse(response.body)["first_name"]
|
|
615
|
+
end
|
|
616
|
+
end
|
|
247
617
|
```
|
|
248
618
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
619
|
+
## Limitations
|
|
620
|
+
|
|
621
|
+
Be aware of these limitations when using this gem:
|
|
622
|
+
|
|
623
|
+
- **PostgreSQL Only** - Requires PostgreSQL 9.4+ for JSONB support
|
|
624
|
+
- **No Event Versioning** - No built-in support for evolving event schemas over time
|
|
625
|
+
- **No Snapshots** - All aggregate reconstruction done by replaying all events (can be slow for aggregates with many events)
|
|
626
|
+
- **No Projections** - No built-in read model or projection support
|
|
627
|
+
- **Manual aggregate_id** - Must manually track and pass `aggregate_id` for updates/deletes
|
|
628
|
+
- **No Saga Support** - No built-in support for long-running processes or sagas
|
|
629
|
+
- **Single Database** - Events and aggregates must be in the same database
|
|
630
|
+
|
|
631
|
+
## Troubleshooting
|
|
632
|
+
|
|
633
|
+
### CommandHandlerNotFoundError
|
|
634
|
+
|
|
635
|
+
**Error:** `RailsSimpleEventSourcing::CommandHandler::CommandHandlerNotFoundError: Handler Customer::CommandHandlers::Create not found`
|
|
636
|
+
|
|
637
|
+
**Cause:** The command handler class doesn't follow the naming convention.
|
|
638
|
+
|
|
639
|
+
**Solution:** Ensure your handler namespace matches your command namespace:
|
|
640
|
+
- Command: `Customer::Commands::Create`
|
|
641
|
+
- Handler: `Customer::CommandHandlers::Create` (not `CustomerCommandHandlers::Create`)
|
|
642
|
+
|
|
643
|
+
### undefined method 'events' for Customer
|
|
644
|
+
|
|
645
|
+
**Error:** `undefined method 'events' for #<Customer>`
|
|
646
|
+
|
|
647
|
+
**Cause:** The model doesn't include the `RailsSimpleEventSourcing::Events` module.
|
|
648
|
+
|
|
649
|
+
**Solution:**
|
|
650
|
+
```ruby
|
|
651
|
+
class Customer < ApplicationRecord
|
|
652
|
+
include RailsSimpleEventSourcing::Events
|
|
653
|
+
end
|
|
252
654
|
```
|
|
253
655
|
|
|
254
|
-
|
|
656
|
+
### ActiveRecord::ReadOnlyRecord when updating model
|
|
657
|
+
|
|
658
|
+
**Error:** `ActiveRecord::ReadOnlyRecord: Customer is marked as readonly`
|
|
659
|
+
|
|
660
|
+
**Cause:** Trying to directly modify a model that uses event sourcing.
|
|
661
|
+
|
|
662
|
+
**Solution:** Create an event instead:
|
|
255
663
|
```ruby
|
|
256
|
-
|
|
664
|
+
# Don't do this:
|
|
665
|
+
customer.update(first_name: "Jane")
|
|
666
|
+
|
|
667
|
+
# Do this:
|
|
668
|
+
cmd = Customer::Commands::Update.new(aggregate_id: customer.id, first_name: "Jane", ...)
|
|
669
|
+
RailsSimpleEventSourcing::CommandHandler.new(cmd).call
|
|
257
670
|
```
|
|
258
671
|
|
|
259
|
-
|
|
672
|
+
### Missing aggregate_id for updates
|
|
673
|
+
|
|
674
|
+
**Error:** `undefined method 'id' for nil:NilClass`
|
|
675
|
+
|
|
676
|
+
**Cause:** Forgot to pass `aggregate_id` to update/delete commands.
|
|
677
|
+
|
|
678
|
+
**Solution:**
|
|
260
679
|
```ruby
|
|
261
|
-
|
|
680
|
+
# Include aggregate_id in the command
|
|
681
|
+
cmd = Customer::Commands::Update.new(
|
|
682
|
+
aggregate_id: params[:id], # This is required
|
|
683
|
+
first_name: params[:first_name],
|
|
684
|
+
# ...
|
|
685
|
+
)
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
### Metadata is empty in tests
|
|
689
|
+
|
|
690
|
+
**Issue:** Event metadata is empty when creating events in tests.
|
|
691
|
+
|
|
692
|
+
**Cause:** Events created outside HTTP requests don't have automatic metadata.
|
|
693
|
+
|
|
694
|
+
**Solution:**
|
|
695
|
+
```ruby
|
|
696
|
+
# Manually set metadata in tests
|
|
697
|
+
RailsSimpleEventSourcing::CurrentRequest.metadata = {
|
|
698
|
+
request_id: "test-123",
|
|
699
|
+
test_mode: true
|
|
700
|
+
}
|
|
262
701
|
```
|
|
263
702
|
|
|
264
703
|
## Contributing
|
|
265
|
-
|
|
704
|
+
|
|
705
|
+
Contributions are welcome! Here's how you can help:
|
|
706
|
+
|
|
707
|
+
1. **Report Bugs**: Open an issue on GitHub with:
|
|
708
|
+
- Steps to reproduce
|
|
709
|
+
- Expected vs actual behavior
|
|
710
|
+
- Ruby/Rails/PostgreSQL versions
|
|
711
|
+
|
|
712
|
+
2. **Submit Pull Requests**:
|
|
713
|
+
- Fork the repository
|
|
714
|
+
- Create a feature branch (`git checkout -b feature/my-feature`)
|
|
715
|
+
- Write tests for your changes
|
|
716
|
+
- Ensure all tests pass (`rake test`)
|
|
717
|
+
- Follow existing code style
|
|
718
|
+
- Commit with clear messages
|
|
719
|
+
- Push and open a PR
|
|
720
|
+
|
|
721
|
+
3. **Running Tests**:
|
|
722
|
+
```bash
|
|
723
|
+
bundle install
|
|
724
|
+
cd test/dummy
|
|
725
|
+
rails db:create db:migrate RAILS_ENV=test
|
|
726
|
+
cd ../..
|
|
727
|
+
rake test
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
4. **Code Style**:
|
|
731
|
+
- Follow Ruby style guide
|
|
732
|
+
- Use RuboCop for linting
|
|
733
|
+
- Write clear, descriptive variable/method names
|
|
734
|
+
- Add comments for complex logic
|
|
735
|
+
|
|
736
|
+
### More Examples
|
|
737
|
+
|
|
738
|
+
See the `test/dummy/app/domain/customer/` directory for complete examples of:
|
|
739
|
+
- Commands (create, update, delete)
|
|
740
|
+
- Command handlers with error handling
|
|
741
|
+
- Events (created, updated, deleted)
|
|
742
|
+
- Controller integration
|
|
266
743
|
|
|
267
744
|
## License
|
|
745
|
+
|
|
268
746
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsSimpleEventSourcing
|
|
4
|
+
module AggregateConfiguration
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
class_methods do
|
|
8
|
+
def aggregate_class(name = nil)
|
|
9
|
+
return @aggregate_class if name.nil?
|
|
10
|
+
|
|
11
|
+
@aggregate_class = name
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def aggregate_class
|
|
16
|
+
self.class.aggregate_class
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def aggregate_defined?
|
|
20
|
+
aggregate_class.present?
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsSimpleEventSourcing
|
|
4
|
+
module EventAttributes
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
class_methods do
|
|
8
|
+
def event_attributes(*attributes)
|
|
9
|
+
attributes.each do |attribute|
|
|
10
|
+
define_payload_accessor(attribute)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def define_payload_accessor(attribute)
|
|
17
|
+
define_method(attribute) do
|
|
18
|
+
self.payload ||= {}
|
|
19
|
+
self.payload[attribute.to_s]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
define_method("#{attribute}=") do |value|
|
|
23
|
+
self.payload ||= {}
|
|
24
|
+
self.payload[attribute.to_s] = value
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -2,85 +2,51 @@
|
|
|
2
2
|
|
|
3
3
|
module RailsSimpleEventSourcing
|
|
4
4
|
class Event < ApplicationRecord
|
|
5
|
+
prepend ApplyWithReturningAggregate
|
|
5
6
|
include ReadOnly
|
|
7
|
+
include EventAttributes
|
|
8
|
+
include AggregateConfiguration
|
|
6
9
|
|
|
7
10
|
belongs_to :eventable, polymorphic: true, optional: true
|
|
8
|
-
|
|
9
11
|
alias aggregate eventable
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
before_validation :
|
|
13
|
-
before_validation :
|
|
14
|
-
before_save :
|
|
15
|
-
before_save :assing_aggregate_id_and_persist_aggregate, if: :aggregate_defined?
|
|
16
|
-
|
|
17
|
-
def self.aggregate_model_name(name)
|
|
18
|
-
singleton_class.instance_variable_set(:@aggregate_model_name, name)
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def aggregate_model_name
|
|
22
|
-
self.class.singleton_class.instance_variable_get(:@aggregate_model_name)
|
|
23
|
-
end
|
|
13
|
+
# Callbacks for automatic aggregate lifecycle
|
|
14
|
+
before_validation :setup_event_fields, on: :create
|
|
15
|
+
before_validation :apply_event_to_aggregate, on: :create, if: :aggregate_defined?
|
|
16
|
+
before_save :persist_aggregate, if: :aggregate_defined?
|
|
24
17
|
|
|
25
|
-
def
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
attributes.map(&:to_s).each do |attribute|
|
|
29
|
-
define_method attribute do
|
|
30
|
-
self.payload ||= {}
|
|
31
|
-
self.payload[attribute]
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
define_method "#{attribute}=" do |argument|
|
|
35
|
-
self.payload ||= {}
|
|
36
|
-
self.payload[attribute] = argument
|
|
37
|
-
end
|
|
18
|
+
def apply(aggregate)
|
|
19
|
+
payload.each do |key, value|
|
|
20
|
+
aggregate.send("#{key}=", value) if aggregate.respond_to?("#{key}=") && value.present?
|
|
38
21
|
end
|
|
39
|
-
|
|
40
|
-
@event_attributes
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def apply(_aggregate)
|
|
44
|
-
raise NoMethodError, "You must implement #{self.class}#apply"
|
|
22
|
+
aggregate
|
|
45
23
|
end
|
|
46
24
|
|
|
47
25
|
private
|
|
48
26
|
|
|
49
|
-
def
|
|
50
|
-
aggregate_model_name.present?
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def initialize_event
|
|
54
|
-
self.class.prepend RailsSimpleEventSourcing::ApplyWithReturningAggregate
|
|
55
|
-
@aggregate = find_or_build_aggregate if aggregate_defined?
|
|
56
|
-
self.event_type = self.class
|
|
57
|
-
self.eventable = @aggregate
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def enable_write_access_on_self
|
|
27
|
+
def setup_event_fields
|
|
61
28
|
enable_write_access!
|
|
29
|
+
self.event_type = self.class
|
|
30
|
+
self.metadata = CurrentRequest.metadata&.compact&.presence
|
|
62
31
|
end
|
|
63
32
|
|
|
64
|
-
def
|
|
65
|
-
@
|
|
66
|
-
|
|
67
|
-
end
|
|
33
|
+
def apply_event_to_aggregate
|
|
34
|
+
@aggregate_for_persistence = aggregate_repository.find_or_build(aggregate_id)
|
|
35
|
+
self.eventable = @aggregate_for_persistence
|
|
68
36
|
|
|
69
|
-
|
|
70
|
-
@
|
|
71
|
-
self.aggregate_id = @aggregate.id
|
|
37
|
+
applicator = EventApplicator.new(self)
|
|
38
|
+
applicator.apply_to_aggregate(@aggregate_for_persistence)
|
|
72
39
|
end
|
|
73
40
|
|
|
74
|
-
def
|
|
75
|
-
return
|
|
41
|
+
def persist_aggregate
|
|
42
|
+
return unless @aggregate_for_persistence
|
|
76
43
|
|
|
77
|
-
|
|
44
|
+
aggregate_repository.save!(@aggregate_for_persistence) if aggregate_id.present?
|
|
45
|
+
self.aggregate_id = @aggregate_for_persistence.id
|
|
78
46
|
end
|
|
79
47
|
|
|
80
|
-
def
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
aggregate_model_name.new
|
|
48
|
+
def aggregate_repository
|
|
49
|
+
@aggregate_repository ||= AggregateRepository.new(aggregate_class)
|
|
84
50
|
end
|
|
85
51
|
end
|
|
86
52
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsSimpleEventSourcing
|
|
4
|
+
class AggregateRepository
|
|
5
|
+
def initialize(aggregate_class)
|
|
6
|
+
@aggregate_class = aggregate_class
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def find_or_build(aggregate_id)
|
|
10
|
+
if aggregate_id.present?
|
|
11
|
+
find_with_lock(aggregate_id)
|
|
12
|
+
else
|
|
13
|
+
build_new
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def save!(aggregate)
|
|
18
|
+
aggregate.enable_write_access!
|
|
19
|
+
aggregate.save!
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def find_with_lock(aggregate_id)
|
|
25
|
+
@aggregate_class.find(aggregate_id).lock!
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def build_new
|
|
29
|
+
@aggregate_class.new
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module RailsSimpleEventSourcing
|
|
4
4
|
class CommandHandler
|
|
5
|
+
class CommandHandlerNotFoundError < StandardError; end
|
|
6
|
+
|
|
5
7
|
def initialize(command)
|
|
6
8
|
@command = command
|
|
7
9
|
end
|
|
@@ -15,7 +17,11 @@ module RailsSimpleEventSourcing
|
|
|
15
17
|
private
|
|
16
18
|
|
|
17
19
|
def initialize_command_handler
|
|
18
|
-
@command.class.to_s.gsub('::Commands::', '::CommandHandlers::')
|
|
20
|
+
handler_class_name = @command.class.to_s.gsub('::Commands::', '::CommandHandlers::')
|
|
21
|
+
handler_class = handler_class_name.safe_constantize
|
|
22
|
+
raise CommandHandlerNotFoundError, "Handler #{handler_class_name} not found" unless handler_class
|
|
23
|
+
|
|
24
|
+
handler_class.new(command: @command)
|
|
19
25
|
end
|
|
20
26
|
end
|
|
21
27
|
end
|
|
@@ -10,6 +10,14 @@ module RailsSimpleEventSourcing
|
|
|
10
10
|
def call
|
|
11
11
|
raise NoMethodError, "You must implement #{self.class}#call"
|
|
12
12
|
end
|
|
13
|
+
|
|
14
|
+
def success_result(data: nil)
|
|
15
|
+
RailsSimpleEventSourcing::Result.new(success?: true, data:)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def failure_result(errors:)
|
|
19
|
+
RailsSimpleEventSourcing::Result.new(success?: false, errors:)
|
|
20
|
+
end
|
|
13
21
|
end
|
|
14
22
|
end
|
|
15
23
|
end
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'aggregate_repository'
|
|
4
|
+
require_relative 'apply_with_returning_aggregate'
|
|
5
|
+
require_relative 'command_handler'
|
|
3
6
|
require_relative 'command_handlers/base'
|
|
4
7
|
require_relative 'commands/base'
|
|
5
|
-
require_relative '
|
|
6
|
-
require_relative '
|
|
8
|
+
require_relative 'event_applicator'
|
|
9
|
+
require_relative 'event_player'
|
|
7
10
|
require_relative 'result'
|
|
8
11
|
|
|
9
12
|
module RailsSimpleEventSourcing
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsSimpleEventSourcing
|
|
4
|
+
class EventApplicator
|
|
5
|
+
def initialize(event)
|
|
6
|
+
@event = event
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def apply_to_aggregate(aggregate)
|
|
10
|
+
enable_aggregate_writes(aggregate)
|
|
11
|
+
replay_history_if_needed(aggregate)
|
|
12
|
+
apply_current_event(aggregate)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def enable_aggregate_writes(aggregate)
|
|
18
|
+
aggregate.enable_write_access!
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def replay_history_if_needed(aggregate)
|
|
22
|
+
return if aggregate.new_record?
|
|
23
|
+
|
|
24
|
+
player = EventPlayer.new(aggregate)
|
|
25
|
+
player.replay_stream
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def apply_current_event(aggregate)
|
|
29
|
+
@event.apply(aggregate)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsSimpleEventSourcing
|
|
4
|
+
class EventPlayer
|
|
5
|
+
def initialize(aggregate)
|
|
6
|
+
@aggregate = aggregate
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def replay_stream
|
|
10
|
+
events = load_event_stream
|
|
11
|
+
apply_events(events)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def load_event_stream
|
|
17
|
+
@aggregate.events.order(created_at: :asc)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def apply_events(events)
|
|
21
|
+
events.each do |event|
|
|
22
|
+
event.apply(@aggregate)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_simple_event_sourcing
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0.
|
|
4
|
+
version: 1.0.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Damian Baćkowski
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-01-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: pg
|
|
@@ -78,6 +78,8 @@ files:
|
|
|
78
78
|
- Rakefile
|
|
79
79
|
- app/assets/config/rails_simple_event_sourcing_manifest.js
|
|
80
80
|
- app/controllers/concerns/rails_simple_event_sourcing/set_current_request_details.rb
|
|
81
|
+
- app/models/concerns/rails_simple_event_sourcing/aggregate_configuration.rb
|
|
82
|
+
- app/models/concerns/rails_simple_event_sourcing/event_attributes.rb
|
|
81
83
|
- app/models/concerns/rails_simple_event_sourcing/events.rb
|
|
82
84
|
- app/models/concerns/rails_simple_event_sourcing/read_only.rb
|
|
83
85
|
- app/models/rails_simple_event_sourcing.rb
|
|
@@ -86,11 +88,14 @@ files:
|
|
|
86
88
|
- config/routes.rb
|
|
87
89
|
- db/migrate/20231231133250_create_rails_simple_event_sourcing_events.rb
|
|
88
90
|
- lib/rails_simple_event_sourcing.rb
|
|
91
|
+
- lib/rails_simple_event_sourcing/aggregate_repository.rb
|
|
89
92
|
- lib/rails_simple_event_sourcing/apply_with_returning_aggregate.rb
|
|
90
93
|
- lib/rails_simple_event_sourcing/command_handler.rb
|
|
91
94
|
- lib/rails_simple_event_sourcing/command_handlers/base.rb
|
|
92
95
|
- lib/rails_simple_event_sourcing/commands/base.rb
|
|
93
96
|
- lib/rails_simple_event_sourcing/engine.rb
|
|
97
|
+
- lib/rails_simple_event_sourcing/event_applicator.rb
|
|
98
|
+
- lib/rails_simple_event_sourcing/event_player.rb
|
|
94
99
|
- lib/rails_simple_event_sourcing/result.rb
|
|
95
100
|
- lib/rails_simple_event_sourcing/version.rb
|
|
96
101
|
- lib/tasks/rails_simple_event_sourcing_tasks.rake
|
|
@@ -114,7 +119,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
114
119
|
- !ruby/object:Gem::Version
|
|
115
120
|
version: '0'
|
|
116
121
|
requirements: []
|
|
117
|
-
rubygems_version: 3.
|
|
122
|
+
rubygems_version: 3.4.21
|
|
118
123
|
signing_key:
|
|
119
124
|
specification_version: 4
|
|
120
125
|
summary: Rails engine for simple event sourcing.
|