rails_simple_event_sourcing 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +541 -93
- 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 +23 -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: 686785bc4c8f52ae548b2880cb664b57d5a048925cda0f4cfe0d2962acbfdb3e
|
|
4
|
+
data.tar.gz: 9555f13015e4aaacdfe8ce8b4866ea8d5f6e1a19d0d4f0766b1d4b9c5b36a64e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ba9de2c72e6ff9c242da76379ae476aa1139bba37b58eb60dced8bd6c37de83dc3335e9d2f031a6ccf3930e7211f1bafc96c65647550a020e7dcd71e262207d2
|
|
7
|
+
data.tar.gz: 691d056df008df794ae8b4833295185eb0ee6f568b2f28b19d2ddbea79bec4069b64143a1c4723c685f49030be97c1e0f964d53ae451551efd1fc6813f4f7c39
|
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
|
+
```
|
|
8
61
|
|
|
9
|
-
|
|
62
|
+
Copy migration to your app:
|
|
63
|
+
```bash
|
|
64
|
+
rails rails_simple_event_sourcing:install:migrations
|
|
65
|
+
```
|
|
10
66
|
|
|
11
|
-
|
|
67
|
+
Run the migration to create the events table:
|
|
68
|
+
```bash
|
|
69
|
+
rake db:migrate
|
|
70
|
+
```
|
|
71
|
+
|
|
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:
|
|
79
|
+
|
|
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
|
|
16
96
|
|
|
17
|
-
Let's start with the directory structure:
|
|
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
|
|
111
|
+
**Note:** The top directory name (`domain/`) can be different - Rails doesn't enforce this namespace.
|
|
32
112
|
|
|
33
|
-
|
|
113
|
+
### Commands
|
|
34
114
|
|
|
35
|
-
|
|
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
|
|
36
119
|
|
|
37
|
-
|
|
120
|
+
Think of commands as "requests to do something" - they describe what you want to happen, not how it happens.
|
|
38
121
|
|
|
39
|
-
|
|
40
|
-
|
|
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,22 +196,39 @@ 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
|
+
- Specify how to apply themselves to the aggregate
|
|
217
|
+
- Are stored permanently in the event log
|
|
87
218
|
|
|
88
|
-
|
|
219
|
+
**Key Concepts:**
|
|
220
|
+
- `aggregate_class` - The model this event applies to (optional - some events may not modify models)
|
|
221
|
+
- `event_attributes` - Fields stored in the event payload
|
|
222
|
+
- `apply(aggregate)` - Method that applies the event to an aggregate instance
|
|
223
|
+
- `aggregate_id` - Links the event to a specific aggregate instance
|
|
224
|
+
|
|
225
|
+
**Example - Create Event:**
|
|
89
226
|
|
|
90
227
|
```ruby
|
|
91
228
|
class Customer
|
|
92
229
|
module Events
|
|
93
230
|
class CustomerCreated < RailsSimpleEventSourcing::Event
|
|
94
|
-
|
|
231
|
+
aggregate_class Customer
|
|
95
232
|
event_attributes :first_name, :last_name, :email, :created_at, :updated_at
|
|
96
233
|
|
|
97
234
|
def apply(aggregate)
|
|
@@ -107,11 +244,19 @@ class Customer
|
|
|
107
244
|
end
|
|
108
245
|
```
|
|
109
246
|
|
|
110
|
-
|
|
111
|
-
- `
|
|
112
|
-
- `event_attributes` -
|
|
247
|
+
**Understanding the Event Structure:**
|
|
248
|
+
- `aggregate_class Customer` - Specifies which model this event modifies
|
|
249
|
+
- `event_attributes` - Defines what data gets stored in the event's JSON payload
|
|
250
|
+
- `apply(aggregate)` - Receives an aggregate instance and applies the event's changes to it
|
|
251
|
+
- `aggregate_id` - Auto-generated for creates, must be provided for updates/deletes
|
|
252
|
+
|
|
253
|
+
**Note on aggregate_class:**
|
|
254
|
+
- Optional - you can have events without an aggregate (e.g., `UserLoginFailed` for logging only)
|
|
255
|
+
- The corresponding model should include `RailsSimpleEventSourcing::Events` for read-only protection
|
|
256
|
+
|
|
257
|
+
### Controller Integration
|
|
113
258
|
|
|
114
|
-
Here
|
|
259
|
+
Here's how to wire everything together in a controller:
|
|
115
260
|
|
|
116
261
|
```ruby
|
|
117
262
|
class CustomersController < ApplicationController
|
|
@@ -132,15 +277,60 @@ class CustomersController < ApplicationController
|
|
|
132
277
|
end
|
|
133
278
|
```
|
|
134
279
|
|
|
135
|
-
|
|
280
|
+
### Update and Delete Operations
|
|
281
|
+
|
|
282
|
+
**Update Example:**
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
class CustomersController < ApplicationController
|
|
286
|
+
def update
|
|
287
|
+
cmd = Customer::Commands::Update.new(
|
|
288
|
+
aggregate_id: params[:id],
|
|
289
|
+
first_name: params[:first_name],
|
|
290
|
+
last_name: params[:last_name],
|
|
291
|
+
email: params[:email]
|
|
292
|
+
)
|
|
293
|
+
handler = RailsSimpleEventSourcing::CommandHandler.new(cmd).call
|
|
294
|
+
|
|
295
|
+
if handler.success?
|
|
296
|
+
render json: handler.data
|
|
297
|
+
else
|
|
298
|
+
render json: { errors: handler.errors }, status: :unprocessable_entity
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
**Delete Example:**
|
|
305
|
+
|
|
306
|
+
```ruby
|
|
307
|
+
class CustomersController < ApplicationController
|
|
308
|
+
def destroy
|
|
309
|
+
cmd = Customer::Commands::Delete.new(aggregate_id: params[:id])
|
|
310
|
+
handler = RailsSimpleEventSourcing::CommandHandler.new(cmd).call
|
|
311
|
+
|
|
312
|
+
if handler.success?
|
|
313
|
+
head :no_content
|
|
314
|
+
else
|
|
315
|
+
render json: { errors: handler.errors }, status: :unprocessable_entity
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
**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/`.
|
|
322
|
+
|
|
323
|
+
### Testing the API
|
|
324
|
+
|
|
325
|
+
Create a customer using curl:
|
|
136
326
|
|
|
137
327
|
```sh
|
|
138
328
|
curl -X POST http://localhost:3000/customers \
|
|
139
329
|
-H 'Content-Type: application/json' \
|
|
140
|
-
-d '{ "first_name": "John", "last_name": "Doe" }' | jq
|
|
330
|
+
-d '{ "first_name": "John", "last_name": "Doe", "email": "john@example.com" }' | jq
|
|
141
331
|
```
|
|
142
332
|
|
|
143
|
-
|
|
333
|
+
Response:
|
|
144
334
|
|
|
145
335
|
```json
|
|
146
336
|
{
|
|
@@ -152,43 +342,51 @@ You will get the response:
|
|
|
152
342
|
}
|
|
153
343
|
```
|
|
154
344
|
|
|
155
|
-
|
|
345
|
+
### Event Querying
|
|
346
|
+
|
|
347
|
+
Open the Rails console (`rails c`) to explore the event log:
|
|
156
348
|
|
|
157
349
|
```ruby
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
#<Customer:
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
350
|
+
# Get the customer
|
|
351
|
+
customer = Customer.last
|
|
352
|
+
# => #<Customer id: 1, first_name: "John", last_name: "Doe", ...>
|
|
353
|
+
|
|
354
|
+
# Access all events for this customer
|
|
355
|
+
customer.events
|
|
356
|
+
# => [#<Customer::Events::CustomerCreated...>]
|
|
357
|
+
|
|
358
|
+
# Get specific event details
|
|
359
|
+
event = customer.events.first
|
|
360
|
+
event.payload
|
|
361
|
+
# => {"first_name"=>"John", "last_name"=>"Doe", "email"=>"john@example.com", ...}
|
|
362
|
+
|
|
363
|
+
event.metadata
|
|
364
|
+
# => {"request_id"=>"2a40d4f9-509b-4b49-a39f-d978679fa5ef",
|
|
365
|
+
# "request_ip"=>"::1",
|
|
366
|
+
# "request_user_agent"=>"curl/8.6.0", ...}
|
|
367
|
+
|
|
368
|
+
# Query events by type
|
|
369
|
+
RailsSimpleEventSourcing::Event.where(event_type: "Customer::Events::CustomerCreated")
|
|
370
|
+
|
|
371
|
+
# Get events in a date range
|
|
372
|
+
customer.events.where(created_at: 1.week.ago..Time.now)
|
|
373
|
+
|
|
374
|
+
# Get all events for a specific aggregate
|
|
375
|
+
RailsSimpleEventSourcing::Event.where(eventable_type: "Customer", eventable_id: 1)
|
|
182
376
|
```
|
|
183
377
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
378
|
+
**Event Structure:**
|
|
379
|
+
- `payload` - Contains the event attributes you defined (as JSON)
|
|
380
|
+
- `metadata` - Contains request context (request ID, IP, user agent, params)
|
|
381
|
+
- `event_type` - The event class name
|
|
382
|
+
- `aggregate_id` - Links to the aggregate instance
|
|
383
|
+
- `eventable` - Polymorphic relation to the aggregate
|
|
187
384
|
|
|
188
|
-
|
|
385
|
+
### Metadata Tracking
|
|
189
386
|
|
|
190
|
-
|
|
387
|
+
To automatically capture request metadata (IP address, user agent, request ID, etc.), include the concern in your ApplicationController:
|
|
191
388
|
|
|
389
|
+
**Setup:**
|
|
192
390
|
|
|
193
391
|
```ruby
|
|
194
392
|
class ApplicationController < ActionController::Base
|
|
@@ -196,73 +394,323 @@ class ApplicationController < ActionController::Base
|
|
|
196
394
|
end
|
|
197
395
|
```
|
|
198
396
|
|
|
199
|
-
|
|
397
|
+
**Default Metadata:**
|
|
398
|
+
By default, the following is captured:
|
|
399
|
+
- `request_id` - Unique request identifier
|
|
400
|
+
- `request_user_agent` - Client user agent
|
|
401
|
+
- `request_referer` - HTTP referer
|
|
402
|
+
- `request_ip` - Client IP address
|
|
403
|
+
- `request_params` - Request parameters (filtered using Rails parameter filter)
|
|
200
404
|
|
|
201
|
-
|
|
405
|
+
**Customizing Metadata:**
|
|
406
|
+
Override the `event_metadata` method in your controller:
|
|
202
407
|
|
|
203
408
|
```ruby
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
409
|
+
class ApplicationController < ActionController::Base
|
|
410
|
+
include RailsSimpleEventSourcing::SetCurrentRequestDetails
|
|
411
|
+
|
|
412
|
+
def event_metadata
|
|
413
|
+
parameter_filter = ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters)
|
|
414
|
+
|
|
415
|
+
{
|
|
416
|
+
request_id: request.uuid,
|
|
417
|
+
request_user_agent: request.user_agent,
|
|
418
|
+
request_ip: request.ip,
|
|
419
|
+
request_params: parameter_filter.filter(request.params),
|
|
420
|
+
current_user_id: current_user&.id, # Add custom fields
|
|
421
|
+
tenant_id: current_tenant&.id
|
|
422
|
+
}
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
**Metadata Outside HTTP Requests:**
|
|
428
|
+
When events are created outside HTTP requests (background jobs, console, tests), metadata will be empty unless you manually set it using `CurrentRequest.metadata = {...}`.
|
|
429
|
+
|
|
430
|
+
### Model Configuration
|
|
431
|
+
|
|
432
|
+
Models that use event sourcing should include the `RailsSimpleEventSourcing::Events` module:
|
|
433
|
+
|
|
434
|
+
```ruby
|
|
435
|
+
class Customer < ApplicationRecord
|
|
436
|
+
include RailsSimpleEventSourcing::Events
|
|
214
437
|
end
|
|
215
438
|
```
|
|
216
439
|
|
|
217
|
-
|
|
440
|
+
**This provides:**
|
|
441
|
+
- `.events` association - Access all events for this aggregate
|
|
442
|
+
- Read-only protection - Prevents accidental direct modifications
|
|
443
|
+
- Event replay capability - Reconstruct state from events
|
|
218
444
|
|
|
219
|
-
|
|
445
|
+
### Immutability and Read-Only Protection
|
|
220
446
|
|
|
221
|
-
|
|
447
|
+
**Important Principles:**
|
|
448
|
+
- **Events are immutable** - Once created, events should never be modified
|
|
449
|
+
- **Models are read-only** - Aggregates should only be modified through events
|
|
450
|
+
- Both have built-in protection against accidental changes
|
|
222
451
|
|
|
223
|
-
|
|
452
|
+
### Soft Deletes
|
|
453
|
+
|
|
454
|
+
**Recommendation:** Use soft deletes instead of hard deletes to preserve event history.
|
|
455
|
+
|
|
456
|
+
**Why?**
|
|
457
|
+
- Events are linked to aggregates via foreign keys
|
|
458
|
+
- Hard deleting a record can orphan its events
|
|
459
|
+
- Event log becomes incomplete
|
|
460
|
+
- Cannot reconstruct historical state
|
|
461
|
+
|
|
462
|
+
**How to implement:**
|
|
224
463
|
|
|
225
464
|
```ruby
|
|
465
|
+
# Migration
|
|
466
|
+
class AddDeletedAtToCustomers < ActiveRecord::Migration[7.0]
|
|
467
|
+
def change
|
|
468
|
+
add_column :customers, :deleted_at, :datetime
|
|
469
|
+
add_index :customers, :deleted_at
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# Model
|
|
226
474
|
class Customer < ApplicationRecord
|
|
227
475
|
include RailsSimpleEventSourcing::Events
|
|
476
|
+
|
|
477
|
+
scope :active, -> { where(deleted_at: nil) }
|
|
478
|
+
scope :deleted, -> { where.not(deleted_at: nil) }
|
|
479
|
+
|
|
480
|
+
def soft_delete
|
|
481
|
+
update(deleted_at: Time.current)
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Event
|
|
486
|
+
class Customer::Events::CustomerDeleted < RailsSimpleEventSourcing::Event
|
|
487
|
+
aggregate_class Customer
|
|
488
|
+
event_attributes :deleted_at
|
|
489
|
+
|
|
490
|
+
def apply(aggregate)
|
|
491
|
+
aggregate.deleted_at = deleted_at
|
|
492
|
+
end
|
|
228
493
|
end
|
|
229
494
|
```
|
|
230
495
|
|
|
231
|
-
|
|
496
|
+
## Testing
|
|
232
497
|
|
|
233
|
-
|
|
498
|
+
### Testing Commands
|
|
234
499
|
|
|
235
|
-
|
|
500
|
+
```ruby
|
|
501
|
+
require "test_helper"
|
|
236
502
|
|
|
237
|
-
|
|
238
|
-
|
|
503
|
+
class Customer::Commands::CreateTest < ActiveSupport::TestCase
|
|
504
|
+
test "valid command" do
|
|
505
|
+
cmd = Customer::Commands::Create.new(
|
|
506
|
+
first_name: "John",
|
|
507
|
+
last_name: "Doe",
|
|
508
|
+
email: "john@example.com"
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
assert cmd.valid?
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
test "invalid without email" do
|
|
515
|
+
cmd = Customer::Commands::Create.new(
|
|
516
|
+
first_name: "John",
|
|
517
|
+
last_name: "Doe"
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
assert_not cmd.valid?
|
|
521
|
+
assert_includes cmd.errors[:email], "can't be blank"
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
### Testing Command Handlers
|
|
239
527
|
|
|
240
528
|
```ruby
|
|
241
|
-
|
|
529
|
+
require "test_helper"
|
|
530
|
+
|
|
531
|
+
class Customer::CommandHandlers::CreateTest < ActiveSupport::TestCase
|
|
532
|
+
test "creates customer and event" do
|
|
533
|
+
cmd = Customer::Commands::Create.new(
|
|
534
|
+
first_name: "John",
|
|
535
|
+
last_name: "Doe",
|
|
536
|
+
email: "john@example.com"
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
result = RailsSimpleEventSourcing::CommandHandler.new(cmd).call
|
|
540
|
+
|
|
541
|
+
assert result.success?
|
|
542
|
+
assert_instance_of Customer, result.data
|
|
543
|
+
assert_equal "John", result.data.first_name
|
|
544
|
+
assert_equal 1, result.data.events.count
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
test "handles duplicate email" do
|
|
548
|
+
# Create first customer
|
|
549
|
+
Customer::Events::CustomerCreated.create(
|
|
550
|
+
first_name: "Jane",
|
|
551
|
+
last_name: "Doe",
|
|
552
|
+
email: "john@example.com"
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
# Try to create duplicate
|
|
556
|
+
cmd = Customer::Commands::Create.new(
|
|
557
|
+
first_name: "John",
|
|
558
|
+
last_name: "Doe",
|
|
559
|
+
email: "john@example.com"
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
result = RailsSimpleEventSourcing::CommandHandler.new(cmd).call
|
|
563
|
+
|
|
564
|
+
assert_not result.success?
|
|
565
|
+
assert_includes result.errors, "Email has already been taken"
|
|
566
|
+
end
|
|
567
|
+
end
|
|
242
568
|
```
|
|
243
569
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
570
|
+
### Testing in Controllers
|
|
571
|
+
|
|
572
|
+
```ruby
|
|
573
|
+
require "test_helper"
|
|
574
|
+
|
|
575
|
+
class CustomersControllerTest < ActionDispatch::IntegrationTest
|
|
576
|
+
test "creates customer" do
|
|
577
|
+
post customers_url, params: {
|
|
578
|
+
first_name: "John",
|
|
579
|
+
last_name: "Doe",
|
|
580
|
+
email: "john@example.com"
|
|
581
|
+
}, as: :json
|
|
582
|
+
|
|
583
|
+
assert_response :success
|
|
584
|
+
assert_equal "John", JSON.parse(response.body)["first_name"]
|
|
585
|
+
end
|
|
586
|
+
end
|
|
247
587
|
```
|
|
248
588
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
589
|
+
## Limitations
|
|
590
|
+
|
|
591
|
+
Be aware of these limitations when using this gem:
|
|
592
|
+
|
|
593
|
+
- **PostgreSQL Only** - Requires PostgreSQL 9.4+ for JSONB support
|
|
594
|
+
- **No Event Versioning** - No built-in support for evolving event schemas over time
|
|
595
|
+
- **No Snapshots** - All aggregate reconstruction done by replaying all events (can be slow for aggregates with many events)
|
|
596
|
+
- **No Projections** - No built-in read model or projection support
|
|
597
|
+
- **Manual aggregate_id** - Must manually track and pass `aggregate_id` for updates/deletes
|
|
598
|
+
- **No Saga Support** - No built-in support for long-running processes or sagas
|
|
599
|
+
- **Single Database** - Events and aggregates must be in the same database
|
|
600
|
+
|
|
601
|
+
## Troubleshooting
|
|
602
|
+
|
|
603
|
+
### CommandHandlerNotFoundError
|
|
604
|
+
|
|
605
|
+
**Error:** `RailsSimpleEventSourcing::CommandHandler::CommandHandlerNotFoundError: Handler Customer::CommandHandlers::Create not found`
|
|
606
|
+
|
|
607
|
+
**Cause:** The command handler class doesn't follow the naming convention.
|
|
608
|
+
|
|
609
|
+
**Solution:** Ensure your handler namespace matches your command namespace:
|
|
610
|
+
- Command: `Customer::Commands::Create`
|
|
611
|
+
- Handler: `Customer::CommandHandlers::Create` (not `CustomerCommandHandlers::Create`)
|
|
612
|
+
|
|
613
|
+
### undefined method 'events' for Customer
|
|
614
|
+
|
|
615
|
+
**Error:** `undefined method 'events' for #<Customer>`
|
|
616
|
+
|
|
617
|
+
**Cause:** The model doesn't include the `RailsSimpleEventSourcing::Events` module.
|
|
618
|
+
|
|
619
|
+
**Solution:**
|
|
620
|
+
```ruby
|
|
621
|
+
class Customer < ApplicationRecord
|
|
622
|
+
include RailsSimpleEventSourcing::Events
|
|
623
|
+
end
|
|
252
624
|
```
|
|
253
625
|
|
|
254
|
-
|
|
626
|
+
### ActiveRecord::ReadOnlyRecord when updating model
|
|
627
|
+
|
|
628
|
+
**Error:** `ActiveRecord::ReadOnlyRecord: Customer is marked as readonly`
|
|
629
|
+
|
|
630
|
+
**Cause:** Trying to directly modify a model that uses event sourcing.
|
|
631
|
+
|
|
632
|
+
**Solution:** Create an event instead:
|
|
255
633
|
```ruby
|
|
256
|
-
|
|
634
|
+
# Don't do this:
|
|
635
|
+
customer.update(first_name: "Jane")
|
|
636
|
+
|
|
637
|
+
# Do this:
|
|
638
|
+
cmd = Customer::Commands::Update.new(aggregate_id: customer.id, first_name: "Jane", ...)
|
|
639
|
+
RailsSimpleEventSourcing::CommandHandler.new(cmd).call
|
|
257
640
|
```
|
|
258
641
|
|
|
259
|
-
|
|
642
|
+
### Missing aggregate_id for updates
|
|
643
|
+
|
|
644
|
+
**Error:** `undefined method 'id' for nil:NilClass`
|
|
645
|
+
|
|
646
|
+
**Cause:** Forgot to pass `aggregate_id` to update/delete commands.
|
|
647
|
+
|
|
648
|
+
**Solution:**
|
|
260
649
|
```ruby
|
|
261
|
-
|
|
650
|
+
# Include aggregate_id in the command
|
|
651
|
+
cmd = Customer::Commands::Update.new(
|
|
652
|
+
aggregate_id: params[:id], # This is required
|
|
653
|
+
first_name: params[:first_name],
|
|
654
|
+
# ...
|
|
655
|
+
)
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
### Metadata is empty in tests
|
|
659
|
+
|
|
660
|
+
**Issue:** Event metadata is empty when creating events in tests.
|
|
661
|
+
|
|
662
|
+
**Cause:** Events created outside HTTP requests don't have automatic metadata.
|
|
663
|
+
|
|
664
|
+
**Solution:**
|
|
665
|
+
```ruby
|
|
666
|
+
# Manually set metadata in tests
|
|
667
|
+
RailsSimpleEventSourcing::CurrentRequest.metadata = {
|
|
668
|
+
request_id: "test-123",
|
|
669
|
+
test_mode: true
|
|
670
|
+
}
|
|
262
671
|
```
|
|
263
672
|
|
|
264
673
|
## Contributing
|
|
265
|
-
|
|
674
|
+
|
|
675
|
+
Contributions are welcome! Here's how you can help:
|
|
676
|
+
|
|
677
|
+
1. **Report Bugs**: Open an issue on GitHub with:
|
|
678
|
+
- Steps to reproduce
|
|
679
|
+
- Expected vs actual behavior
|
|
680
|
+
- Ruby/Rails/PostgreSQL versions
|
|
681
|
+
|
|
682
|
+
2. **Submit Pull Requests**:
|
|
683
|
+
- Fork the repository
|
|
684
|
+
- Create a feature branch (`git checkout -b feature/my-feature`)
|
|
685
|
+
- Write tests for your changes
|
|
686
|
+
- Ensure all tests pass (`rake test`)
|
|
687
|
+
- Follow existing code style
|
|
688
|
+
- Commit with clear messages
|
|
689
|
+
- Push and open a PR
|
|
690
|
+
|
|
691
|
+
3. **Running Tests**:
|
|
692
|
+
```bash
|
|
693
|
+
bundle install
|
|
694
|
+
cd test/dummy
|
|
695
|
+
rails db:create db:migrate RAILS_ENV=test
|
|
696
|
+
cd ../..
|
|
697
|
+
rake test
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
4. **Code Style**:
|
|
701
|
+
- Follow Ruby style guide
|
|
702
|
+
- Use RuboCop for linting
|
|
703
|
+
- Write clear, descriptive variable/method names
|
|
704
|
+
- Add comments for complex logic
|
|
705
|
+
|
|
706
|
+
### More Examples
|
|
707
|
+
|
|
708
|
+
See the `test/dummy/app/domain/customer/` directory for complete examples of:
|
|
709
|
+
- Commands (create, update, delete)
|
|
710
|
+
- Command handlers with error handling
|
|
711
|
+
- Events (created, updated, deleted)
|
|
712
|
+
- Controller integration
|
|
266
713
|
|
|
267
714
|
## License
|
|
715
|
+
|
|
268
716
|
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,49 @@
|
|
|
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
|
|
24
|
-
|
|
25
|
-
def self.event_attributes(*attributes)
|
|
26
|
-
@event_attributes ||= []
|
|
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
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
@event_attributes
|
|
41
|
-
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?
|
|
42
17
|
|
|
18
|
+
# Must be implemented by subclasses
|
|
43
19
|
def apply(_aggregate)
|
|
44
|
-
raise
|
|
20
|
+
raise NotImplementedError, "#{self.class}#apply must be implemented"
|
|
45
21
|
end
|
|
46
22
|
|
|
47
23
|
private
|
|
48
24
|
|
|
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
|
|
25
|
+
def setup_event_fields
|
|
61
26
|
enable_write_access!
|
|
27
|
+
self.event_type = self.class
|
|
28
|
+
self.metadata = CurrentRequest.metadata&.compact&.presence
|
|
62
29
|
end
|
|
63
30
|
|
|
64
|
-
def
|
|
65
|
-
@
|
|
66
|
-
|
|
67
|
-
end
|
|
31
|
+
def apply_event_to_aggregate
|
|
32
|
+
@aggregate_for_persistence = aggregate_repository.find_or_build(aggregate_id)
|
|
33
|
+
self.eventable = @aggregate_for_persistence
|
|
68
34
|
|
|
69
|
-
|
|
70
|
-
@
|
|
71
|
-
self.aggregate_id = @aggregate.id
|
|
35
|
+
applicator = EventApplicator.new(self)
|
|
36
|
+
applicator.apply_to_aggregate(@aggregate_for_persistence)
|
|
72
37
|
end
|
|
73
38
|
|
|
74
|
-
def
|
|
75
|
-
return
|
|
39
|
+
def persist_aggregate
|
|
40
|
+
return unless @aggregate_for_persistence
|
|
76
41
|
|
|
77
|
-
|
|
42
|
+
aggregate_repository.save!(@aggregate_for_persistence) if aggregate_id.present?
|
|
43
|
+
self.aggregate_id = @aggregate_for_persistence.id
|
|
78
44
|
end
|
|
79
45
|
|
|
80
|
-
def
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
aggregate_model_name.new
|
|
46
|
+
def aggregate_repository
|
|
47
|
+
@aggregate_repository ||= AggregateRepository.new(aggregate_class)
|
|
84
48
|
end
|
|
85
49
|
end
|
|
86
50
|
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.1
|
|
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-11 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.
|