7 Event Sourcing Patterns in Ruby on Rails Every Developer Should Know
Learn 7 proven Event Sourcing patterns for Ruby on Rails — from event stores to sagas and snapshots. Build auditable, flexible Rails apps. Read the full guide.
Let me walk you through the seven patterns I use to implement Event Sourcing in Ruby on Rails. I’ve been doing this for years, and I want to share them in the simplest way possible. If you’ve never heard of Event Sourcing, think of it this way: instead of saving the current state of something (like “the account balance is $100”), you save every single change that happened (like “deposit $50”, “withdraw $20”). The current state is then rebuilt by replaying all those changes in order. It sounds strange at first, but it gives you total audit trail, time travel, and flexibility. Rails makes this easy if you apply the right patterns.
Pattern One – The Event Store
The first thing you need is a place to store all your events. This is called the event store. In Rails, I usually create a simple table called events. Each row has a few columns: an event_id, aggregate_id, event_type, data (I use a JSON column), metadata, and created_at. That’s it. No complex setup.
Here is how I create the migration:
class CreateEvents < ActiveRecord::Migration[7.0]
def change
create_table :events do |t|
t.string :event_id, null: false
t.string :aggregate_id, null: false
t.string :event_type, null: false
t.jsonb :data, default: {}
t.jsonb :metadata, default: {}
t.datetime :created_at, null: false
t.index :aggregate_id
t.index :event_type
t.index :event_id, unique: true
end
end
end
I always use a UUID for event_id and aggregate_id. The aggregate_id groups events that belong to the same object, like a specific bank account or an order. The event_type is a string like AccountDeposited. The data holds the actual fields of the event, for example { amount: 50, currency: "USD" }. The metadata is for things like the user who triggered it, a request ID, or a timestamp from the client.
I store events in the order they happen. I never update or delete them. Only append. This is the heart of Event Sourcing. You read events from the store for a given aggregate_id and replay them to build the current state.
I also add a method on the ActiveRecord model to append an event:
class Event < ApplicationRecord
def self.append(aggregate_id:, event_type:, data:, metadata: {})
create!(
event_id: SecureRandom.uuid,
aggregate_id: aggregate_id,
event_type: event_type,
data: data,
metadata: metadata,
created_at: Time.current
)
end
end
This method is the only way to write to the store. It ensures consistency and keeps the schema simple.
Pattern Two – The Aggregate Root
The aggregate root is the object that holds state and applies events. It is not an ActiveRecord model — it is a plain Ruby object. It starts with a version (the number of events that have been applied) and a state hash that is built by replaying events. The aggregate root also has methods that produce new events when you call commands on it.
Here is a simple example for a bank account:
class Account
attr_reader :id, :balance, :version
def initialize(id, events = [])
@id = id
@balance = 0
@version = 0
events.each { |e| apply_event(e) }
end
def deposit(amount, metadata = {})
raise "Amount must be positive" if amount <= 0
event = {
aggregate_id: id,
event_type: 'AccountDeposited',
data: { amount: amount },
metadata: metadata
}
apply_event(event)
event
end
def withdraw(amount, metadata = {})
raise "Insufficient funds" if amount > @balance
event = {
aggregate_id: id,
event_type: 'AccountWithdrawn',
data: { amount: amount },
metadata: metadata
}
apply_event(event)
event
end
private
def apply_event(event)
case event[:event_type]
when 'AccountDeposited'
@balance += event[:data]['amount']
when 'AccountWithdrawn'
@balance -= event[:data]['amount']
end
@version += 1
end
end
Notice that the aggregate root does not save anything. It just builds in-memory state from events and returns new events to be stored. The separation between the pure business logic and the persistence is clean. I love this because it makes testing easy: I can instantiate an account with a list of past events and call commands without a database.
Pattern Three – The Command Handler
The command handler connects the outside world (like a controller) to the aggregate root and the event store. It is a service object that loads the aggregate, runs a command (like deposit), and then appends the new events to the store. This is where transactional safety lives.
Here is a command handler for a deposit:
class DepositHandler
def call(account_id:, amount:, metadata: {})
events = Event.where(aggregate_id: account_id).order(:created_at)
account = Account.new(account_id, events.map { |e|
{ event_type: e.event_type, data: e.data, metadata: e.metadata }
})
new_event = account.deposit(amount, metadata)
Event.append(
aggregate_id: account_id,
event_type: new_event[:event_type],
data: new_event[:data],
metadata: new_event[:metadata]
)
account
rescue => e
Rails.logger.error("Deposit failed: #{e.message}")
raise
end
end
This pattern forces all writes to go through a single handler. The handler loads all existing events for that aggregate, replays them into the aggregate root (which builds the current state), then calls the command method. The command method validates the business rules and returns the event. The handler appends the event. If something fails, the whole operation is abandoned and no partial data is saved. This gives you strong consistency for each aggregate.
Pattern Four – The Projection (Read Model)
Storing events is great for writes, but you rarely want to load hundreds of events every time you show a page. That is where projections come in. A projection listens to events and updates a read model table that you can query normally with ActiveRecord. For example, you might want a table that shows the current balance of all accounts.
I build projections using an event listener that subscribes to events. In Rails, I can do this with an Active Support notification or a simple callback after an event is stored.
Here is a projection for an account summary:
class AccountSummaryProjector
def call(event)
case event.event_type
when 'AccountDeposited'
account = AccountSummary.find_or_create_by!(account_id: event.aggregate_id)
account.balance += event.data['amount']
account.save!
when 'AccountWithdrawn'
account = AccountSummary.find_by!(account_id: event.aggregate_id)
account.balance -= event.data['amount']
account.save!
when 'AccountCreated'
AccountSummary.create!(account_id: event.aggregate_id, balance: 0)
end
end
end
I hook this into the event store like this after appending:
class Event < ApplicationRecord
after_create_commit :run_projectors
private
def run_projectors
AccountSummaryProjector.new.call(self)
end
end
Now when a deposit event is stored, the AccountSummary row is updated. My controller can then do AccountSummary.find_by(account_id: params[:id]) and get the balance instantly. This is a classic read model. You can have as many projections as you want, each serving different queries.
Pattern Five – The Saga (Process Manager)
Some operations cross multiple aggregates. For example, when a user places an order, you might need to reserve money in an account, update inventory, and send an email. A saga (or process manager) is a pattern that coordinates multiple events across different aggregates. It is itself an event handler that maintains its own state and sends commands.
In Rails, I implement sagas as a separate service that listens to events and emits new ones. Here is a simple saga for order fulfillment:
class OrderFulfillmentSaga
def call(event)
case event.event_type
when 'OrderPlaced'
reserve_money(event)
when 'MoneyReserved'
create_invoice(event)
when 'InvoiceCreated'
send_confirmation_email(event)
end
end
private
def reserve_money(event)
# Send a command to the account aggregate
DepositHandler.new.call(
account_id: event.data['customer_account_id'],
amount: event.data['total'],
metadata: { saga: 'order_fulfillment', order_id: event.aggregate_id }
)
end
def create_invoice(event)
# ... more logic
end
end
The saga is triggered by events and produces events (or commands) that are stored. Each step is an event. If a step fails, the saga can compensate (rollback) by emitting compensation events. In Event Sourcing, compensation events are just normal events – they don’t undo the past, they add new events that correct the state. This is called a compensating action.
Pattern Six – The Snapshot
When an aggregate has a very long history (thousands of events), loading all of them every time becomes slow. The snapshot pattern solves this by storing the full state of the aggregate at a certain version. When you load the aggregate, you fetch the latest snapshot and then only replay the events that happened after that snapshot.
I keep a snapshots table with the aggregate_id, version, and a JSONB state column. Every N events (say every 100), I save a snapshot.
Here is how I modify the aggregate root to support snapshots:
class Account
def to_snapshot
{ balance: @balance, version: @version }
end
def self.from_snapshot(id, snapshot_data, events)
state = snapshot_data['state']
version = snapshot_data['version']
account = new(id)
account.instance_variable_set(:@balance, state['balance'])
account.instance_variable_set(:@version, version)
events.each { |e| account.apply_event(e) }
account
end
end
Then in the command handler, I check for a snapshot:
def load_aggregate(aggregate_id)
snapshot = Snapshot.where(aggregate_id: aggregate_id).order(version: :desc).first
after_version = snapshot ? snapshot.version : 0
events = Event.where(aggregate_id: aggregate_id)
.where('version > ?', after_version)
.order(:created_at)
if snapshot
Account.from_snapshot(aggregate_id, snapshot, events)
else
Account.new(aggregate_id, events.map { |e| event_to_hash(e) })
end
end
I also add a background job that periodically creates snapshots for aggregates with many events. This pattern keeps replay fast even after years of use.
Pattern Seven – The Event Bus / Publisher
The final pattern is how events get delivered to projections and sagas. In small Rails apps, I use a simple in-process bus that fires after commit. But as the app grows, you may want a more reliable publisher, like using Active Job or a message queue.
I have a module that wraps the event store append and publishes the event to all subscribers:
module EventPublisher
def self.append_and_publish(aggregate_id:, event_type:, data:, metadata: {})
event = Event.append(
aggregate_id: aggregate_id,
event_type: event_type,
data: data,
metadata: metadata
)
# Notify all subscribers
ActiveSupport::Notifications.instrument('event.appended', event: event)
event
end
end
Then my projections subscribe to 'event.appended'. This keeps the store append and publishing in one transaction. For production, I swap out the publisher to use a background job to ensure that the projections do not slow down the write path. For example, I use broadcast_later from Active Job:
class EventBroadcastJob < ApplicationJob
def perform(event_id)
event = Event.find(event_id)
# call all projectors
end
end
Now the store append is fast, and the projections happen asynchronously. The event bus pattern gives you the flexibility to add new subscribers without changing core code.
Putting It All Together in a Rails App
When I build a new feature with Event Sourcing, I start by defining the events and the aggregate root. Then I create the command handler and the event store. I write a projection for the first query I need. I add a snapshot job for aggregates that I know will grow. Finally, I wire the event bus.
Here is a complete example of a controller that accepts a deposit:
class DepositsController < ApplicationController
def create
handler = DepositHandler.new
account = handler.call(
account_id: params[:account_id],
amount: params[:amount].to_f,
metadata: { user_id: current_user.id, ip: request.remote_ip }
)
render json: { balance: account.balance }, status: :ok
rescue => e
render json: { error: e.message }, status: :unprocessable_entity
end
end
That controller is thin. All logic is in the handler and aggregate. The projection updates the read model automatically via the event bus.
Why These Seven Patterns Work
Each pattern solves a specific problem. The event store gives you immutable history. The aggregate root keeps business rules pure. The command handler ensures consistency. Projections make reads fast. Sagas handle cross-aggregate workflows. Snapshots keep performance steady. The event bus decouples publishers from subscribers.
I have used this approach in several Rails applications with thousands of events per day. It requires discipline, but it pays off when you need to debug a production issue or add a new feature that requires a different view of the data.
A Personal Note
When I first learned Event Sourcing, I thought it was overkill. But after my second time spending a week fixing a messed up database state because someone ran an UPDATE without a WHERE, I decided to give it a try. The first pattern I implemented was the event store. I couldn’t believe how liberating it was to never worry about destructive migrations again. Every time a bug happened, I could replay the events up to the point before the bug and see exactly what went wrong.
The hardest pattern for me was the saga. I kept trying to put all the logic into a single controller. Now I treat sagas as small event handlers that live on their own. They are easier to test and modify.
Final Advice
Start small. Pick one aggregate—say a TodoList—and implement pattern one and two. Do not worry about projections or sagas until you need them. The event store table and the aggregate root are enough to get started. Then add a projection when you need a dashboard. Add a snapshot when loads feel slow. Add a saga when you have to coordinate two things.
You do not need a fancy event store library. Rails and PostgreSQL are perfectly capable. The patterns I shared with you are simple and proven. I use them every day, and they have never let me down.
Now go ahead and create your first event table. Play with it. You will quickly see why Event Sourcing changes the way you think about data.