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
- Lost Context: Traditional CRUD overwrites the "why" behind data changes.
- Debugging Nightmares: "How did this order total become $0?" requires forensic SQL.
- 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;
You store:
AccountBalanceDeposited.new(account_id: 123, amount: 100, timestamp: Time.now)
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 }
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
Implementing in Rails
Step 1: Choose an Event Store
- Rails Event Store (simplest for Rails)
- Eventide (PostgreSQL-focused)
- Kafka (for cross-service events)
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
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
Step 4: Handle Side Effects
Subscribe to events asynchronously:
Rails.configuration.event_store.subscribe(
SendDepositNotification,
to: [AccountBalanceDeposited]
)
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
)
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
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
- "Event Sourcing vs. CRUD: When 1000 Database Writes Don’t Matter"
- "CQRS in Rails: Scaling Reads and Writes Independently"
- "From Events to APIs: Building a REST Layer on Event Sourcing"
- "Testing Event-Sourced Systems: No More Fixtures, Just Replays"
- "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:
- Add event publishing to one critical model.
- Keep using ActiveRecord for queries.
- Gradually shift logic to projections.
Have you tried event sourcing? Share your "aha" moment (or horror story) below.
Top comments (2)
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?
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.