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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9789524ed9ede4a4a16561d587fbfc729fe825d0e4196390b6bd39d4196ed56d
4
- data.tar.gz: 5bbc7f33e45dd59ce805b06cea72a84a4a8f63c852fc859270eef9f2b8d11cb7
3
+ metadata.gz: 686785bc4c8f52ae548b2880cb664b57d5a048925cda0f4cfe0d2962acbfdb3e
4
+ data.tar.gz: 9555f13015e4aaacdfe8ce8b4866ea8d5f6e1a19d0d4f0766b1d4b9c5b36a64e
5
5
  SHA512:
6
- metadata.gz: 4112017f8f52f621b401dc463be3673f635176bfcee2732a7a0502757d4472a2897328169a68ddea60e4e8ec9726068d45daa8f1f4d70e6b471d42efffbd0da7
7
- data.tar.gz: cbe47f8933ff692665a019fce022e1f52bdc849067fb7ae10bbebef02a9fb3c5eb6222d28d3ce5205249e37466a6733544de7dbd388f4aae43fc4a1219cb96c2
6
+ metadata.gz: ba9de2c72e6ff9c242da76379ae476aa1139bba37b58eb60dced8bd6c37de83dc3335e9d2f031a6ccf3930e7211f1bafc96c65647550a020e7dcd71e262207d2
7
+ data.tar.gz: 691d056df008df794ae8b4833295185eb0ee6f568b2f28b19d2ddbea79bec4069b64143a1c4723c685f49030be97c1e0f964d53ae451551efd1fc6813f4f7c39
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
+ ```
8
61
 
9
- ### Important notice
62
+ Copy migration to your app:
63
+ ```bash
64
+ rails rails_simple_event_sourcing:install:migrations
65
+ ```
10
66
 
11
- This plugin will only work with Postgres database because it uses JSONB data type which is only supported by this database.
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
- So how does it all work?
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 name of the top directory can be different because Rails does not namespace it.
111
+ **Note:** The top directory name (`domain/`) can be different - Rails doesn't enforce this namespace.
32
112
 
33
- Based on the example above, the usage looks like this
113
+ ### Commands
34
114
 
35
- Command -> Command Handler -> Create Event (which under the hood writes changes to the appropriate model)
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
- Explanation of each of these blocks above:
120
+ Think of commands as "requests to do something" - they describe what you want to happen, not how it happens.
38
121
 
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).
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
- - `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,22 +196,39 @@ 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
+ - Specify how to apply themselves to the aggregate
217
+ - Are stored permanently in the event log
87
218
 
88
- Example:
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
- aggregate_model_name Customer
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
- 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).
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 is an example of a custom controller that uses all the blocks described above:
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
- Now, if you make an API call using curl:
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
- You will get the response:
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
- Run `rails c` and do the following:
345
+ ### Event Querying
346
+
347
+ Open the Rails console (`rails c`) to explore the event log:
156
348
 
157
349
  ```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>]
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
- 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).
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
- To have these metadata fields populated automatically, you need to include `RailsSimpleEventSourcing::SetCurrentRequestDetails` in your ApplicationController.
385
+ ### Metadata Tracking
189
386
 
190
- Example:
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
- 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.
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
- By default, this method looks like this:
405
+ **Customizing Metadata:**
406
+ Override the `event_metadata` method in your controller:
202
407
 
203
408
  ```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
- }
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
- #### Important notice
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
- 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.
445
+ ### Immutability and Read-Only Protection
220
446
 
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).
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
- Example:
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
- 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.
496
+ ## Testing
232
497
 
233
- #### More examples
498
+ ### Testing Commands
234
499
 
235
- There is a sample application in the `test/dummy/app` directory so you can see how updates and deletes are handled.
500
+ ```ruby
501
+ require "test_helper"
236
502
 
237
- ## Installation
238
- Add this line to your application's Gemfile:
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
- gem "rails_simple_event_sourcing"
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
- And then execute:
245
- ```bash
246
- $ bundle
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
- Or install it yourself as:
250
- ```bash
251
- $ gem install rails_simple_event_sourcing
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
- Copy migration to your app:
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
- rails rails_simple_event_sourcing:install:migrations
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
- And then run the migration in order to create the rails_simple_event_sourcing_events table (the table that will store the event log):
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
- rake db:migrate
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
- Contribution directions go here.
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
- 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
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 NoMethodError, "You must implement #{self.class}#apply"
20
+ raise NotImplementedError, "#{self.class}#apply must be implemented"
45
21
  end
46
22
 
47
23
  private
48
24
 
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
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 apply_on_aggregate
65
- @aggregate.enable_write_access!
66
- apply(@aggregate)
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
- def assing_aggregate_id_and_persist_aggregate
70
- @aggregate.save! if aggregate_id.present?
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 add_metadata
75
- return if CurrentRequest.metadata.blank?
39
+ def persist_aggregate
40
+ return unless @aggregate_for_persistence
76
41
 
77
- self.metadata = CurrentRequest.metadata.compact.presence
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 find_or_build_aggregate
81
- return aggregate_model_name.find(aggregate_id).lock! if aggregate_id.present?
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::').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.1'
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.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: 2024-10-08 00:00:00.000000000 Z
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.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.