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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9789524ed9ede4a4a16561d587fbfc729fe825d0e4196390b6bd39d4196ed56d
4
- data.tar.gz: 5bbc7f33e45dd59ce805b06cea72a84a4a8f63c852fc859270eef9f2b8d11cb7
3
+ metadata.gz: baad400eca4ab6e7629c1bc7a9d30f66841d2da8ee1d3ab1dd3225333bf9ea10
4
+ data.tar.gz: dd2e881c3f8693aadae17ed44343acbb2633465eaf36d010934173793b91daf3
5
5
  SHA512:
6
- metadata.gz: 4112017f8f52f621b401dc463be3673f635176bfcee2732a7a0502757d4472a2897328169a68ddea60e4e8ec9726068d45daa8f1f4d70e6b471d42efffbd0da7
7
- data.tar.gz: cbe47f8933ff692665a019fce022e1f52bdc849067fb7ae10bbebef02a9fb3c5eb6222d28d3ce5205249e37466a6733544de7dbd388f4aae43fc4a1219cb96c2
6
+ metadata.gz: c1ed4111dd95c2e6f0bba524606749e98d22c78d7803ea0b1dc2f0f03a27ef39e070d868d25b5ac2491389db08c154221432c2374d647d343299cd2469eb0656
7
+ data.tar.gz: 44764791b5a57ad810562f1dbf32f5b17f59ee19c047092c19751db5470498cb56797e6c82aece230ddae68ac820dd6505a5ab8e86433d582bb002fef984c7a4
data/README.md CHANGED
@@ -1,20 +1,100 @@
1
1
  # RailsSimpleEventSourcing ![tests](https://github.com/dbackowski/rails_simple_event_sourcing/actions/workflows/minitest.yml/badge.svg) ![codecheck](https://github.com/dbackowski/rails_simple_event_sourcing/actions/workflows/codecheck.yml/badge.svg)
2
2
 
3
- This is a very minimalist implementation of an event sourcing pattern, if you want a full-featured framework in ruby you can check out one of these:
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
- I wanted to learn how to build this from scratch and also wanted to build something that would be very easy to use since most of the fully featured frameworks like the two above require a lot of configuration and learning.
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
- ### Important notice
67
+ Run the migration to create the events table:
68
+ ```bash
69
+ rake db:migrate
70
+ ```
10
71
 
11
- This plugin will only work with Postgres database because it uses JSONB data type which is only supported by this database.
72
+ This creates the `rails_simple_event_sourcing_events` table that stores your event log.
12
73
 
13
74
  ## Usage
14
75
 
15
- So how does it all work?
76
+ ### Architecture Overview
77
+
78
+ The event sourcing flow follows this pattern:
16
79
 
17
- Let's start with the directory structure:
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 name of the top directory can be different because Rails does not namespace it.
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
- Command -> Command Handler -> Create Event (which under the hood writes changes to the appropriate model)
113
+ ### Commands
36
114
 
37
- Explanation of each of these blocks above:
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
- - `Command` - is responsible for any action you want to take in your system, it is also responsible for validating the input parameters it takes (you can use the same validations you would normally use in models).
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
- - `CommandHandler` - is responsible for handling the passed command (it automatically checks if a command is valid), making additional API calls, doing additional business logic, and finally creating a proper event. This should always return the `RailsSimpleEventSourcing::Result` struct.
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
- This struct has 3 keywords:
60
- - `success?:` true/false (if everything went well, commands are automatically validated, but there might still be an API call here, etc., so you can return false if something went wrong)
61
- - `data:` data that you want to return, eg. to the controller (in the example above the `event.aggregate` will return a proper instance of the Customer model)
62
- - `errors:` in a scenario where you set `success?:false`, you can also return some related errors here (see: test/dummy/app/domain/customer/command_handlers/create.rb for an example)
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
- RailsSimpleEventSourcing::Result.new(success?: true, data: event.aggregate)
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
- - `Event` - is responsible for storing immutable data of your actions, you should use past tense for naming events since an event is something that has already happened (e.g. customer was created)
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
- Example:
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
- aggregate_model_name Customer
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.first_name = first_name
100
- aggregate.last_name = last_name
101
- aggregate.email = email
102
- aggregate.created_at = created_at
103
- aggregate.updated_at = updated_at
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
- In the example above:
111
- - `aggregate_model_name` is used for the corresponding model (each model is normally set to read-only mode since the only way to modify it should be via events), this param is optional since you can have an event that is not applied to the model, e.g. UserLoginAlreadyTaken
112
- - `event_attributes` - defines params that will be stored in the event and these params will be available to apply to the model via the `apply(aggregate)` method (where aggregate is an instance of your model passed in aggregate_model_name).
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
- Here is an example of a custom controller that uses all the blocks described above:
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
- Now, if you make an API call using curl:
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
- You will get the response:
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
- Run `rails c` and do the following:
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
- Customer.last
159
- =>
160
- #<Customer:0x0000000107e20998
161
- id: 1,
162
- first_name: "John",
163
- last_name: "Doe",
164
- created_at: Sat, 03 Aug 2024 16:52:30.829043000 UTC +00:00,
165
- updated_at: Sat, 03 Aug 2024 16:52:30.848243000 UTC +00:00>
166
- Customer.last.events
167
- [#<Customer::Events::CustomerCreated:0x0000000108dbcac8
168
- id: 1,
169
- type: "Customer::Events::CustomerCreated",
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
- You can override metadata fields by defining the `event_metadata` method in the controller, this method should return a Hash which will be stored in the metadata field of the event.
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
- By default, this method looks like this:
464
+ Models that use event sourcing should include the `RailsSimpleEventSourcing::Events` module:
202
465
 
203
466
  ```ruby
204
- def event_metadata
205
- parameter_filter = ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters)
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
- #### Important notice
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
- The data stored in the events should be immutable (i.e., you shouldn't change it after it's created), so they have simple protection against accidental modification, which means that the model is marked as read-only.
486
+ **Recommendation:** Use soft deletes instead of hard deletes to preserve event history.
220
487
 
221
- The same goes for models, any model that should be updated by events should include `include RailsSimpleEventSourcing::Events`, this will give you access to the `.events` relation and you will have read-only protection as well (model should only be updated by creating an event).
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
- Example:
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
- One thing to note here is that it would be better to do soft-deletes (mark record as deleted) instead of deleting records from the DB, since every record has relations called `events` when you have all the events that were applied to it.
526
+ ## Testing
232
527
 
233
- #### More examples
528
+ ### Testing Commands
234
529
 
235
- There is a sample application in the `test/dummy/app` directory so you can see how updates and deletes are handled.
530
+ ```ruby
531
+ require "test_helper"
236
532
 
237
- ## Installation
238
- Add this line to your application's Gemfile:
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
- gem "rails_simple_event_sourcing"
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
- And then execute:
245
- ```bash
246
- $ bundle
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
- Or install it yourself as:
250
- ```bash
251
- $ gem install rails_simple_event_sourcing
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
- Copy migration to your app:
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
- rails rails_simple_event_sourcing:install:migrations
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
- And then run the migration in order to create the rails_simple_event_sourcing_events table (the table that will store the event log):
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
- rake db:migrate
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
- Contribution directions go here.
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
- after_initialize :initialize_event
12
- before_validation :enable_write_access_on_self, if: :new_record?
13
- before_validation :apply_on_aggregate, if: :aggregate_defined?
14
- before_save :add_metadata
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 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
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 aggregate_defined?
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 apply_on_aggregate
65
- @aggregate.enable_write_access!
66
- apply(@aggregate)
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
- def assing_aggregate_id_and_persist_aggregate
70
- @aggregate.save! if aggregate_id.present?
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 add_metadata
75
- return if CurrentRequest.metadata.blank?
41
+ def persist_aggregate
42
+ return unless @aggregate_for_persistence
76
43
 
77
- self.metadata = CurrentRequest.metadata.compact.presence
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 find_or_build_aggregate
81
- return aggregate_model_name.find(aggregate_id).lock! if aggregate_id.present?
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::').constantize.new(command: @command)
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 'command_handler'
6
- require_relative 'apply_with_returning_aggregate'
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsSimpleEventSourcing
4
- VERSION = '1.0.0'
4
+ VERSION = '1.0.2'
5
5
  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.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: 2024-10-08 00:00:00.000000000 Z
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.1.6
122
+ rubygems_version: 3.4.21
118
123
  signing_key:
119
124
  specification_version: 4
120
125
  summary: Rails engine for simple event sourcing.