RUBY ON RAILS · 16 MIN READ ·

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.

Rails Event Sourcing: Append-Only Domain Events, Projecties en CQRS in Productie

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.

#rails-event-sourcing #rails-cqrs-pattern #rails-domain-events #event-sourcing-projections-rails #rails-append-only-audit-log

Related Articles

Laatste sectie. Bel dan alsjeblieft.

Het is een telefoongesprek. Erger dan dat kan het niet worden.

Geen discovery-deck. Geen 45-minuten "kwalificatiegesprek." 30 minuten, jouw probleem, mijn mening. Als we een fit zijn weet je dat in minuut 12.

Directe lijn — Roger neemt zelf op
+31 6 5123 6132
Ma–vr, 09:00–18:00 CET · Nu beschikbaar

OF
info@ttb.software