A customer clicks Transfer twice because the first response is slow. At the same moment, a scheduled payment tries to spend from the same account. The real architectural question is not how quickly a controller can update a balance column. It is how the system proves that money was neither created nor spent twice, explains every accepted decision, and still answers a dashboard query without replaying years of history.
CQRS and event sourcing are often introduced as a pair that solves this kind of problem. They can, but they solve different problems and impose different costs. Using them well starts with separating the two ideas.
Two patterns, two decisions
Command Query Responsibility Segregation (CQRS) gives writes and reads different models. The command side accepts intentions such as OpenAccount or TransferFunds, protects business invariants, and reports success or failure. The query side returns data shaped for a screen, report, search index, or API consumer. That separation can be entirely in-process and can still use one transactional database.
Event sourcing stores an aggregate’s accepted domain events as its source of truth. Instead of overwriting an account row, the system appends facts such as AccountOpened, FundsDeposited, and FundsTransferred. Current state is derived by folding those events in order. The event log is authoritative; a balance table is a projection that can be rebuilt.
You may use CQRS without event sourcing: normalized write tables plus denormalized read tables is a common design. You may event-source an aggregate while querying its reconstructed state directly, though that usually becomes inefficient. Neither pattern requires microservices, a message broker, or eventual consistency between every component.
| Approach | Write model | Read model | Source of truth | Typical fit |
|---|---|---|---|---|
| CRUD | Rows represent current state | Usually the same rows | Current database state | Simple workflows and administration |
| CQRS only | Domain-oriented write schema | Purpose-built query schema | Write database | Complex writes or very different read shapes |
| Event sourcing only | Event stream folded into state | Rehydrated aggregate | Immutable event stream | Audit-heavy domain with modest query needs |
| CQRS + event sourcing | Aggregate emits events | Projections consume events | Immutable event stream | Rich domain, history, and multiple read views |
Tip
Adopt each pattern for its own reason. “We need an audit trail” may justify event sourcing for one aggregate; “our dashboard queries are awkward” may justify CQRS without it.
The aggregate is the consistency boundary
An event-sourced aggregate does not expose setters. It receives a command, checks invariants against state reconstructed from committed events, and returns new events. Applying those events changes state without performing I/O. This split between decide and evolve makes the rules explicit and replay deterministic.
Consider an account where the currency never changes, amounts must be positive, and withdrawals may not exceed available funds. Money uses integer minor units to avoid floating-point errors.
export type AccountEvent =
| {
type: 'AccountOpened';
accountId: string;
currency: string;
occurredAt: string;
}
| {
type: 'FundsDeposited';
accountId: string;
amountMinor: number;
reference: string;
occurredAt: string;
}
| {
type: 'FundsTransferred';
accountId: string;
destinationId: string;
amountMinor: number;
transferId: string;
occurredAt: string;
};
export type AccountState =
| { status: 'empty' }
| {
status: 'open';
accountId: string;
currency: string;
balanceMinor: number;
completedTransferIds: ReadonlySet<string>;
};
export const initialAccount: AccountState = { status: 'empty' };
export function evolve(
state: AccountState,
event: AccountEvent,
): AccountState {
switch (event.type) {
case 'AccountOpened':
return {
status: 'open',
accountId: event.accountId,
currency: event.currency,
balanceMinor: 0,
completedTransferIds: new Set(),
};
case 'FundsDeposited':
if (state.status !== 'open') throw new Error('Account is not open');
return { ...state, balanceMinor: state.balanceMinor + event.amountMinor };
case 'FundsTransferred': {
if (state.status !== 'open') throw new Error('Account is not open');
const completedTransferIds = new Set(state.completedTransferIds);
completedTransferIds.add(event.transferId);
return {
...state,
balanceMinor: state.balanceMinor - event.amountMinor,
completedTransferIds,
};
}
}
}
export function rehydrate(events: readonly AccountEvent[]): AccountState {
return events.reduce(evolve, initialAccount);
}
export function decideTransfer(
state: AccountState,
command: {
destinationId: string;
amountMinor: number;
transferId: string;
now: string;
},
): AccountEvent[] {
if (state.status !== 'open') throw new Error('Account is not open');
if (!Number.isSafeInteger(command.amountMinor) || command.amountMinor <= 0) {
throw new Error('Transfer amount must be a positive integer');
}
if (state.completedTransferIds.has(command.transferId)) return [];
if (state.balanceMinor < command.amountMinor) {
throw new Error('Insufficient funds');
}
return [{
type: 'FundsTransferred',
accountId: state.accountId,
destinationId: command.destinationId,
amountMinor: command.amountMinor,
transferId: command.transferId,
occurredAt: command.now,
}];
}
The transfer ID makes a retried command idempotent within the aggregate. More importantly, the balance invariant is checked inside one consistency boundary. A transfer spanning two accounts needs an explicit process: debit one aggregate, then credit the other through an idempotent handler, with compensation and reconciliation for partial failure. Pretending two independent streams form one atomic aggregate only hides the distributed transaction.
Command handling and optimistic concurrency
Rehydrating, deciding, and appending is safe only if nobody changes the stream between the read and write. An event store therefore appends with an expected version. The append succeeds when the stream still has that version; otherwise it rejects the write. The handler can reload and retry when the command remains meaningful, or return a conflict to the caller.
import {
decideTransfer,
rehydrate,
type AccountEvent,
} from './account.js';
export interface StoredEvent<T> {
streamId: string;
streamVersion: number;
globalPosition: number;
event: T;
}
export interface EventStore {
readStream(streamId: string): Promise<readonly StoredEvent<AccountEvent>[]>;
append(
streamId: string,
expectedVersion: number,
events: readonly AccountEvent[],
): Promise<void>;
}
export class ConcurrencyError extends Error {}
export async function transferFunds(
store: EventStore,
command: {
accountId: string;
destinationId: string;
amountMinor: number;
transferId: string;
now: string;
},
): Promise<void> {
const streamId = `account-${command.accountId}`;
const stored = await store.readStream(streamId);
const state = rehydrate(stored.map(({ event }) => event));
const newEvents = decideTransfer(state, command);
if (newEvents.length === 0) return;
await store.append(streamId, stored.length, newEvents);
}
In production, the event envelope also carries an event ID, schema version, correlation and causation IDs, actor, tenant, and trace metadata. The store must enforce a unique (streamId, streamVersion) key in the same transaction as the append. Publishing events to a broker should use a transactional outbox or the event store’s durable subscription, not a fragile “commit, then publish” sequence.
Conflicts are information, not exceptional infrastructure noise. A conflict may mean the available balance changed, so blindly retrying an earlier decision is wrong. Rehydrate and run decideTransfer again; the invariant then evaluates against current facts.
Projections turn history into useful reads
Command-side streams are deliberately narrow. A dashboard wants the account name, formatted balance, last activity, and pending transfer count in one lookup. A projection consumes the ordered event log and maintains that read model.
import type { StoredEvent } from './command-handler.js';
import type { AccountEvent } from './account.js';
interface AccountSummary {
accountId: string;
currency: string;
balanceMinor: number;
lastActivityAt: string;
}
export interface ProjectionStore {
transaction<T>(work: (tx: ProjectionTransaction) => Promise<T>): Promise<T>;
}
export interface ProjectionTransaction {
getAccount(accountId: string): Promise<AccountSummary | undefined>;
putAccount(account: AccountSummary): Promise<void>;
hasProcessed(eventId: string): Promise<boolean>;
markProcessed(eventId: string, position: number): Promise<void>;
}
export async function projectAccountSummary(
db: ProjectionStore,
eventId: string,
stored: StoredEvent<AccountEvent>,
): Promise<void> {
await db.transaction(async (tx) => {
if (await tx.hasProcessed(eventId)) return;
const event = stored.event;
if (event.type === 'AccountOpened') {
await tx.putAccount({
accountId: event.accountId,
currency: event.currency,
balanceMinor: 0,
lastActivityAt: event.occurredAt,
});
} else {
const current = await tx.getAccount(event.accountId);
if (!current) throw new Error(`Missing account ${event.accountId}`);
const delta = event.type === 'FundsDeposited'
? event.amountMinor
: -event.amountMinor;
await tx.putAccount({
...current,
balanceMinor: current.balanceMinor + delta,
lastActivityAt: event.occurredAt,
});
}
await tx.markProcessed(eventId, stored.globalPosition);
});
}
Projection delivery is normally at least once, so the checkpoint and read-model update must commit atomically. Deduplication by event ID makes retries harmless. Consumers must also preserve ordering where it matters, stop on poison events, expose lag, and support a dead-letter or quarantine workflow. The query API should communicate staleness when users expect read-your-own-writes behavior; it can return the committed global position and wait until a projection reaches it, or render the accepted command result optimistically.
Replay and schema evolution
Replay is event sourcing’s strongest capability and one of its sharpest operational edges. A new projection can process the log from position zero, allowing a team to add a report without migrating the write model. The same mechanism repairs a corrupted read database. For large histories, replay needs throttling, partitioning, progress metrics, and separate infrastructure so rebuilding does not starve live consumers.
Aggregate snapshots are a cache, not a source of truth. Store state plus the stream version every few hundred events, load the latest compatible snapshot, then fold subsequent events. If a snapshot cannot be read after a deployment, discard it and replay the stream.
Events are permanent facts, but their TypeScript representation is not permanent. Never edit an old payload in place. Prefer backward-compatible additions, version event names or envelopes for breaking changes, and upcast old payloads to the current in-memory shape when reading. Keep upcasters pure and chained, and test them against fixtures from production history. Semantic changes are harder than syntactic ones: if FundsTransferred once meant “requested” and now means “settled,” create a new event rather than redefining the old fact.
Deletion requirements deserve design work before adoption. Encryption with per-subject keys can make selected personal fields unreadable by destroying a key, while tokenization can keep sensitive data outside the event log. Neither technique excuses collecting unnecessary data in immutable events.
Testing and failure modes
The pure aggregate model supports concise tests: given prior events, when a command arrives, expect emitted events or an error. Test both happy paths and invariants: insufficient funds, zero amounts, duplicate transfer IDs, unopened accounts, and boundary values. Property-based tests are especially effective for asserting that arbitrary accepted command sequences never produce a negative balance.
Then test the infrastructure seams separately:
- two writers using the same expected version produce exactly one successful append;
- every projection event is idempotent and advances its checkpoint atomically;
- replaying the complete log into an empty database matches the live read model;
- every historical event fixture upcasts and folds successfully;
- handlers recover from crashes before and after append, publish, and checkpoint commits.
Common failures follow from ignoring those seams. A team may call mutable audit rows “events,” publish before a transaction commits, let projectors perform non-idempotent side effects, or couple every service to one giant global event schema. Long streams can make commands slow; hot aggregates can conflict continuously; projection lag can confuse users; and an unbounded number of read models becomes a migration and monitoring burden.
Operationally, you own event retention, backups, restore drills, subscription offsets, replay tooling, schema compatibility, observability, and on-call procedures. Debugging improves because history is available, but incident response becomes harder if engineers cannot inspect streams, correlate commands, or measure projection lag.
Warning
An immutable log is not automatically truthful. Bugs, duplicated commands, bad timestamps, and incorrect authorization can all produce durable wrong facts. Corrections should be explicit compensating events, supported by audit and repair tools.
Takeaways
Choose CQRS when the write model’s invariants and the read model’s shapes genuinely pull in different directions. Choose event sourcing when preserving domain decisions as an ordered history creates durable value: temporal audit, reconstruction, new projections, or business processes that depend on what happened, not merely current state.
Prefer conventional transactions when the domain is mostly CRUD, history has little business value, strong immediate reads dominate, or the team cannot fund the operational tooling. A useful adoption path is one bounded context and one high-value aggregate, with an explicit event contract and a disposable projection. Measure conflict rate, stream length, projection lag, replay duration, and operator effort before expanding.
The decisive question is not whether CQRS and event sourcing are sophisticated. It is whether the history and model separation repay their permanent cost. When they do, they provide unusually clear boundaries: commands express intent, aggregates defend invariants, events record accepted facts, and projections make those facts useful. When they do not, a well-designed transactional model is the more mature choice.