Index

Commands, Events, and Processes

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, and both can update a listener-owned read model. So far, the difference is invisible. 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 almost 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 event classes may look impressive while the database is still doing perfectly ordinary current-state persistence.

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. It was never meant to rebuild anything.

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.

There is a blunt way to find the source of truth: delete every representation you call a projection. Which durable record can recreate the model without consulting those rows? That record matters. The presence of an event class or message broker does not.

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 cannot simply deserialize both rows into today’s wallet and hope for the best. 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. Keeping the rows forever while losing the ability to read them is just a more elaborate form of data loss.

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, but they are not an ordered per-model sequence. Clock precision, clock skew, and concurrent writes do not express which accepted change owns the next position. Sorting harder does not repair that missing contract.

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 buying one does not quietly give you the other.

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.

The failure controls turn “we can rebuild” from architecture-meeting optimism into something the application can actually prove.

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. It is barely the start of one.

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. It also means old mistakes stay on the payroll. 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 bill for choosing the stream as truth.

Current-state persistence is often the honest answer

Event sourcing earns that bill 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. That is the difference: publishing an event changes a collaboration; event sourcing changes how the model persists and how someone has to operate it.