Rails Event Sourcing: Append-Only Domain Events, Projecties en CQRS in Productie
Rails event sourcing: bouw append-only domain event logs, schrijf projecties en implementeer CQRS-patronen in productie Ruby on Rails apps. Volledige code.
Het investeerdersgesprek was op maandag. Het telefoontje kwam de vrijdag daarvoor.
Een fintech-startup had een snelle vraag van hun lead investor: laat me precies zien wat er met dit klantenaccount is gebeurd tussen 3 en 17 februari. Het antwoord had een kasboek van gebeurtenissen moeten zijn — wat er veranderd was, wie elke wijziging had veroorzaakt, wanneer, en waarom. Wat ze hadden was één enkele balances-tabel met een updated_at-timestamp. Alleen de huidige staat. Geen geschiedenis. De updated_at-kolom toonde de meest recente schrijfactie. Alles ervoor was verdwenen.
Ze konden de geschiedenis niet reconstrueren. Het investeerdersgesprek ging door, liep slecht af, en de drie weken daarna werden besteed aan het bouwen van een transactielogboek dat er vanaf het begin had moeten zijn.
Na negentien jaar Rails heb ik dit probleem gezien in facturatiesystemen, compliance-zwaar SaaS-platforms, voorraadbeheertools en alles met een relevant concept van “hoe zijn we in deze toestand beland?” De oplossing, als je er vooraf op plant, heet event sourcing. De oplossing als je hem achteraf inbouwt kost aanzienlijk meer.
Wat Rails Event Sourcing Eigenlijk Betekent
Rails event sourcing is de praktijk van het opslaan van elke betekenisvolle toestandswijziging als een onveranderlijk event, in plaats van de huidige toestand te overschrijven. Je eventstabel is de bron van waarheid. De huidige toestand van elke record is een projectie — een waarde afgeleid door de relevante events in volgorde opnieuw af te spelen.
Dit verschilt van de audit-trail aanpak (een aparte versions- of changes-tabel naast je normale records). In een puur event-gesourced model is de eventstabel het systeem. De afgeleide toestandstabellen zijn gematerialiseerde caches.
In de praktijk hebben de meeste Rails-applicaties geen pure event sourcing nodig. Wat ze wel nodig hebben is: domain events die betekenisvolle toestandsovergangen vastleggen, append-only persistentie voor die events, en de mogelijkheid om toestand helemaal opnieuw op te bouwen. Dat is de nuttige 80%, en dat is waar deze gids over gaat.
Het Domain Event Model
Begin met een domain_events-tabel:
# 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 en aggregate_id identificeren waarvan het event deel uitmaakt — een bestelling, een abonnement, een account. sequence dwingt volgorde af binnen een aggregaat en voorkomt dat twee events dezelfde positie innemen. payload is een JSONB-kolom met de event-specifieke 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
Events Publiceren vanuit Service Objects
De meest betrouwbare plek om domain events te publiceren is binnen service objects — niet in ActiveRecord callbacks. Callbacks vuren automatisch, wat verleidelijk is, maar koppelt je eventsysteem strak aan de ActiveRecord lifecycle en maakt testen aanzienlijk moeilijker. De ActiveRecord callbacks post behandelt precies waarom callbacks het verkeerde gereedschap zijn voor cross-cutting concerns zoals dit.
Een patroon dat ik consequent gebruik:
# 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
De eventschrijfactie vindt plaats binnen dezelfde transactie als de toestandsmutatie. Als de transactie teruggerold wordt, landt noch de toestandswijziging noch het event. Dit is de kern-invariant van rails event sourcing: events en toestand blijven consistent omdat ze in dezelfde databasetransactie leven.
De maximum(:sequence) + 1-aanpak is veilig onder Postgres’ rij-niveau locking binnen een transactie. Voor zwaar belaste aggregaten waar meerdere schrijvers concurreren, gebruik je optimistische gelijktijdigheidscontrole:
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
De unieke index op [aggregate_type, aggregate_id, sequence] is de database-afgedwongen vorm van optimistisch vergrendelen. De tweede schrijver die een volgnummer opeist krijgt een RecordNotUnique-uitzondering en kan vanuit de aanroeper opnieuw proberen met een vers gelezen volgnummer.
Een Projectie Bouwen
Een projectie leest events in volgorde en bouwt een afgeleide representatie. De eenvoudigste vorm is het opnieuw opbouwen van de toestand van een model vanaf nul:
# 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
Roep het aan als SubscriptionProjector.project(subscription.id). Het resultaat is de huidige toestand van het abonnement, afgeleid van zijn volledige eventgeschiedenis — volledig te controleren, volledig te reconstrueren. Als de investeerder vraagt “wat was de status van dit account op 10 februari?”, speel je alle events tot dat tijdstip opnieuw af en lees je de resulterende toestand.
Snapshotting voor Prestaties
Voor een abonnement met honderd verlengingsevents is het telkens opnieuw afspelen van alles bij elke leesactie traag. Snapshots lossen dit op: registreer de geprojecteerde toestand bij een bekend volgnummer, en laad bij toekomstige lezingen de snapshot en speel alleen de events daarna opnieuw af.
# 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
Werk de projector bij om snapshots te gebruiken:
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
Maak een snapshot vanuit een achtergrondtaak na elke N events. De Solid Queue achtergrondtaken gids behandelt planningsopties die dit lichtgewicht houden:
# 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: Aparte Lees- en Schrijfmodellen
Zodra je rails event sourcing hebt, volgt CQRS vanzelf. Je schrijfmodel verwerkt commando’s — “activeer dit abonnement” — en voegt events toe. Je leesmodel is een gematerialiseerde weergave gebouwd uit die events, geoptimaliseerd voor querypatronen.
In Rails houdt de eenvoudigste implementatie beide in dezelfde Postgres-database maar in aparte tabellen:
# Het schrijfmodel: pure events
DomainEvent.for_aggregate("Subscription", id)
# Het leesmodel: gedenormaliseerd, geïndexeerd voor queries
SubscriptionReadModel.active.includes(:customer).page(params[:page])
Het leesmodel wordt asynchroon bijgewerkt na elk 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
Gooi de taak vanuit de service object in de wachtrij na het publiceren:
def call
ApplicationRecord.transaction do
@subscription.update!(status: "active", activated_at: Time.current)
publish_event(...)
end
UpdateSubscriptionReadModelJob.perform_later(@subscription.id)
end
Let op: de taakwachtrij staat buiten de transactie — je wilt niet dat een mislukte taakinwachtrij een gecommitteerde toestandswijziging teruggooit. Het leesmodel loopt mogelijk een seconde of twee achter op het eventlogboek. Voor de meeste use cases is dat acceptabel en ruimschoots de prestatiewinst waard.
Je admin-queries raken subscription_read_models — geïndexeerd, gedenormaliseerd, snel. Je audit-queries raken domain_events. Elke tabel doet één ding goed.
Wanneer Event Sourcing te Gebruiken in Rails
Gebruik het wanneer je een complete, reconstrueerbare audittrail per ontwerp nodig hebt: financiële transacties, compliance-zware systemen, voorraadboeken, alles waarbij “wat is er gebeurd?” een vraag is die je in productie moet kunnen beantwoorden. Gebruik het wanneer meerdere downstream-systemen op dezelfde toestandswijzigingen moeten reageren — het eventlogboek wordt een schoon integratiepunt voor webhooks, analytics-pipelines en externe diensten.
Sla het over wanneer je alleen een auditlogboek nodig hebt op een bestaand CRUD-model. De paper_trail gem voegt een geversioneerde versions-tabel toe aan elk ActiveRecord-model met drie regels configuratie. Dat is het juiste gereedschap voor “we willen zien wie deze record heeft gewijzigd en wanneer.” Event sourcing is het juiste gereedschap wanneer de events zelf het product zijn.
Sla het over wanneer het systeem puur CRUD is zonder domeinovergangen die het vastleggen waard zijn, of wanneer je drie engineers bent met een deadline over twee weken. De overhead is reëel. De incrementele Rails upgrade strategie geldt hier ook: kies één bounded context waar eventgeschiedenis telt, implementeer het daar, en laat de rest van de applicatie onaangeroerd.
De fintech-startup uit het begin had geen volledige event sourcing door de hele applicatie nodig. Ze hadden een grootboektabel nodig voor één aggregaat — accountbalanswijzigingen — met append-only rijen en een duidelijk eventtype op elke rij. Dat is event sourcing in zijn minimum viable vorm, en het is genoeg voor de meeste auditvereisten. Als ze dat op dag één hadden gebouwd, was het investeerdersgesprek anders verlopen.
Veelgestelde Vragen
Wat is het verschil tussen event sourcing en event-driven architectuur in Rails?
Event sourcing betekent toestand opslaan als een reeks onveranderlijke events — de eventstabel is de bron van waarheid. Event-driven architectuur betekent dat systemen communiceren door events te publiceren en te abonneren, vaak via een message bus zoals Kafka of SQS. Ze zijn compatibel maar onafhankelijk. Je kunt een event-driven Rails-app hebben die events uitzendt via een message bus zonder event sourcing te gebruiken voor interne opslag. Je kunt event sourcing intern gebruiken zonder een gedistribueerde event bus. De patronen zijn orthogonaal.
Hoe bevraag ik de huidige toestand efficiënt in een rails event sourcing opzet?
Onderhoud een gematerialiseerd leesmodel — in geheugen voor kleine aggregaten, of in een speciale leesstabel voor alles wat op schaal bevraagd wordt. De leesstabel is een implementatiedetail, niet de bron van waarheid. Als hij wordt verwijderd of beschadigd, bouw je hem opnieuw op door events opnieuw af te spelen. Behandel de leesstabel nooit als canoniek; het eventlogboek is het enige dat je nooit mag verliezen.
Kan ik rails event sourcing toevoegen aan een bestaande app zonder volledige herschrijving?
Ja. Voeg de domain_events-tabel toe, identificeer één bounded context waar auditgeschiedenis ertoe doet — facturering, voorraadbeheer, wijzigingen in gebruikersrollen — en begin events te publiceren naast je bestaande update!-aanroepen. Laat beide parallel lopen: de bestaande toestandskolommen voor huidige lezingen, het eventlogboek voor geschiedenis. Zodra je het event stream vertrouwt, leid je het leesmodel af van events in plaats van van de toestandskolommen en verwijder je de oude kolommen. Je hoeft de rest van de app niet aan te raken. Identificeer één naad, laat het werken, breid dan uit.
Moet ik de rails_event_store gem gebruiken of zelf bouwen?
rails_event_store is een solide bibliotheek die abonnementen, asynchrone handlers en projecties als first-class objecten biedt. Het is de moeite waard als je die functies direct op schaal nodig hebt. De implementatie in dit artikel — een domain_events-tabel, een projectorklasse en een leesmodeltaak — is onder de driehonderd regels gewone Rails en dekt de kern van wat de meeste applicaties nodig hebben. Begin met de eenvoudige aanpak. Grijp naar de gem wanneer je vereisten eroverheen groeien.
Bouw je een facturerings- of compliancesysteem waarbij “wat is er precies gebeurd?” op afroep beantwoord moet kunnen worden? TTB Software ontwerpt event-driven Rails-architecturen die audittrails, reconciliatie en historische queries een first-class feature maken — geen retrofit. We doen dit al negentien jaar.
Related Articles
Rails Zeitwerk Autoloading: NameErrors oplossen, Eager Loading en de Classic Loader Migratie
Rails Zeitwerk autoloading uitgelegd: fix NameErrors, begrijp eager loading valkuilen, migreer van Classic loader, en...
Rails API Versiebeheer: URL Namespaces, Header Routing en Nette Deprecatie
Rails API versiebeheer goed aanpakken: URL namespaces, Accept header routing, controller overerving en Sunset headers...
Solid Queue Recurring Jobs: Vervang Whenever en Sidekiq-Cron in Rails 8
Solid Queue recurring jobs vervangen whenever en sidekiq-cron in Rails 8. Leer recurring task configuratie, dispatche...