A small event sourcing toolkit for Ruby 3.2+ applications that use ActiveRecord.
It provides:
- command and command-handler dispatch
- aggregate roots and nested domains
- event records backed by ActiveRecord
- event replay and projection hooks
- optional snapshots
- optional request-local aggregate memoization
- optional RSpec matchers
This gem does not own your database schema or application namespaces. The host application provides the event record model, event table, snapshot table if needed, command handlers, and event handlers.
Add the gem to your application:
gem "event_sourcing"The runtime gem does not load RSpec. Test helpers are loaded separately with:
require "event_sourcing/rspec"Configure the gem from an initializer:
EventSourcing.configure do |config|
config.event_record_class = AccountEvent
config.command_handlers = [
Accounts::CommandHandlers::OpenAccountHandler.new(config)
]
config.event_handlers = [
Accounts::Projectors::AccountProjector.new(config)
]
config.error_reporter = lambda do |error, context|
Rails.logger.error("[event_sourcing] #{error.class}: #{error.message}")
Rails.logger.debug(context.inspect)
end
endOptional settings:
EventSourcing.configure do |config|
config.snapshot_enabled = true
config.snapshot_class = AccountSnapshot
config.memoization_store = EventSourcing::MemoizationStore::RequestStore
config.silent_exception_reraise_errors = [ExpectedBusinessError]
enderror_reporter can be any object that responds to call(error, context) or
notify(error).
Create an ActiveRecord model that inherits from EventSourcing::EventRecord.
The host application decides the table name and persistence behavior.
class AccountEvent < EventSourcing::EventRecord
self.table_name = "account_events"
self.event_module = "Accounts::Events"
def self.save_event(event)
create!(event: event)
end
endA typical PostgreSQL event table contains:
create_table :account_events do |t|
t.string :parent_id
t.string :aggregate_id, null: false
t.integer :version, null: false
t.string :event_type, null: false
t.jsonb :event_payload, null: false, default: {}
t.string :domain_type
t.string :domain_id
t.datetime :created_at, null: false
end
add_index :account_events, [:aggregate_id, :version]
add_index :account_events, [:domain_type, :domain_id]Events inherit from EventSourcing::Event and declare typed attributes with
attrs.
module Accounts
module Events
class AccountOpenedEvent < EventSourcing::Event
attrs :account_name, String
attrs :opened_by, String
end
end
endEvent classes under an Events namespace are stored without the application
prefix. For example, Accounts::Events::AccountOpenedEvent becomes
account_opened_event.
Aggregates inherit from EventSourcing::AggregateRoot. Public behavior emits
events with apply; event handlers mutate aggregate state through the on
DSL.
module Accounts
module Aggregates
class Account < EventSourcing::AggregateRoot
SCHEMA_VERSION = 1
attr_reader :account_name
on Events::AccountOpenedEvent do |event|
@account_name = event.account_name
end
def open(account_name:, opened_by:)
apply Events::AccountOpenedEvent,
account_name: account_name,
opened_by: opened_by
end
end
end
endLoad and commit aggregates through the repository:
repository = EventSourcing.configuration.aggregate_repository
account = repository.load_aggregate(
Accounts::Aggregates::Account,
account_id
)
account.open(account_name: "Operating Account", opened_by: user_id)
repository.commit(account)Aggregates and domains can declare simple state transitions. When state-machine
support is enabled with attr_state, each applied event must move the object
through a declared transition.
class Account < EventSourcing::AggregateRoot
attr_state :status
state :initialized
state :opened
transition(
from: :initialized,
to: :opened,
event: Accounts::Events::AccountOpenedEvent
)
endCommands use typed attributes and ActiveModel validations.
module Accounts
module Commands
class OpenAccount < EventSourcing::Command
attrs :account_id, String
attrs :account_name, String
attrs :opened_by, String
validates :account_id, :account_name, :opened_by, presence: true
end
end
endHandlers inherit from EventSourcing::CommandHandler.
module Accounts
module CommandHandlers
class OpenAccountHandler < EventSourcing::CommandHandler
on Commands::OpenAccount do |command|
account = repository.load_aggregate(
Aggregates::Account,
command.account_id
)
account.open(
account_name: command.account_name,
opened_by: command.opened_by
)
repository.commit(account)
end
end
end
endExecute commands with:
EventSourcing.configuration.command_service.execute(command)Event handlers receive events after they are stored.
module Accounts
module Projectors
class AccountProjector < EventSourcing::EventHandler
on Accounts::Events::AccountOpenedEvent do |event|
AccountReadModel.create!(
account_id: event.aggregate_id,
account_name: event.account_name
)
end
end
end
endFor replayable projectors, inherit from EventSourcing::Projector and implement
remove(aggregate_id).
Set config.snapshot_enabled = true and provide config.snapshot_class to
enable snapshots. Snapshot records are expected to support:
parent_idaggregate_idaggregate_typeevent_versionschema_versiondatacreated_at
Snapshots currently use Marshal.dump and Marshal.load. Only load snapshots
that were written by your own trusted application.
RSpec is not a runtime dependency. Add it to your application's test or development dependencies before loading the matcher entrypoint.
Load matchers only in the test environment:
require "event_sourcing/rspec"Available matchers:
be_event,be_an_event,an_event,eventhave_publishedpublishhave_appliedapply
Example:
expect(event_store).to have_published(
an_event(Accounts::Events::AccountOpenedEvent).with_data(
aggregate_id: account_id,
account_name: "Operating Account"
)
)The public API uses event_record_class, uncommitted_events, committed, and
event_sourcing/extensions.
For existing applications, the previous names still work:
assets_event_classuncommited_eventscommitedevent_sourcing/extentions
These compatibility aliases are kept to ease migration, but new code should use the corrected names.
This project targets Ruby 3.2 and uses GitHub Actions for CI. Useful local checks are:
bundle install
bundle exec rspec
gem build event_sourcing.gemspec
ruby -Ilib -e 'require "event_sourcing"; require "event_sourcing/rspec"'MIT.