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.

7 Event Sourcing Patterns in Ruby on Rails Every Developer Should Know

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.


// Keep Reading

Similar Articles