Event sourcing without the ceremony most teams never needed

Event sourcing without ceremony is not a conference-stack architecture. Most teams need an append-only event log, a projection table, and clear write boundaries — not Kafka clusters, snapshot stores, and CQRS middleware before the product has customers.

Engineering8 min read
Event sourcingSoftware architecturePostgresAudit logBackend
Share

Event sourcing without ceremony starts with a question most architecture reviews skip: what problem does the event log solve that a normal table cannot? The textbook answer — complete history, temporal queries, replayable state — is real. The textbook implementation — dedicated event store, snapshot pipeline, projection workers, versioned schemas, saga orchestration — is often disproportionate. Teams adopt the label before they need the machinery. Six months later they maintain three write paths, two message buses, and a replay job nobody trusts, all to answer "who changed this invoice status?" — a question an append-only audit table answers in forty lines of SQL.

The useful middle ground is lightweight event sourcing: treat domain mutations as immutable events, project current state into query-friendly tables, and defer the heavy infrastructure until replay volume, compliance scope, or cross-service choreography actually demands it. The pattern preserves the core benefit — auditable history and reconstructable state — without paying the distributed systems tax on day one.

Full event sourcing fails when ceremony arrives before demand

Classic event sourcing prescribes a stack. Commands enter an aggregate. The aggregate emits events. Events land in an append-only store. Projections consume the stream and build read models. Snapshots accelerate aggregate reload. Process managers coordinate multi-step workflows across aggregates. Each layer solves a real problem at scale. Each layer also adds operational surface: schema evolution rules, idempotent consumers, poison-message handling, monitoring for projection lag, and on-call runbooks for replay failures.

Small teams hit the wall when they implement the full stack for a single bounded context inside a monolith. The write model becomes an event-sourced island in a CRUD sea. Developers must remember which tables are projections (never update directly) and which are legacy (update freely). Debugging a user-facing bug requires tracing from the read model back through projection code to the event that caused the divergence — often across three repositories and a local Kafka container.

The failure mode is not theoretical. A billing module event-sourced "for future flexibility" while subscriptions, usage metering, and invoicing remain CRUD produces split-brain semantics: refunds appear in the event log but the dashboard reads a materialized view refreshed five minutes late. Support tickets report "the system says refunded but the balance is wrong." The team spends a sprint building snapshot infrastructure instead of fixing the projection bug.

Event sourcing without ceremony means the log is the audit trail first and the architecture religion second.

The diagnostic is simple. If the primary consumer of the event stream is a compliance auditor or an incident investigator — not a fleet of real-time projections — the team needs an audit log, not event sourcing. If the primary consumer is one read model inside the same deployable, the team needs an append-only table and a projection function, not a message bus.

Lightweight event sourcing is an append-only log plus a projection

The minimal viable pattern has three pieces.

An append-only events table. Each row records what happened: event type, aggregate identifier, payload as JSONB, actor, timestamp, and optionally a monotonic sequence or hash chain for tamper evidence. No updates. No deletes except governed retention archival. Postgres handles this natively with a single table, BRIN indexes on occurred_at for time-range scans, and GIN indexes on extracted payload keys for entity lookups.

A projection into current state. A function — trigger, background worker, or inline transaction step — applies each event to a query-optimized table. InvoiceCreated inserts a row. InvoicePaid updates status and paid_at. The projection table is what the application reads. The events table is what investigators and replay jobs read.

A write boundary. Application code never mutates projection tables except through event application. That rule is enforced by convention in a monolith (module-private writers) or by database permissions in stricter setups. One entry point emits events; one code path applies them.

CREATE TABLE domain_events (
  id            BIGSERIAL PRIMARY KEY,
  aggregate_id  UUID NOT NULL,
  event_type    TEXT NOT NULL,
  payload       JSONB NOT NULL,
  actor_id      UUID,
  occurred_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);
 
CREATE INDEX domain_events_aggregate_idx
  ON domain_events (aggregate_id, occurred_at);
 
CREATE TABLE invoices (
  id          UUID PRIMARY KEY,
  status      TEXT NOT NULL,
  amount_cents INTEGER NOT NULL,
  paid_at     TIMESTAMPTZ
);
 
-- Projection applied in the same transaction as the event insert
INSERT INTO domain_events (aggregate_id, event_type, payload, actor_id)
VALUES ($1, 'InvoicePaid', '{"paid_at": "2026-07-10T12:00:00Z"}', $2);
 
UPDATE invoices
SET status = 'paid', paid_at = '2026-07-10T12:00:00Z'
WHERE id = $1;

This is not CQRS. It is not microservices. It is an honest record of mutations with a derived read surface — the same pattern compliance frameworks describe as an immutable audit trail, extended with enough structure to rebuild state if the projection drifts.

The modular monolith architecture makes this pattern natural: the events table lives inside the owning module's schema, the projection tables are module-private, and other modules see only the public query API. Boundaries stay language-enforced until extraction genuinely requires a network.

Three signals that justify more machinery

Not every system stops at the minimal pattern. Three signals indicate the ceremony-heavy stack earns its cost.

Replay at scale. Rebuilding projections from scratch must complete within a defined recovery window — hours, not days. A million events replayed through synchronous triggers will not meet that SLA. Snapshot stores and background projection workers become justified when replay is a production operation, not a developer convenience.

Cross-service choreography. Multiple deployables must react to the same event stream with ordering guarantees. An in-process append table cannot coordinate a payment service, a fulfillment worker, and a notification service. A durable log with consumer groups — Kafka, NATS JetStream, Postgres logical replication with careful design — enters when the network boundary is real, not anticipated.

Regulatory immutability with external proof. SOC 2, HIPAA, and financial record-keeping sometimes require tamper evidence beyond database permissions. Hash-chained event rows, WORM storage exports, or third-party anchoring justify additional infrastructure when auditors ask for cryptographic proof, not just access controls.

Until one of these signals is present, adding Kafka is speculation. Adding snapshot versioning is speculation. The lightweight log handles the first 100k events and the first compliance questionnaire.

Schema evolution without an event versioning framework

The hardest practical problem in event sourcing is not storage — it is schema change. Events are immutable. Payloads written two years ago still deserialize today.

Lightweight event sourcing handles evolution with conventions, not frameworks.

Upcast at read time. Store events in their original shape. When replaying or projecting, a small function migrates InvoiceCreatedV1 payloads to the current shape before application. Version lives in event_type suffix or a schema_version column.

Additive changes only in flight. New fields are optional with defaults. Removed fields stay in old events but are ignored by new projection code. Breaking renames go through a new event type, not an in-place mutation.

Projection rebuilds are the escape hatch. If projection logic changes materially, truncate the projection table and replay from the event log. With under a million events this completes in minutes on Postgres. The rebuild script is the integration test for schema evolution — if replay fails, the migration is not safe.

Avoid premature event type proliferation. UserEmailChanged, UserEmailChangedV2, and UserEmailCorrected in the same quarter means the domain model was not stable enough for event sourcing yet. Fix the model before fixing the log.

When does lightweight event sourcing beat CRUD plus audit triggers?

The decision is not ideological. It is operational.

What is the primary read pattern?

If the application always queries current state and occasionally needs history for support, CRUD with an audit trigger on the same table is simpler. Postgres triggers capture old_value and new_value JSONB on every update — sufficient for "who changed this row?" Lightweight event sourcing wins when history is a first-class product surface: activity feeds, undo, temporal reports, or state reconstruction after projection bugs.

Can the team enforce a single write path?

Event sourcing fails when developers bypass the log and write directly to projection tables "just this once." A modular monolith with package-private modules can enforce the boundary. A sprawling codebase without ownership cannot — and will accumulate projection drift faster than CRUD with triggers.

Does replay need to be automated or is audit export enough?

Compliance exports over a twelve-month window do not require replay infrastructure. They require indexed, immutable rows queryable by actor_id and occurred_at. Replay infrastructure is justified when incorrect projections must be rebuildable without manual SQL surgery — typically after the team has shipped at least one projection bug to production and felt the recovery cost.

A common argument runs the other way

The opposing view holds that partial event sourcing is worse than none — that teams should either commit to the full pattern with proper tooling or stay on CRUD, because a half-implemented event store creates more confusion than a disciplined audit log.

That argument is correct for teams without enforcement discipline. A projection table updated from three code paths is worse than honest CRUD. It is incorrect for teams that already enforce module boundaries and need reconstructable state inside one deployable. Lightweight event sourcing is the middle path for bounded contexts where history matters but a message bus does not exist yet.

The failure mode to avoid is labeling CRUD tables "events" without append-only semantics. An events table with ON UPDATE CASCADE is not event sourcing. It is a badly named audit log that will fail the first immutability audit.

Key takeaways

  • Event sourcing without ceremony is an append-only log, a projection table, and a single write boundary — not a message bus by default.
  • If the main consumer of the stream is compliance or incident investigation, start with an audit log.
  • Kafka, snapshots, and saga orchestration earn their cost at replay scale, cross-service choreography, or cryptographic immutability requirements.
  • Schema evolution uses upcast functions and projection rebuilds — not premature versioning frameworks.
  • The modular monolith is the natural host: one module owns the log, the projection, and the public query API.
  • Partial event sourcing without write-path discipline creates split-brain state worse than CRUD with triggers.

Conclusion

Event sourcing became synonymous with infrastructure heavy enough to need its own platform team. That association pushed teams toward either heroic adoption or total avoidance. The productive middle is smaller: record what happened, derive what is true now, and add machinery when measured pain — replay time, cross-service ordering, audit rigor — exceeds what Postgres and a disciplined module boundary can carry.

The next architecture review should not ask "should we event-source?" It should ask which mutations need immutable history, which queries need projected state, and whether any signal on the horizon justifies a durable log beyond the database the application already runs. Most early-stage systems answer with one append-only table and a clear rule about who may touch the read model. That is enough ceremony for a long time.

Related articles

Command Palette

Search for a command to run...