Event Sourcing Fundamentals

BackendEvent SourcingArchitectureDDD
Share on LinkedIn

Support asks what the account balance was on March 3rd at 2 PM. With CRUD you might have a nullable updated_at and hope nobody ran a backfill. With event sourcing you replay AccountCredited, AccountDebited, and FeeApplied until that timestamp — exact, auditable, and the same mechanism that rebuilds read models after a bug fix. Event sourcing stores what happened as an immutable log; current state is a fold over that log. The pattern pairs naturally with CQRS and domain-driven design, but it pays for itself only when history and rebuildability are product requirements, not because conference talks said CRUD is boring.

Core concepts

Command → Aggregate → Events → Event Store
                              ↓
                         Projections (read models)

Example aggregate handler (conceptual C#):

public class Account : AggregateRoot
{
    public void Transfer(Money amount, AccountId destination, IdempotencyKey key)
    {
        if (ProcessedCommands.Contains(key)) return;

        if (Balance < amount)
            throw new InsufficientFundsException();

        Raise(new FundsTransferred(Id, destination, amount, key, DateTime.UtcNow));
    }

    void Apply(FundsTransferred e) => Balance -= e.Amount;
}

Events are past tense nouns — facts, not instructions.

Event store schema

Minimal stream table:

CREATE TABLE events (
    stream_id     UUID NOT NULL,
    version       BIGINT NOT NULL,
    event_type    TEXT NOT NULL,
    payload       JSONB NOT NULL,
    metadata      JSONB NOT NULL,  -- correlation_id, causation_id, user_id
    occurred_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (stream_id, version)
);

CREATE INDEX events_type_idx ON events (event_type, occurred_at);

version enforces optimistic concurrency — append fails if expected version mismatch ( concurrent write).

Global sequence number optional for cross-aggregate ordering in projections.

Commercial options: EventStoreDB, Marten, Axon Server — or PostgreSQL with careful indexing.

Snapshots for performance

Replaying 50,000 events per load is slow. Periodic snapshots:

{
  "stream_id": "account-123",
  "version": 50000,
  "state": { "balance": 4200.50, "currency": "USD" }
}

Load snapshot at v50000 + replay events 50001+.

Snapshot frequency trade-off: storage vs replay time — nightly or every N events.

Idempotency and at-least-once delivery

Message brokers redeliver. Clients retry on timeout. Without idempotency:

// BAD — double spend on retry
Raise(new FundsTransferred(...)); // twice

Fix: idempotency key in command stored in aggregate or dedup table:

CREATE TABLE processed_commands (
    idempotency_key TEXT PRIMARY KEY,
    processed_at TIMESTAMPTZ NOT NULL
);

Or embed key in event and skip if already applied during load.

Event schema evolution

Events live forever — plan versioning:

{
  "event_type": "FundsTransferred",
  "schema_version": 2,
  "payload": { "amount_cents": 1000, "currency": "USD" }
}

Upcast old v1 events to v2 in application layer on read; avoid mutating stored JSON.

Never delete event types from code while streams contain them — tombstone handlers or migration projections.

When not to event source

Event sourcing adds operational surface: projection lag monitoring, schema migrations, debugging distributed replays.

CQRS pairing with event sourcing

Event sourcing stores writes as events. CQRS separates read models (projections) from write models (aggregates):

Command → Aggregate → Events → Event Store
                              ↓
                    Projection handlers → Read models (SQL, Elasticsearch, cache)
                              ↓
Query ← Read model (optimized for queries)

Write side optimized for business rules and consistency. Read side optimized for queries — denormalized, indexed, eventually consistent with write side.

Projection lag is normal — document expected lag (typically seconds, not hours). Monitor:

-- Projection lag check
SELECT
  stream_id,
  max(event_number) AS latest_event,
  projection_checkpoint,
  max(event_number) - projection_checkpoint AS lag
FROM event_streams
JOIN projection_checkpoints USING (stream_id)
WHERE lag > 1000;  -- alert threshold

Snapshotting for replay performance

Replaying 100k events to rebuild aggregate state is slow. Snapshots capture state at a point:

def load_aggregate(stream_id):
    snapshot = snapshot_store.get_latest(stream_id)
    events = event_store.read(stream_id, from_version=snapshot.version + 1)
    aggregate = snapshot.state
    for event in events:
        aggregate = apply(aggregate, event)
    return aggregate

Snapshot every N events (100–1000 depending on apply cost). EventStoreDB supports snapshots natively.

Event store selection

Store Best for
EventStoreDB Purpose-built, streams, projections
PostgreSQL (custom) Teams knowing Postgres, moderate volume
Kafka (log compaction) Already on Kafka, high throughput
Marten (.NET) .NET ecosystem, Postgres-backed

Don't event-source in vanilla MongoDB without append-only discipline — you lose ordering guarantees.

Failure modes

Production checklist

Common production mistakes

Teams get event sourcing fundamentals wrong in predictable ways:

Production implementations of event sourcing fundamentals fail when staging mirrors production topology poorly, rollback is untested, and on-call runbooks describe the happy path only.

Resources

Frequently asked questions

What is event sourcing in simple terms?

Event sourcing persists state changes as an append-only sequence of domain events instead of overwriting rows in place. Current state is derived by replaying events (or loading a snapshot plus recent events). The event log is the system of record — databases holding projections are derived and rebuildable.

When should I use event sourcing versus traditional CRUD?

Use event sourcing when audit history is intrinsic, you need temporal queries (state at any past time), multiple read models from one write model, or complex domain workflows with compensating events. Skip it for simple CRUD entities where event log complexity exceeds business value.

How do I handle duplicate commands in event sourcing?

Make command handlers idempotent using deterministic event IDs, idempotency keys on commands, or aggregate version checks (optimistic concurrency). Store processed command IDs in the aggregate or a side table; reject or no-op duplicates from client retries and message broker redelivery.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →