DEV Community

gentlyding
gentlyding

Posted on

Event Sourcing Gives You an Event Store, Not an Audit Log

A lot of teams reach for Event Sourcing and then treat the event store as their audit log. It's an easy mistake: both are append-only-ish streams of "what happened," so they look like the same thing. They aren't, and the gap shows up exactly when an auditor asks you to prove it.

This post is about where the two diverge, why an event store fails the audit test on its own, and the minimum you have to add so an event-sourced system can actually produce evidence.

They solve different problems

Event Sourcing is an application pattern. Its job is to let you rebuild domain state by replaying events, and to drive read models (projections) from those events. The primary reader of an event store is your own code.

An audit log is an evidence pattern. Its job is to let a third party — an auditor, a regulator, a forensic investigator, sometimes a hostile one — prove what happened, when, and that it hasn't been rewritten since. The primary reader of an audit log is someone who does not trust you.

Those two readers pull the design in opposite directions.

The gap, side by side

Event store (ES) Audit log
Purpose Rebuild state; drive projections Prove "who did what, when" to a third party
Reader Your own services An auditor who doesn't trust you
Third-party verifiable? Usually no Must be yes
Tamper-evident? Not by default Required
Time source App-generated wall-clock External, anchored
Survival of deletion Events get scavenged/compacted Records must persist for the retention period

If you've shipped Event Sourcing and someone asks "can you prove these events weren't edited?", the honest answer from a vanilla event store is usually "no."

Three things a raw event store is missing

1. Third-party verifiable integrity. An event store typically just stores events. Nothing binds event n to event n-1 in a way an outsider can check. "Our system says the history is intact" is not the same as "anyone can recompute a hash chain and see it matches." You need the chain, and you need to expose it — the auditor has to be able to replay it themselves.

2. Immutability that survives operations. Event stores are not actually append-forever. EventStoreDB scavenges deleted streams; Kafka drops segments past the retention window; projections are regenerated from scratch whenever you change a handler. All of that is correct for Event Sourcing and fatal for an audit trail. If "compacting old history" is a routine ops task, you don't have an audit log — you have a cache of recent state.

3. An external time anchor. Event timestamps in an event store are written by your application, from its own clock. Whoever controls that server can backdate an event or rewrite one and the timestamp will happily agree. A real audit log anchors each batch of records to a trusted timestamp authority (RFC 3161) so "when this was written" stops being something you can quietly edit.

Bridging the gap without throwing ES away

You don't have to abandon Event Sourcing. You add a verifiable audit trail alongside it, fed by the same events:

  1. When an event is appended, compute a content hash chain over the event — each link hashes its own canonical payload plus the previous link's hash.
  2. Periodically anchor the chain head to an RFC 3161 timestamp authority, so the chain has an external "this existed by then" proof.
  3. Expose a verification report — anyone can replay the chain, recompute hashes, and check the timestamps. Proof, not assertion.

The crucial part: the audit source is the immutable event stream + chain + anchors, not the projection. Read models are derived; they can lag, rebuild, or disagree, and none of that should be able to silently rewrite history.

A minimal append-side snippet (hash chain over a canonical event payload, not a database row):

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;

class AuditAppender {
    private final MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
    private byte[] headHash = new byte[0]; // genesis link is empty

    byte[] append(String canonicalEventJson) {
        byte[] content = canonicalEventJson.getBytes(StandardCharsets.UTF_8);
        sha256.reset();
        sha256.update(content);
        sha256.update(headHash);        // bind this link to the previous one
        byte[] link = sha256.digest();
        headHash = link;               // advance the chain head
        return link;
    }
}
Enter fullscreen mode Exit fullscreen mode

canonicalEventJson is the point: it must be a stable serialization of the event — fixed key order, no insignificant whitespace, numbers in a fixed format. Hash the raw object's toString() and two semantically identical events will produce two different hashes, which defeats the whole chain.

Two traps to avoid

  • Don't treat a projection as evidence. A read model is regenerated whenever a handler changes. "The audit says the balance was X" cannot come from a thing you rebuild on a whim. The evidence is the chained event stream; the projection is just a view.
  • Canonicalize before you hash. JSON key ordering, trailing whitespace, and 1.0 vs 1.00 all change a hash. Define one canonical form for your event payload and hash that, or your verification step will flag false tampering on perfectly honest events.

Bottom line

Event Sourcing gives you a great way to rebuild state and drive projections. It does not, by itself, give you an audit log — because an audit log's whole job is to be provable to someone who doesn't trust you, and a vanilla event store can't do that. Add a content hash chain, anchor it to external time, and expose verification. Then your event store and your audit trail coexist, and the audit trail actually holds up.

Top comments (0)