Index

Publishing Events Is Not Event Sourcing

Two wallet applications accept the same credits and publish the same FundsCredited event. Both end with a balance of 3,500 cents. Both can update a listener-owned read model.

Now delete the wallet row and ask each application to recover.

The first application cannot. Its wallets.balance_minor column was the authoritative value. The events were notifications sent after accepted changes, and their payloads do not contain enough information to reconstruct the balance.

The second application deletes its balance projection, reads the wallet’s ordered event stream, and rebuilds the same result. Its current balance was always derived. The event stream is the authoritative record.

Both applications are event-driven. Only one is event sourced.

Delete the derived state and see what remains

“This system uses events” says nothing about how it persists state. A Laravel model can update a row and dispatch a domain event. A queued listener can consume it. A separate read model can project it. None of those choices makes the published event the source of the model’s current state.

The reproducible fixture makes the difference deliberately plain. Its current-state path performs two operations in one transaction:

UPDATE wallets
SET balance_minor = balance_minor + :amount
WHERE wallet_id = :wallet_id;

INSERT INTO application_notifications (
    event_id,
    stream_id,
    event_type,
    recorded_at
) VALUES (...);

The notification identifies what happened and who may react. It omits the credited amount, so it is not pretending to be complete rebuild history. Once the balance row is gone, two notifications remain but recovery stops with:

Cannot rebuild wallet-1: 2 notifications omit credited amounts.

That is a fair current-state design for the question it answers. It can use events for integration or local reactions while the row remains authoritative. Calling it failed event sourcing would be like calling a receipt a failed database backup.

The event-sourced path writes a different contract:

event_id
stream_id
stream_position
event_type
schema_version
payload
recorded_at

The Event Sourcing pattern describes this store as the system of record. Current wallet state is reconstructed by folding its events in stream order. Query tables are projections which may be discarded and rebuilt.

This suggests a practical diagnostic: delete every representation you call a projection. Which durable record can recreate the model without consulting the deleted rows? That record, not the presence of an event class or message broker, reveals the source of truth.

Replay is part of the write model

The fixture stores two versions of FundsCredited. Version 1 uses flat fields:

{
  "amount_cents": 1000,
  "currency": "EUR"
}

Version 2 introduces an explicit money shape:

{
  "money": {
    "minor": 2500,
    "currency": "EUR"
  }
}

Rehydration does not deserialize those rows directly into today’s wallet. An upcaster first gives both stored shapes one current meaning:

if ($event->schemaVersion === 1) {
    return [
        'minor' => $event->payload['amount_cents'],
        'currency' => $event->payload['currency'],
    ];
}

if ($event->schemaVersion === 2) {
    return $event->payload['money'];
}

The aggregate then applies the normalized credit. Three events across the two schemas reconstruct a 4,000-cent balance. The fixture hashes every stored row before and after replay; the hashes are identical. Old facts remain unchanged while current code adapts their representation.

Remove the version 1 branch and replay fails at the first old event. Accept an unknown version silently and a separate control fails because the application should report the type, stream, and position it cannot interpret:

Unsupported FundsCredited schema v3 at wallet-1:1.

There are other valid schema strategies. A consumer can keep version-specific handlers. An additive change can use tolerant deserialization. A new event type can make a breaking meaning explicit. EventSourcingDB’s versioning guide recommends new types for breaking schemas and transformation outside immutable storage.

The mechanism is negotiable. The obligation is not: if history remains the source of truth, current code must still understand the meanings needed to replay that history.

Order needs a durable number

The event-sourced fixture inserts position 2 before position 1 and gives the second event an earlier timestamp. Reading by recorded_at would apply them in the wrong order. Reading by stream_position returns positions 1, then 2, and rehydrates the expected balance.

A second control stores positions 1 and 3. Rehydration refuses the stream:

Stream wallet-1 expected position 2; received wallet-1:3.

Timestamps remain useful metadata. They are not a safe substitute for an ordered per-model sequence. Clock precision, clock skew, and concurrent writes do not express which accepted change owns the next position.

The position also protects writes. After the wallet reaches position 3, a writer which expected position 2 is refused. No fourth event is partially stored. This fixture demonstrates a stale-position check and a unique stream position in SQLite. It does not establish the concurrency behavior of a production event-store product or a distributed database.

An event stream is therefore not interchangeable with a message broker topic. A broker may distribute events well while lacking per-model reads and an expected-position append contract. Distribution and authoritative persistence can cooperate, but they answer different questions.

A projection is disposable only when replay works

The wallet balance projection keeps its own position. Applying the same event twice leaves one balance change, one external notification, and one checkpoint. That idempotency belongs to the consumer; the event object does not supply it.

The clean path applies three events incrementally, records its checksum, drops the projection, and rebuilds from an empty table. The rebuilt checksum and final position match the incremental result.

Then the failure controls make “we can rebuild” less ceremonial.

One projection crashes before position 2. The event store is already at position 3, while the projection remains at position 1 with its earlier balance. The difference is visible as lag. Resuming from the checkpoint processes positions 2 and 3 and reaches 4,000 cents.

Another projection starts with a deliberately wrong balance of 3,999 at position 3. Rebuilding through corrected code replaces that stale checksum without changing the event stream. Mutate the projector’s addition into subtraction and the repair test fails instead of blessing a newly corrupted read model.

Replay also suppresses the fixture’s external notifier. Rebuilding a database view must not resend three customer messages, charge a card again, or invoke another irreversible effect. A mutation removes that guard, and the replay test catches the repeated notifications.

Martin Fowler’s description calls complete rebuild a consequence of Event Sourcing. In working software, that consequence needs a command with checkpoints, processed counts, a failure position, a resulting checksum, and rules for side effects. “We could replay the events” is not yet an operating procedure.

Immutable history makes old mistakes current work

The fixture rejects a second append with an existing event ID and verifies that the stored-row hash did not change. A mutation replaces the insert with INSERT OR REPLACE; the immutability test fails because a later write can quietly change history.

Immutability is valuable precisely because it constrains correction. A wrong projection can be rebuilt from unchanged facts. A wrong fact cannot be fixed by deploying a better projector. It may need a compensating event, an explicit interpretation rule, or—under a carefully governed exceptional procedure—a history migration.

The pattern guidance also identifies a tension with personal-data deletion. Personal or secret data embedded in an event cannot be treated like a field in an ordinary mutable row. Encryption boundaries, deletion requirements, and retention policy must shape the event before it becomes durable history. This article does not test a regulatory solution; it identifies a design decision the append-only contract makes hard to postpone.

Schema support, replay tools, ordering, optimistic concurrency, idempotent projections, side-effect suppression, and repair are not optional polish around an event store. They are the work created by choosing the stream as truth.

Current-state persistence is often the honest answer

Event sourcing earns that work when the complete sequence matters to the business: historical reconstruction changes a decision, temporal state must be queried, or new interpretations must be derived from the original accepted facts.

It is a poor bargain when the system needs only the latest value and ordinary history can be handled by an audit table, temporal database feature, or ledger. Those alternatives can preserve row versions or transactions without making every domain model rehydrate through events.

Event sourcing also need not become the identity of an entire system. A model whose history is authoritative can use it while profiles, configuration, and reference data keep ordinary current-state persistence.

CQS versus CQRS makes the same boundary on the read side: separate models and projections do not require event sourcing. A Domain Event Is a Fact, Not a Question explains what earns the event name before persistence enters the decision.

Start with the recovery question. If every derived row vanished, would the ordered facts be the record you are required to trust? If not, publish the events you need and keep the simpler source of truth. If yes, prove that the oldest schema still replays, a stale writer cannot append, projection lag is visible, repair is repeatable, and replay cannot repeat external effects.

Publishing an event is a collaboration choice. Event sourcing is a persistence and operating commitment.