DEV Community

Alex Aslam
Alex Aslam

Posted on

Event Sourcing in Rails: Rebuilding Reality From a Stream of Truth

Your database is lying to you.

Every UPDATE users SET status = 'banned' erases history. Every DELETE FROM orders is digital amnesia. What if instead:

  • You could replay last month’s user signups to debug a fraud spike?
  • Your audit log was your database?
  • Undoing production mistakes meant rewinding events, not restoring backups?

Event Sourcing makes this possible. Here’s how to implement it in Rails—without rewriting your app.


Why Event Sourcing?

The Problems It Solves

  1. Lost Context: Traditional CRUD overwrites the "why" behind data changes.
  2. Debugging Nightmares: "How did this order total become $0?" requires forensic SQL.
  3. Temporal Queries: "Show me all users who were active last Tuesday at 3 PM."

When to Use It

Financial systems (non-repudiation is critical)
Regulated industries (audit trails by design)
Complex workflows (e.g., order fulfillment with rollbacks)

When to Avoid:
❌ Basic CRUD apps (overkill for todo lists)
❌ Low-latency requirements (event rebuilding adds overhead)


Core Concepts

1. Events Are Your Source of Truth

Instead of:

UPDATE accounts SET balance = 100 WHERE id = 123;
Enter fullscreen mode Exit fullscreen mode

You store:

AccountBalanceDeposited.new(account_id: 123, amount: 100, timestamp: Time.now)
Enter fullscreen mode Exit fullscreen mode

2. Projections Rebuild State

Need the current balance? Reducing all events:

events = EventStore.for(account_id: 123)
balance = events.reduce(0) { |sum, event| sum + event.amount }
Enter fullscreen mode Exit fullscreen mode

3. Commands Validate Before Events

class DepositMoney
  def call(account_id, amount)
    raise "Negative deposit" if amount < 0
    EventStore.publish(AccountBalanceDeposited.new(account_id:, amount:))
  end
end
Enter fullscreen mode Exit fullscreen mode

Implementing in Rails

Step 1: Choose an Event Store

Step 2: Model Your Events

# app/events/account_balance_deposited.rb
class AccountBalanceDeposited < RailsEventStore::Event
  def schema
    {
      account_id: String,
      amount: Integer,
      timestamp: Time
    }
  end
end
Enter fullscreen mode Exit fullscreen mode

Step 3: Build Projections

# app/projections/account_balance.rb
class AccountBalance
  def initialize(account_id)
    @events = EventStore.for(account_id: account_id)
  end

  def current_balance
    @events.sum(&:amount)
  end

  def as_of(timestamp)
    @events.up_to(timestamp).sum(&:amount)
  end
end
Enter fullscreen mode Exit fullscreen mode

Step 4: Handle Side Effects

Subscribe to events asynchronously:

Rails.configuration.event_store.subscribe(
  SendDepositNotification,
  to: [AccountBalanceDeposited]
)
Enter fullscreen mode Exit fullscreen mode

Advanced Patterns

1. Snapshots (For Performance)

Rebuilding from 10,000 events? Periodically save state:

Snapshot.create!(
  aggregate_id: account_id,
  data: { balance: 100 },
  version: 42
)
Enter fullscreen mode Exit fullscreen mode

2. CQRS (Command Query Responsibility Segregation)

  • Write Model: Handles commands → emits events.
  • Read Model: Optimized projections (e.g., materialized views).

3. Schema Evolution

Need to change an event? Use upcasters:

class AccountBalanceDeposited
  def upcast(old_event)
    old_event.data.merge(new_field: "default")
  end
end
Enter fullscreen mode Exit fullscreen mode

Pitfalls to Avoid

  • Event Spaghetti: Keep events small and focused.
  • Over-Engineering: Start with a single event stream.
  • Ignoring Idempotency: Design for replay safety.

Suggested Next Topics

  1. "Event Sourcing vs. CRUD: When 1000 Database Writes Don’t Matter"
  2. "CQRS in Rails: Scaling Reads and Writes Independently"
  3. "From Events to APIs: Building a REST Layer on Event Sourcing"
  4. "Testing Event-Sourced Systems: No More Fixtures, Just Replays"
  5. "When Event Sourcing Fails: War Stories from Production"

"But ActiveRecord Is Our Truth!"

It still can be. Event sourcing augments—it doesn’t require burning your CRUD models. Start small:

  1. Add event publishing to one critical model.
  2. Keep using ActiveRecord for queries.
  3. Gradually shift logic to projections.

Have you tried event sourcing? Share your "aha" moment (or horror story) below.

Top comments (2)

Collapse
 
dotallio profile image
Dotallio

Love how you broke this down, especially the advice to start with a single event stream to avoid over-complicating things. Have you found a good way to keep projections in sync when teams move fast?

Collapse
 
alex_aslam profile image
Alex Aslam

Great question! 🚀 The key is versioned projections—tag each projection with the event schema version it was built from. When schemas change, old projections stay intact while new ones handle the updated format.
Another lifesaver: automated schema checks in CI. Fail the build if a PR modifies events without updating projections.
How’s your team handling projection drift today? Would love to hear what’s worked (or backfired)! 🔍

Some comments may only be visible to logged-in visitors. Sign in to view all comments.