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.
If you need a more comprehensive solution, check out:
Table of Contents
- Features
- Requirements
- Installation
- Configuration
- Usage
- Directory Structure
- Commands
- Command Handlers
- Events
- Registering Command Handlers
- Controller Integration
- Update and Delete Operations
- Metadata Tracking
- Event Querying
- Testing
- Limitations
- Troubleshooting
- Command Handler Registry
- CommandHandlerNotFoundError
- Contributing
- License
Features
- Immutable Event Log - All changes stored as immutable events with full audit trail
- Automatic Aggregate Reconstruction - Rebuild model state by replaying events
- Built-in Metadata Tracking - Captures request context (IP, user agent, params, etc.)
- Read-only Model Protection - Prevents accidental direct model modifications
- Command Handler Registry - Explicit command-to-handler mapping with fallback to convention
- Simple Command Pattern - Clear command → handler → event flow
- PostgreSQL JSONB Storage - Efficient JSON storage for event payloads and metadata
- Minimal Configuration - Convention over configuration approach
Requirements
- Ruby: 2.7 or higher
- Rails: 6.0 or higher
- Database: PostgreSQL 9.4+ (requires JSONB support)
Installation
Add this line to your application's Gemfile:
gem "rails_simple_event_sourcing"And then execute:
$ bundleOr install it yourself as:
$ gem install rails_simple_event_sourcingCopy migration to your app:
rails rails_simple_event_sourcing:install:migrationsRun the migration to create the events table:
rake db:migrateThis creates the rails_simple_event_sourcing_events table that stores your event log.
Configuration
You can configure the behavior of the gem using the configuration block:
# config/initializers/rails_simple_event_sourcing.rb
RailsSimpleEventSourcing.configure do |config|
# When true, falls back to convention-based handler resolution
# When false, requires explicit registration of all handlers
config.use_naming_convention_fallback = true
endUsage
Architecture Overview
The event sourcing flow follows this pattern:
HTTP Request → Controller → Command → CommandHandler → Event → Aggregate (Model)
↓ ↓ ↓ ↓ ↓
Pass data Parameters Validation + Immutable Database
+ Validation Business Storage
Rules Logic
Flow breakdown:
- Controller - Receives request, creates command with params
- Command - Defines parameters and validation rules (ActiveModel)
- CommandHandler - Validates command, executes business logic, creates event
- Event - Immutable record of what happened
- Aggregate - Model updated via event
Directory Structure
Let's start with the recommended directory structure:
app/
├─ domain/
│ ├─ customer/
│ │ ├─ command_handlers/
│ │ │ ├─ create.rb
│ │ ├─ events/
│ │ │ ├─ customer_created.rb
│ │ ├─ commands/
│ │ │ ├─ create.rb
Note: The top directory name (domain/) can be different - Rails doesn't enforce this namespace.
Commands
Commands represent intentions to perform actions in your system. They are responsible for:
- Encapsulating action parameters
- Validating input data (using ActiveModel validations)
- Being immutable value objects
Think of commands as "requests to do something" - they describe what you want to happen, not how it happens.
Example - Create Command:
class Customer
module Commands
class Create < RailsSimpleEventSourcing::Commands::Base
attr_accessor :first_name, :last_name, :email
validates :first_name, presence: true
validates :last_name, presence: true
validates :email, presence: true
end
end
endCommand Handlers
Command handlers contain the business logic for executing commands. They:
- Automatically validate the command before execution
- Perform business logic and API calls
- Create events when successful
- Handle errors gracefully
- Return a
RailsSimpleEventSourcing::Resultobject
Handler Discovery: Handlers can be discovered in two ways:
-
Explicit Registration (recommended) - Using the
CommandHandlerRegistryto register handlers - Convention-based - Using naming convention mapping (can be disabled via configuration)
Result Object:
The Result struct has three fields:
-
success?- Boolean indicating if the operation succeeded -
data- Data to return (usually the aggregate/model instance) -
errors- Array or hash of error messages whensuccess?is false
Helper Methods: The base class provides convenience methods:
-
success_result(data:)- Creates a successful result -
failure_result(errors:)- Creates a failed result
Example - Basic Handler:
class Customer
module CommandHandlers
class Create < RailsSimpleEventSourcing::CommandHandlers::Base
def call
event = Customer::Events::CustomerCreated.create(
first_name: @command.first_name,
last_name: @command.last_name,
email: @command.email,
created_at: Time.zone.now,
updated_at: Time.zone.now
)
# Using helper method (recommended)
success_result(data: event.aggregate)
# Or create Result directly
# RailsSimpleEventSourcing::Result.new(success?: true, data: event.aggregate)
end
end
end
endExample - Handler with Error Handling:
class Customer
module CommandHandlers
class Create < RailsSimpleEventSourcing::CommandHandlers::Base
def call
event = Customer::Events::CustomerCreated.create(
first_name: @command.first_name,
last_name: @command.last_name,
email: @command.email,
created_at: Time.zone.now,
updated_at: Time.zone.now
)
success_result(data: event.aggregate)
rescue ActiveRecord::RecordNotUnique
failure_result(errors: ["Email has already been taken"])
rescue StandardError => e
failure_result(errors: ["An error occurred: #{e.message}"])
end
end
end
endEvents
Events represent facts - things that have already happened in your system. They:
- Store immutable data about state changes
- Use past tense naming (e.g.,
CustomerCreated, notCreateCustomer) - Define which aggregate (model) they apply to
- Are stored permanently in the event log
Key Concepts:
-
aggregate_class- The model this event applies to (optional - some events may not modify models) -
event_attributes- Fields stored in the event payload -
apply(aggregate)- Optional method that applies the event to an aggregate instance -
aggregate_id- Links the event to a specific aggregate instance
Example - Basic Event (Automatic Application):
class Customer
module Events
class CustomerCreated < RailsSimpleEventSourcing::Event
aggregate_class Customer
event_attributes :first_name, :last_name, :email, :created_at, :updated_at
end
end
endAutomatic Attribute Application:
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.
The default implementation sets each event attribute on the aggregate:
# This happens automatically
aggregate.first_name = first_name
aggregate.last_name = last_name
aggregate.email = email
# ... and so on for all event_attributesExample - Custom Apply Method (When Needed):
You may still need to implement a custom apply method in certain cases:
- Setting computed or derived values
- Complex business logic during application
- Handling nested objects or special data transformations
- Setting the aggregate ID explicitly (though this is usually handled automatically)
class Customer
module Events
class CustomerCreated < RailsSimpleEventSourcing::Event
aggregate_class Customer
event_attributes :first_name, :last_name, :email, :created_at, :updated_at
def apply(aggregate)
# Custom logic example
aggregate.id = aggregate_id
aggregate.full_name = "#{first_name} #{last_name}" # Computed value
aggregate.email_normalized = email.downcase.strip # Transformation
# You can still call super to apply remaining attributes automatically
super
end
end
end
endUnderstanding the Event Structure:
-
aggregate_class Customer- Specifies which model this event modifies -
event_attributes- Defines what data gets stored in the event's JSON payload and what will be automatically applied -
apply(aggregate)- Optional method; only implement if you need custom logic beyond automatic attribute assignment -
aggregate_id- Auto-generated for creates, must be provided for updates/deletes
Note on aggregate_class:
- Optional - you can have events without an aggregate (e.g.,
UserLoginFailedfor logging only) - The corresponding model should include
RailsSimpleEventSourcing::Eventsfor read-only protection
Registering Command Handlers
The recommended approach is to register command handlers explicitly using the registry. This makes the command-to-handler mapping explicit and avoids relying on naming conventions.
# config/initializers/rails_simple_event_sourcing.rb
RailsSimpleEventSourcing.configure do |config|
config.use_naming_convention_fallback = false
end
Rails.application.config.after_initialize do
# Register all command handlers
RailsSimpleEventSourcing::CommandHandlerRegistry.register(
Customer::Commands::Create,
Customer::CommandHandlers::Create
)
RailsSimpleEventSourcing::CommandHandlerRegistry.register(
Customer::Commands::Update,
Customer::CommandHandlers::Update
)
RailsSimpleEventSourcing::CommandHandlerRegistry.register(
Customer::Commands::Delete,
Customer::CommandHandlers::Delete
)
endIf you prefer the convention-based approach, you don't need to take any action, as the use_naming_convention_fallback is set to true by default.
Controller Integration
Here's how to wire everything together in a controller:
class CustomersController < ApplicationController
def create
cmd = Customer::Commands::Create.new(
first_name: params[:first_name],
last_name: params[:last_name],
email: params[:email]
)
handler = RailsSimpleEventSourcing::CommandHandler.new(cmd).call
if handler.success?
render json: handler.data
else
render json: { errors: handler.errors }, status: :unprocessable_entity
end
end
endUpdate and Delete Operations
Update Example:
class CustomersController < ApplicationController
def update
cmd = Customer::Commands::Update.new(
aggregate_id: params[:id],
first_name: params[:first_name],
last_name: params[:last_name],
email: params[:email]
)
handler = RailsSimpleEventSourcing::CommandHandler.new(cmd).call
if handler.success?
render json: handler.data
else
render json: { errors: handler.errors }, status: :unprocessable_entity
end
end
endDelete Example:
class CustomersController < ApplicationController
def destroy
cmd = Customer::Commands::Delete.new(aggregate_id: params[:id])
handler = RailsSimpleEventSourcing::CommandHandler.new(cmd).call
if handler.success?
head :no_content
else
render json: { errors: handler.errors }, status: :unprocessable_entity
end
end
endImportant: 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/.
Testing the API
Create a customer using curl:
curl -X POST http://localhost:3000/customers \
-H 'Content-Type: application/json' \
-d '{ "first_name": "John", "last_name": "Doe", "email": "john@example.com" }' | jqResponse:
{
"id": 1,
"first_name": "John",
"last_name": "Doe",
"created_at": "2024-08-03T16:52:30.829Z",
"updated_at": "2024-08-03T16:52:30.848Z"
}Event Querying
Open the Rails console (rails c) to explore the event log:
# Get the customer
customer = Customer.last
# => #<Customer id: 1, first_name: "John", last_name: "Doe", ...>
# Access all events for this customer
customer.events
# => [#<Customer::Events::CustomerCreated...>]
# Get specific event details
event = customer.events.first
event.payload
# => {"first_name"=>"John", "last_name"=>"Doe", "email"=>"john@example.com", ...}
event.metadata
# => {"request_id"=>"2a40d4f9-509b-4b49-a39f-d978679fa5ef",
# "request_ip"=>"::1",
# "request_user_agent"=>"curl/8.6.0", ...}
# Query events by type
RailsSimpleEventSourcing::Event.where(event_type: "Customer::Events::CustomerCreated")
# Get events in a date range
customer.events.where(created_at: 1.week.ago..Time.now)
# Get all events for a specific aggregate
RailsSimpleEventSourcing::Event.where(eventable_type: "Customer", eventable_id: 1)Event Structure:
-
payload- Contains the event attributes you defined (as JSON) -
metadata- Contains request context (request ID, IP, user agent, params) -
event_type- The event class name -
aggregate_id- Links to the aggregate instance -
eventable- Polymorphic relation to the aggregate
Metadata Tracking
To automatically capture request metadata (IP address, user agent, request ID, etc.), include the concern in your ApplicationController:
Setup:
class ApplicationController < ActionController::Base
include RailsSimpleEventSourcing::SetCurrentRequestDetails
endDefault Metadata: By default, the following is captured:
-
request_id- Unique request identifier -
request_user_agent- Client user agent -
request_referer- HTTP referer -
request_ip- Client IP address -
request_params- Request parameters (filtered using Rails parameter filter)
Customizing Metadata:
Override the event_metadata method in your controller:
class ApplicationController < ActionController::Base
include RailsSimpleEventSourcing::SetCurrentRequestDetails
def event_metadata
parameter_filter = ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters)
{
request_id: request.uuid,
request_user_agent: request.user_agent,
request_ip: request.ip,
request_params: parameter_filter.filter(request.params),
current_user_id: current_user&.id, # Add custom fields
tenant_id: current_tenant&.id
}
end
endMetadata Outside HTTP Requests:
When events are created outside HTTP requests (background jobs, console, tests), metadata will be empty unless you manually set it using CurrentRequest.metadata = {...}.
Model Configuration
Models that use event sourcing should include the RailsSimpleEventSourcing::Events module:
class Customer < ApplicationRecord
include RailsSimpleEventSourcing::Events
endThis provides:
-
.eventsassociation - Access all events for this aggregate - Read-only protection - Prevents accidental direct modifications
- Event replay capability - Reconstruct state from events
Immutability and Read-Only Protection
Important Principles:
- Events are immutable - Once created, events should never be modified
- Models are read-only - Aggregates should only be modified through events
- Both have built-in protection against accidental changes
Soft Deletes
Recommendation: Use soft deletes instead of hard deletes to preserve event history.
Why?
- Events are linked to aggregates via foreign keys
- Hard deleting a record can orphan its events
- Event log becomes incomplete
- Cannot reconstruct historical state
How to implement:
# Migration
class AddDeletedAtToCustomers < ActiveRecord::Migration[7.0]
def change
add_column :customers, :deleted_at, :datetime
add_index :customers, :deleted_at
end
end
# Model
class Customer < ApplicationRecord
include RailsSimpleEventSourcing::Events
scope :active, -> { where(deleted_at: nil) }
scope :deleted, -> { where.not(deleted_at: nil) }
def soft_delete
update(deleted_at: Time.current)
end
end
# Event
class Customer::Events::CustomerDeleted < RailsSimpleEventSourcing::Event
aggregate_class Customer
event_attributes :deleted_at
# No need to implement apply - deleted_at will be automatically set on the aggregate
endTesting
Setting Up Tests with Command Handler Registry
If you're using the command handler registry with use_naming_convention_fallback = false, you'll need to register your command handlers in your tests. There are a few approaches to handling this:
- Create an initializer in the test app:
# test/dummy/config/initializers/rails_simple_event_sourcing.rb
Rails.application.config.after_initialize do
RailsSimpleEventSourcing::CommandHandlerRegistry.register(
Customer::Commands::Create,
Customer::CommandHandlers::Create
)
# Register other handlers...
end- Register in test_helper.rb:
# test/test_helper.rb
# After requiring the environment
RailsSimpleEventSourcing::CommandHandlerRegistry.register(
Customer::Commands::Create,
Customer::CommandHandlers::Create
)
# Register other handlers...Testing Commands
require "test_helper"
class Customer::Commands::CreateTest < ActiveSupport::TestCase
test "valid command" do
cmd = Customer::Commands::Create.new(
first_name: "John",
last_name: "Doe",
email: "john@example.com"
)
assert cmd.valid?
end
test "invalid without email" do
cmd = Customer::Commands::Create.new(
first_name: "John",
last_name: "Doe"
)
assert_not cmd.valid?
assert_includes cmd.errors[:email], "can't be blank"
end
endTesting Command Handlers
require "test_helper"
class Customer::CommandHandlers::CreateTest < ActiveSupport::TestCase
test "creates customer and event" do
cmd = Customer::Commands::Create.new(
first_name: "John",
last_name: "Doe",
email: "john@example.com"
)
result = RailsSimpleEventSourcing::CommandHandler.new(cmd).call
assert result.success?
assert_instance_of Customer, result.data
assert_equal "John", result.data.first_name
assert_equal 1, result.data.events.count
end
test "handles duplicate email" do
# Create first customer
Customer::Events::CustomerCreated.create(
first_name: "Jane",
last_name: "Doe",
email: "john@example.com"
)
# Try to create duplicate
cmd = Customer::Commands::Create.new(
first_name: "John",
last_name: "Doe",
email: "john@example.com"
)
result = RailsSimpleEventSourcing::CommandHandler.new(cmd).call
assert_not result.success?
assert_includes result.errors, "Email has already been taken"
end
endTesting in Controllers
require "test_helper"
class CustomersControllerTest < ActionDispatch::IntegrationTest
test "creates customer" do
post customers_url, params: {
first_name: "John",
last_name: "Doe",
email: "john@example.com"
}, as: :json
assert_response :success
assert_equal "John", JSON.parse(response.body)["first_name"]
end
endLimitations
Be aware of these limitations when using this gem:
- PostgreSQL Only - Requires PostgreSQL 9.4+ for JSONB support
- No Event Versioning - No built-in support for evolving event schemas over time
- No Snapshots - All aggregate reconstruction done by replaying all events (can be slow for aggregates with many events)
- No Projections - No built-in read model or projection support
-
Manual aggregate_id - Must manually track and pass
aggregate_idfor updates/deletes - No Saga Support - No built-in support for long-running processes or sagas
- Single Database - Events and aggregates must be in the same database
Troubleshooting
Command Handler Registry
The gem provides a registry pattern for explicitly mapping commands to their handlers. This is a more robust alternative to the convention-based mapping.
Configuration:
# config/initializers/rails_simple_event_sourcing.rb
RailsSimpleEventSourcing.configure do |config|
# Set to false to disable convention-based mapping
config.use_naming_convention_fallback = false
end
# Register command handlers
Rails.application.config.after_initialize do
RailsSimpleEventSourcing::CommandHandlerRegistry.register(
Customer::Commands::Create,
Customer::CommandHandlers::Create
)
RailsSimpleEventSourcing::CommandHandlerRegistry.register(
Customer::Commands::Update,
Customer::CommandHandlers::Update
)
RailsSimpleEventSourcing::CommandHandlerRegistry.register(
Customer::Commands::Delete,
Customer::CommandHandlers::Delete
)
endCommandHandlerNotFoundError
Error: RailsSimpleEventSourcing::CommandHandler::CommandHandlerNotFoundError: Handler Customer::CommandHandlers::Create not found or No handler registered for Customer::Commands::Create
Causes:
- The command handler class doesn't follow the naming convention (when using convention-based mapping)
- The command handler hasn't been registered with the registry (when using explicit registration)
Solutions:
- Ensure your handler namespace matches your command namespace:
- Command:
Customer::Commands::Create - Handler:
Customer::CommandHandlers::Create(notCustomerCommandHandlers::Create)
- Command:
- Register your command handlers using the registry pattern shown above
undefined method 'events' for Customer
Error: undefined method 'events' for #<Customer>
Cause: The model doesn't include the RailsSimpleEventSourcing::Events module.
Solution:
class Customer < ApplicationRecord
include RailsSimpleEventSourcing::Events
endActiveRecord::ReadOnlyRecord when updating model
Error: ActiveRecord::ReadOnlyRecord: Customer is marked as readonly
Cause: Trying to directly modify a model that uses event sourcing.
Solution: Create an event instead:
# Don't do this:
customer.update(first_name: "Jane")
# Do this:
cmd = Customer::Commands::Update.new(aggregate_id: customer.id, first_name: "Jane", ...)
RailsSimpleEventSourcing::CommandHandler.new(cmd).callMissing aggregate_id for updates
Error: undefined method 'id' for nil:NilClass
Cause: Forgot to pass aggregate_id to update/delete commands.
Solution:
# Include aggregate_id in the command
cmd = Customer::Commands::Update.new(
aggregate_id: params[:id], # This is required
first_name: params[:first_name],
# ...
)Metadata is empty in tests
Issue: Event metadata is empty when creating events in tests.
Cause: Events created outside HTTP requests don't have automatic metadata.
Solution:
# Manually set metadata in tests
RailsSimpleEventSourcing::CurrentRequest.metadata = {
request_id: "test-123",
test_mode: true
}Contributing
Contributions are welcome! Here's how you can help:
-
Report Bugs: Open an issue on GitHub with:
- Steps to reproduce
- Expected vs actual behavior
- Ruby/Rails/PostgreSQL versions
-
Submit Pull Requests:
- Fork the repository
- Create a feature branch (
git checkout -b feature/my-feature) - Write tests for your changes
- Ensure all tests pass (
rake test) - Follow existing code style
- Commit with clear messages
- Push and open a PR
-
Running Tests:
bundle install cd test/dummy rails db:create db:migrate RAILS_ENV=test cd ../.. rake test
-
Code Style:
- Follow Ruby style guide
- Use RuboCop for linting
- Write clear, descriptive variable/method names
- Add comments for complex logic
More Examples
See the test/dummy/app/domain/customer/ directory for complete examples of:
- Commands (create, update, delete)
- Command handlers with error handling
- Events (created, updated, deleted)
- Controller integration
License
The gem is available as open source under the terms of the MIT License.