Rails Event Sourcing: Append-Only Domain Events, Projections, and CQRS in Production
Rails event sourcing: build append-only domain event logs, write projections, and implement CQRS patterns in production Ruby on Rails apps. Full code.
The investor meeting was on a Monday. The call came the Friday before.
A fintech startup needed a quick answer for their lead investor: show me exactly what happened to this customer’s account between February 3rd and February 17th. The answer should have been a ledger of events — what changed, who triggered each change, when, and why. What they had was a single balances table with an updated_at timestamp. Current state only. No history. The updated_at column showed the most recent write. Everything before it was gone.
They could not reconstruct the history. The investor meeting happened anyway, went poorly, and the next three weeks were spent building a transaction log that should have been there from the beginning.
After nineteen years of Rails I have seen this exact problem in billing systems, compliance-heavy SaaS platforms, inventory management tools, and anything with a meaningful concept of “how did we get to this state?” The fix, when you plan for it upfront, is called event sourcing. The fix when you retrofit it costs considerably more.
What Rails Event Sourcing Actually Means
Rails event sourcing is the practice of storing every meaningful state change as an immutable event rather than overwriting current state. Your events table is the source of truth. The current state of any record is a projection — a value derived by replaying the relevant events in order.
This is different from the audit trail approach (a versions or changes table bolted alongside your normal records). In a true event-sourced model, the events table is the system. The derived state tables are materialized caches.
In practice, most Rails applications do not need pure event sourcing. What they do need is: domain events that capture meaningful state transitions, append-only persistence for those events, and the ability to rebuild state from scratch. That is the useful 80%, and it is what this guide covers.
The Domain Event Model
Start with a domain_events table:
# db/migrate/20260609000001_create_domain_events.rb
class CreateDomainEvents < ActiveRecord::Migration[8.0]
def change
create_table :domain_events, id: :uuid do |t|
t.string :event_type, null: false
t.string :aggregate_type, null: false
t.uuid :aggregate_id, null: false
t.jsonb :payload, null: false, default: {}
t.uuid :caused_by_user_id
t.bigint :sequence, null: false
t.timestamps
end
add_index :domain_events, [:aggregate_type, :aggregate_id, :sequence],
unique: true,
name: "index_domain_events_on_aggregate_and_sequence"
add_index :domain_events, :event_type
add_index :domain_events, :created_at
end
end
aggregate_type and aggregate_id identify what the event belongs to — an order, a subscription, an account. sequence enforces ordering within an aggregate and prevents two events from occupying the same position. payload is a JSONB column holding the event-specific data.
# app/models/domain_event.rb
class DomainEvent < ApplicationRecord
validates :event_type, :aggregate_type, :aggregate_id, :sequence, presence: true
validates :sequence, uniqueness: { scope: [:aggregate_type, :aggregate_id] }
scope :for_aggregate, ->(type, id) {
where(aggregate_type: type, aggregate_id: id).order(:sequence)
}
scope :of_type, ->(type) { where(event_type: type) }
end
Publishing Events from Service Objects
The most reliable place to publish domain events is inside service objects — not ActiveRecord callbacks. Callbacks fire automatically, which is tempting, but they tightly couple your event system to the ActiveRecord lifecycle and make testing significantly harder. The ActiveRecord callbacks post covers exactly why callbacks are the wrong tool for cross-cutting concerns like this.
A pattern I use consistently:
# app/services/subscriptions/activate.rb
module Subscriptions
class Activate
def initialize(subscription:, activated_by:)
@subscription = subscription
@activated_by = activated_by
end
def call
ApplicationRecord.transaction do
@subscription.update!(
status: "active",
activated_at: Time.current
)
publish_event(
event_type: "subscription.activated",
payload: {
plan_id: @subscription.plan_id,
activated_at: @subscription.activated_at.iso8601
}
)
end
end
private
def publish_event(event_type:, payload:)
next_sequence = DomainEvent
.where(aggregate_type: "Subscription", aggregate_id: @subscription.id)
.maximum(:sequence)
.to_i + 1
DomainEvent.create!(
event_type: event_type,
aggregate_type: "Subscription",
aggregate_id: @subscription.id,
payload: payload,
caused_by_user_id: @activated_by.id,
sequence: next_sequence
)
end
end
end
The event write happens inside the same transaction as the state mutation. If the transaction rolls back, neither the state change nor the event lands. This is the core invariant of rails event sourcing: events and state stay consistent because they live in the same database transaction.
The maximum(:sequence) + 1 approach is safe under Postgres’s row-level locking within a transaction. For high-throughput aggregates where multiple writers compete, use an optimistic concurrency check instead:
def publish_event(event_type:, payload:, expected_sequence:)
DomainEvent.create!(
event_type: event_type,
aggregate_type: "Subscription",
aggregate_id: @subscription.id,
payload: payload,
caused_by_user_id: @activated_by.id,
sequence: expected_sequence
)
rescue ActiveRecord::RecordNotUnique
raise ConcurrencyConflict, "Another event was written at sequence #{expected_sequence}"
end
The unique index on [aggregate_type, aggregate_id, sequence] is the database-enforced form of optimistic locking. The second writer to claim a sequence number gets a RecordNotUnique exception and can retry from the caller with a fresh sequence read.
Building a Projection
A projection reads events in order and builds a derived representation. The simplest form is rebuilding a model’s state from scratch:
# app/projectors/subscription_projector.rb
class SubscriptionProjector
HANDLERS = {
"subscription.created" => :handle_created,
"subscription.activated" => :handle_activated,
"subscription.cancelled" => :handle_cancelled,
"subscription.renewed" => :handle_renewed
}.freeze
def self.project(subscription_id)
new.project(subscription_id)
end
def project(subscription_id)
state = {}
events = DomainEvent.for_aggregate("Subscription", subscription_id)
events.each do |event|
handler = HANDLERS[event.event_type]
send(handler, state, event.payload) if handler
end
state
end
private
def handle_created(state, payload)
state.merge!(
status: "pending",
plan_id: payload["plan_id"],
created_at: payload["created_at"]
)
end
def handle_activated(state, payload)
state.merge!(
status: "active",
activated_at: payload["activated_at"]
)
end
def handle_cancelled(state, payload)
state.merge!(
status: "cancelled",
cancelled_at: payload["cancelled_at"],
cancellation_reason: payload["reason"]
)
end
def handle_renewed(state, payload)
state.merge!(
status: "active",
current_period_end: payload["current_period_end"],
renewal_count: (state[:renewal_count] || 0) + 1
)
end
end
Call it as SubscriptionProjector.project(subscription.id). The result is the current state of the subscription as derived from its complete event history — fully auditable, fully reconstructable. If the investor asks “what was this account’s status on February 10th?”, you replay all events up to that timestamp and read the resulting state.
Snapshotting for Performance
For a subscription with a hundred renewal events, replaying everything on every read is slow. Snapshots solve this: record the projected state at a known sequence number, then on future reads load the snapshot and replay only the events that came after it.
# db/migrate/20260609000002_create_aggregate_snapshots.rb
class CreateAggregateSnapshots < ActiveRecord::Migration[8.0]
def change
create_table :aggregate_snapshots do |t|
t.string :aggregate_type, null: false
t.uuid :aggregate_id, null: false
t.bigint :sequence, null: false
t.jsonb :state, null: false, default: {}
t.timestamps
end
add_index :aggregate_snapshots, [:aggregate_type, :aggregate_id], unique: true
end
end
Update the projector to use snapshots:
def project(subscription_id)
snapshot = AggregateSnapshot.find_by(
aggregate_type: "Subscription",
aggregate_id: subscription_id
)
state = snapshot&.state&.symbolize_keys || {}
from_sequence = snapshot&.sequence.to_i + 1
events = DomainEvent
.for_aggregate("Subscription", subscription_id)
.where("sequence >= ?", from_sequence)
events.each do |event|
handler = HANDLERS[event.event_type]
send(handler, state, event.payload) if handler
end
state
end
Take a snapshot from a background job after every N events. The Solid Queue background jobs guide covers scheduling options that keep this lightweight:
# app/jobs/snapshot_aggregate_job.rb
class SnapshotAggregateJob < ApplicationJob
def perform(aggregate_type, aggregate_id)
latest = DomainEvent.for_aggregate(aggregate_type, aggregate_id).last
return unless latest
state = SubscriptionProjector.project(aggregate_id)
AggregateSnapshot.upsert(
{ aggregate_type: aggregate_type, aggregate_id: aggregate_id,
sequence: latest.sequence, state: state },
unique_by: [:aggregate_type, :aggregate_id]
)
end
end
CQRS: Separate Read and Write Models
Once you have rails event sourcing, CQRS falls out naturally. Your write model handles commands — “activate this subscription” — and appends events. Your read model is a materialized view built from those events, optimized for query patterns.
In Rails, the simplest implementation keeps both in the same Postgres database but in separate tables:
# The write model: pure events
DomainEvent.for_aggregate("Subscription", id)
# The read model: denormalized, indexed for query
SubscriptionReadModel.active.includes(:customer).page(params[:page])
The read model is updated asynchronously after each event:
# app/jobs/update_subscription_read_model_job.rb
class UpdateSubscriptionReadModelJob < ApplicationJob
def perform(subscription_id)
state = SubscriptionProjector.project(subscription_id)
SubscriptionReadModel.upsert(
{ id: subscription_id, **state },
unique_by: :id
)
end
end
Trigger it from the service object after publishing:
def call
ApplicationRecord.transaction do
@subscription.update!(status: "active", activated_at: Time.current)
publish_event(...)
end
UpdateSubscriptionReadModelJob.perform_later(@subscription.id)
end
Note the job enqueue is outside the transaction — you do not want a failed job enqueue to roll back a committed state change. The read model may lag a second or two behind the event log. For most use cases that is acceptable and well worth the query performance.
Your admin queries hit subscription_read_models — indexed, denormalized, fast. Your audit queries hit domain_events. Each table does one job well.
When to Use Event Sourcing in Rails
Use it when you need a complete, reconstructable audit trail by design: financial transactions, compliance-heavy systems, inventory ledgers, anything where “what happened?” is a query you will need to answer in production. Use it when multiple downstream systems need to react to the same state changes — the event log becomes a clean integration point for webhooks, analytics pipelines, and external services.
Skip it when you just need an audit log on an existing CRUD model. The paper_trail gem adds a versioned versions table to any ActiveRecord model with three lines of configuration. That is the right tool for “we want to see who changed this record and when.” Event sourcing is the right tool when the events themselves are the product.
Skip it when the system is pure CRUD with no domain transitions worth capturing, or when you are three engineers with a deadline in two weeks. The overhead is real. The incremental Rails upgrade strategy principle applies here: pick one bounded context where event history matters, implement it there, and leave the rest of the app alone.
The fintech startup from the opening did not need full event sourcing across their whole application. They needed a ledger table for one aggregate — account balance changes — with append-only rows and a clear event type on each row. That is event sourcing at its minimum viable form, and it is enough for most audit requirements. If they had built that on day one, the investor meeting would have gone differently.
FAQ
What is the difference between event sourcing and event-driven architecture in Rails?
Event sourcing means storing state as a sequence of immutable events — the events table is the source of truth. Event-driven architecture means systems communicate by publishing and subscribing to events, often over a message bus like Kafka or SQS. They are compatible but independent. You can have an event-driven Rails app that emits events over a message bus without using event sourcing for internal storage. You can use event sourcing internally without a distributed event bus. The patterns are orthogonal.
How do I query current state efficiently in a rails event sourcing setup?
Maintain a materialized read model — either in memory for small aggregates or in a dedicated read table for anything queried at scale. The read table is an implementation detail, not the source of truth. If it is dropped or corrupted, you rebuild it by replaying events from scratch. Never treat the read table as canonical; the event log is the only thing you must never lose.
Can I add rails event sourcing to an existing app without a full rewrite?
Yes. Add the domain_events table, identify one bounded context where audit history matters — billing, inventory, user role changes — and start publishing events alongside your existing update! calls. Run both in parallel: the existing state columns for current reads, the event log for history. Once you trust the event stream, derive the read model from events instead of from the state columns and drop the old columns. You do not need to touch the rest of the app. Identify one seam, make it work, then expand.
Should I use the rails_event_store gem or build it myself?
rails_event_store is a solid library that gives you subscriptions, asynchronous handlers, and projections as first-class objects. It is worth using if you need those features immediately and at scale. The implementation in this post — a domain_events table, a projector class, and a read model job — is under three hundred lines of plain Rails and covers the core of what most applications need. Start with the plain approach. Reach for the gem when your requirements outgrow it.
Building a billing or compliance system where “what happened?” needs to be answerable on demand? TTB Software designs event-driven Rails architectures that make audit trails, reconciliation, and historical queries a first-class feature — not a retrofit. We have been doing this for nineteen years.
Related Articles
Rails Zeitwerk Autoloading: Fix NameErrors, Eager Loading, and the Classic Loader Migration
Rails Zeitwerk autoloading explained: fix NameErrors, understand eager loading gaps, migrate from Classic loader, and...
Rails API Versioning: URL Namespaces, Header Routing, and Graceful Deprecation
Rails API versioning done right: URL namespaces, Accept header routing, controller inheritance, and Sunset headers fo...
Solid Queue Recurring Jobs: Replace Whenever and Sidekiq-Cron in Rails 8
Solid Queue recurring jobs replace whenever and sidekiq-cron in Rails 8. Configure cron tasks, handle missed executio...