A regulator asks a question that sounds simple: on the day you filed, what did your system believe was true — and when did it learn it? Answering it is routine work in regulatory compliance, and for a backend built on plain CRUD it is often impossible. The row that held the answer was updated in place months ago, the value it used to hold is gone, and the audit table bolted on beside it is a parallel story that may or may not match what actually happened. You are left reconstructing history from application logs and hope.
This is the problem CQRS — Command Query Responsibility Segregation — is unusually good at solving, and unusually easy to over-apply. This article is a decision guide for building a CQRS backend for regulatory compliance: when the pattern earns its considerable cost, how to keep it separate from the event-sourcing decision it is constantly confused with, and how to make the write log itself the audit trail instead of a report you assemble after the fact. You'll get a framework for deciding whether you need it, production .NET code for an append-only command log and the projections it feeds, a point-in-time reconstruction that answers the regulator's question directly, and the failure modes — GDPR erasure against an append-only store chief among them.
CQRS for Regulatory Compliance: Two Decisions, Not One
CQRS is one idea: the model you use to change state does not have to be the model you use to read it. Commands go through a write model that enforces invariants; queries are served from one or more read models shaped for how they're actually consulted. That's the whole pattern. Martin Fowler, who did as much as anyone to popularize it, is blunt that it should be used sparingly — "most systems fit a single representation" and CQRS "adds significant complexity". Microsoft's CQRS pattern guidance carries the same warning. So the interesting question is never "is CQRS good" — it's "does this particular regulated backend clear the bar."
The single most expensive mistake teams make here is conflating CQRS with event sourcing, and paying for both when they needed the properties of one. They are orthogonal. CQRS is about the read/write split. Event sourcing — Microsoft's Event Sourcing pattern — is about whether your source of truth is current state or an append-only log of what happened. You can do either without the other. The grid below is the mental model we use to place a system before writing any code:
| Source of truth ↓ / Read-write split → | One model (no CQRS) | Separate read models (CQRS) |
|---|---|---|
| Current state (CRUD) | Plain CRUD. The right default for most software, regulated or not. | CQRS-lite. Divergent query shapes, but no history — you still can't say what changed. |
| Append-only log (event sourcing) | History without query flexibility. Rare — you almost always want projections. | The regulated sweet spot. The log is the audit trail; read models answer regulators. |
Read the bottom-right cell carefully, because it explains why these two patterns keep arriving together in compliance work without either implying the other. The property a regulated backend actually needs is usually event sourcing's: the history of what happened is itself a regulated artifact you must retain and reproduce. CQRS follows for a mechanical reason — once your source of truth is a log, nobody can query a log efficiently, so you project it into read models. You adopt them together because of a real dependency, not because a diagram said to.
When the Pattern Earns Its Cost
Four conditions push a backend toward the bottom-right cell. Treat the last one as the decider: if point-in-time reconstruction is a hard compliance requirement, the rest of the pattern almost always pays for itself. If it isn't, be suspicious of the whole exercise.
- Read and write shapes genuinely diverge. You write discrete regulated facts (a vote cast, a correction filed, a position revalued) but must serve queries in entirely different shapes — regulator extracts, reconciliation reports, operational dashboards — that no single normalized model serves well.
- The write history is the regulated artifact, not just current state. The obligation is to prove what happened and in what order, not merely to show what is true now. If overwriting a value would destroy evidence, current-state storage is already the wrong substrate.
- Read and write evolve independently. A regulator introduces a new report next year; you need to project a new read model from history you already have, without migrating or endangering the write path that holds the source of truth.
- Point-in-time reconstruction is required. Someone can compel you to reproduce exactly what the system held, or reported, as of a past date. This is the capability CRUD cannot retrofit, and the one that justifies the append-only log.
If only the first condition holds, you want CQRS-lite — two models, one database — and no event sourcing. If none hold, you want plain CRUD with a well-built change-data-capture or audit interceptor, and you should walk away from this pattern entirely. The complexity is real: eventual consistency, event schema versioning, and replay infrastructure are not free, and a team that adopts them without a reconstruction requirement has bought a liability, not a compliance asset.
The Compliance-Specific Shape: The Log Is the Audit Trail
Here is the arrangement most regulated systems start with — current-state CRUD, with an audit table added beside it to satisfy the compliance ask. It looks responsible. It is quietly lossy:
// NAIVE: current-state CRUD with a bolted-on audit table.
// The state row is overwritten in place; the audit log is a SECOND,
// separate write that can drift, be skipped, or be edited later.
public async Task RecordVoteAsync(CastVote cmd, CancellationToken ct)
{
var ballot = await _db.Ballots
.SingleAsync(b => b.BallotId == cmd.BallotId, ct);
ballot.Choice = cmd.Choice; // the previous choice is now gone forever
ballot.CastAtUtc = DateTime.UtcNow;
_db.AuditLog.Add(new AuditEntry // a parallel story, not the state itself
{
Entity = "Ballot",
EntityId = cmd.BallotId,
Action = "VoteChanged",
AtUtc = DateTime.UtcNow
});
await _db.SaveChangesAsync(ct);
}
Two sources of truth now exist for the same fact: the Ballots row and the
AuditLog row. They are written by hand, on this code path, in this order — and every
other code path that touches a ballot has to remember to do the same. The state row records only
the latest value; the prior choice is unrecoverable. The audit row records the developer's
description of what changed, not the state transition itself, and nothing structurally
prevents the two from disagreeing. When an auditor asks you to prove they always agreed, you
can't — you can only show that they currently do.
Event sourcing inverts the relationship. The event is not a description of a change written alongside the state; the event is the change, and current state is derived from the ordered sequence of events. The audit trail stops being a feature you maintain and becomes the substrate the system runs on — which is exactly the property we argue for in architecting auditable backend systems. There is only one place the truth can live, so there is nothing for it to drift against.
An Append-Only Command Log in .NET
The write side stops speaking in row updates and starts speaking in immutable events. Each event carries two timestamps and an actor — the trio an auditor reaches for first: who did it, when it happened, and when the system recorded it.
// The write vocabulary: immutable events, never row mutations.
public sealed record VoteCast(string Choice);
public sealed record VoteRevoked(string Reason);
// Two clocks and an actor. Both timestamps matter for compliance (see below).
public sealed record EventMetadata(
string ActorId, // who caused it
DateTime OccurredAtUtc, // valid time: when it happened in the world
DateTime RecordedAtUtc, // transaction time: when our system committed it
string CorrelationId); // trace across services
public sealed record StoredEvent(Guid StreamId, object Payload, EventMetadata Meta);
public interface IEventLog
{
/// <summary>Appends an event to the aggregate's stream inside the caller's
/// transaction. Existing events are never modified or deleted.</summary>
Task AppendAsync(Guid streamId, object payload, EventMetadata meta, CancellationToken ct);
/// <summary>Reads a stream in append order, oldest first.</summary>
IAsyncEnumerable<StoredEvent> ReadStreamAsync(Guid streamId, CancellationToken ct);
}
The command handler appends an event and enforces invariants on the write side before the append. It never mutates state in place. The append and any bookkeeping share one transaction, so a crash leaves the log either advanced or untouched, never half-written:
public sealed class CastVoteHandler
{
private readonly IEventLog _log;
private readonly AppDbContext _db;
private readonly IClock _clock;
private readonly IInvariantGuard _guard;
private readonly ILogger<CastVoteHandler> _logger;
public CastVoteHandler(IEventLog log, AppDbContext db, IClock clock,
IInvariantGuard guard, ILogger<CastVoteHandler> logger)
{
_log = log; _db = db; _clock = clock; _guard = guard; _logger = logger;
}
public async Task HandleAsync(CastVote cmd, CancellationToken ct)
{
// Invariants are enforced HERE, synchronously, against the write model —
// never against an eventually-consistent read model. (See tradeoffs.)
await _guard.EnsureHolderHasNotVotedAsync(cmd.BallotId, cmd.ActorId, ct);
await using var tx = await _db.Database.BeginTransactionAsync(ct);
try
{
var meta = new EventMetadata(
ActorId: cmd.ActorId,
OccurredAtUtc: cmd.CastAtUtc, // when the holder actually voted
RecordedAtUtc: _clock.UtcNow, // when we committed it
CorrelationId: cmd.CorrelationId);
await _log.AppendAsync(cmd.BallotId, new VoteCast(cmd.Choice), meta, ct);
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
_logger.LogInformation(
"Vote appended for ballot {BallotId} by {ActorId} (corr {CorrelationId})",
cmd.BallotId, cmd.ActorId, cmd.CorrelationId);
}
catch (Exception ex)
{
await tx.RollbackAsync(ct);
_logger.LogError(ex, "Append failed for ballot {BallotId}", cmd.BallotId);
throw;
}
}
}
Reading is a separate concern. A projection folds the event stream into a read model shaped for how it's queried. The crucial property is that the read model is derived and disposable: you can throw it away and rebuild it by replaying the log, which is what makes new regulator reports cheap to add later.
// A projection folds events into a read model. It owns no truth of its own —
// delete it and rebuild it from the log any time the required shape changes.
public sealed class BallotProjector
{
public BallotReadModel Apply(BallotReadModel? s, StoredEvent e) => e.Payload switch
{
VoteCast v => (s ?? new BallotReadModel(e.StreamId)) with
{ Choice = v.Choice, LastChangedUtc = e.Meta.OccurredAtUtc },
VoteRevoked _ => (s ?? new BallotReadModel(e.StreamId)) with
{ Choice = null, LastChangedUtc = e.Meta.OccurredAtUtc },
_ => s ?? new BallotReadModel(e.StreamId)
};
}
Point-in-Time Reconstruction and the Two Clocks
Notice that EventMetadata carries two timestamps, not one. This is
bitemporality, and in regulated systems it is not a luxury. OccurredAtUtc is
valid time — when the fact became true in the world. RecordedAtUtc is
transaction time — when your system learned it and committed it. They differ constantly:
a vote cast at the meeting on Monday but transmitted to you on Wednesday occurred Monday and was
recorded Wednesday. A regulator's "as of" question is almost always about transaction time —
what had you recorded by the filing deadline — while a business "as of" question is
usually about valid time. Storing only one clock silently answers the wrong question.
With both clocks on every event, point-in-time reconstruction is a fold over the stream that stops at the as-of instant. This is the code that answers the auditor directly:
// "What did the system believe on the filing date?"
// Fold only the events we had RECORDED on or before the as-of instant.
public async Task<BallotReadModel?> ReconstructAsOfAsync(
Guid streamId, DateTime asOfRecordedUtc, CancellationToken ct)
{
BallotReadModel? state = null;
await foreach (var e in _log.ReadStreamAsync(streamId, ct))
{
if (e.Meta.RecordedAtUtc > asOfRecordedUtc)
break; // ignore anything we learned later
state = _projector.Apply(state, e);
}
return state;
}
No CRUD schema retrofits this. The reason event sourcing can answer it is that nothing was ever overwritten — the past states are not lost, they are simply the prefixes of the log. Martin Kleppmann develops this idea at length in Designing Data-Intensive Applications (O'Reilly, 2017): an append-only event log is the authoritative record, and every queryable view is a derived, rebuildable projection of it. That framing is what turns "prove what you knew and when" from a forensic exercise into a function call.
Tradeoffs and Failure Modes
This shape buys reconstructability and a non-drifting audit trail. It does not buy simplicity, and it introduces problems a CRUD system never has. The honest list:
- GDPR erasure versus append-only. "The right to be forgotten" and "never delete an event" are in direct tension. You resolve it with crypto-shredding: encrypt each subject's personal fields under a per-subject key and delete the key on an erasure request, which makes the data unrecoverable without rewriting a single event. The financial and ordering facts survive; only the personal payload dies. We work through the mechanics in building an immutable audit trail in .NET and Azure.
- Where invariants live. Regulatory rules — "a holder cannot vote twice," "the filing total must reconcile" — must be enforced synchronously on the write side, before the event is appended, against the authoritative model. Enforcing them by reading an eventually-consistent projection is a race that eventually files a violation. Read models may lag; invariant checks may not.
- Events are forever, so schemas need upcasting. A
VoteCastyou wrote two years ago must still deserialize after the shape has changed three times. You version events and upcast old ones to the current shape on read. There is no "just run a migration and forget the old form" — the old form is part of the permanent record. - Replay gets expensive; snapshot it. Rebuilding state by folding from event zero is fine for one ballot and ruinous for an aggregate with a million events. Periodic snapshots (a materialized fold at a known version) bound replay cost; reconstruction starts from the nearest snapshot before the as-of instant and folds forward from there.
- Don't event-source the whole system. The pattern earns its cost on the aggregates with a reconstruction requirement — ballots, filings, ledger entries — and taxes you everywhere else. Reference data, user preferences, and CRUD admin screens should stay CRUD. Scope event sourcing to the regulated core.
Regulated backends of this shape recur across domains — our proxy voting automation case study is one such system, where an auditable, reconstructable record of every voting instruction is a hard requirement rather than a nice-to-have. The pattern here is what makes that guarantee structural instead of aspirational. If you're still deciding whether the reconstruction requirement is real, that's the question to settle first — it's the one that determines whether any of this complexity is warranted.
Frequently Asked Questions
- Do we need event sourcing if we already have CQRS?
- No — they're independent decisions. CQRS separates the model you write through from the model you read through; event sourcing decides whether your source of truth is current state or an append-only log. You can run CQRS over an ordinary current-state database and store no events at all, and you can event-source an aggregate with no read/write separation. In regulated backends they tend to arrive together because once the write side is a log, you need projections to query it — but adopt event sourcing for the audit and replay properties, not because CQRS implies it.
- How do we handle GDPR delete requests with an append-only log?
- Separate the log's structure from the personal data inside it. Crypto-shredding is the standard technique: encrypt each subject's personal fields under a per-subject key, keep the ciphertext in the events, and hold the keys in a mutable keystore. An erasure request deletes the key, rendering that subject's data unrecoverable while the event stream's ordering, hashes, and regulatory facts stay intact. You never rewrite an event, so the append-only guarantee holds.
- Can read models be eventually consistent for regulators?
- For reporting and dashboards, yes — regulators care that a report is correct and reproducible as of a stated time, not that it reflects a write from three milliseconds ago. What must not be eventually consistent is invariant enforcement. Rules like "a holder cannot vote twice" or "the filing total must reconcile" have to be checked synchronously on the command side, against the authoritative write model, before the event is appended. Enforcing a regulatory invariant off an eventually-consistent read model is a race waiting to become a violation.
- What is the smallest viable CQRS implementation?
- Two models in one database, no new infrastructure. Write through a command model that enforces invariants, read through separately shaped queries or views, and keep both in the same SQL database and transaction boundary — "CQRS-lite." You get the divergent read/write shapes without brokers, separate stores, or eventual consistency. Add event sourcing only for the specific aggregates with a real audit or point-in-time replay requirement, and leave the rest as ordinary CRUD.
When to Bring in External Help
The failure mode we're called in to fix is rarely "CQRS done wrong." It's CQRS and event sourcing adopted everywhere, for a system that needed reconstruction on three aggregates and CRUD on the other forty — now carrying eventual consistency, event versioning, and replay infrastructure across the whole codebase, for a compliance benefit it only ever needed in a corner. The reverse is just as common and more dangerous: a regulated backend on plain CRUD that cannot answer the point-in-time question at all, discovered during an audit rather than before it.
A two-week Discovery Sprint is enough to tell you which of those you're looking at: whether your compliance requirements genuinely demand event sourcing, which aggregates need it, and where the pattern is costing you complexity for no regulatory return.