TypeScript로 CQRS + Event Sourcing 직접 구현하기 — EventStore, 이벤트 리플레이, Projection까지
When I first encountered CQRS and Event Sourcing, I'll be honest — my reaction was: "Store every event? Can't we just shove the current state into a DB?" But then a fintech project handed me a requirement: "Please trace why this account had this balance three months ago." That completely changed my mind. With a conventional CRUD approach, there would be no change history left, making restoration outright impossible.
This post walks through what problems CQRS and Event Sourcing solve, and — using an account management system as the running example — shows how to design an EventStore in TypeScript, restore state via event replay, and synchronize read models with Projections. Rather than a library tour, the goal is to follow the design decisions you actually run into when you build it yourself.
The target audience is backend and full-stack developers designing systems with complex domain logic or audit-trail requirements in TypeScript. Rather than any specific library like NestJS or KurrentDB, understanding the internals of the core patterns lets you apply them quickly regardless of which framework you use.
Core Concepts
Reads and Writes Are Fundamentally Different Problems
The starting point of CQRS (Command Query Responsibility Segregation) is simple. Handling reads (Queries) and writes (Commands) with the same model is an attempt to force two different problems into one solution.
On the write side, the key concerns are domain rule validation, transactional consistency, and concurrency control. On the read side, what matters is diverse aggregation and denormalization, plus fast response times. Doing both well with a single model is practically impossible. CQRS separates the two.
Event Sourcing goes one step further. Instead of storing current state, it stores every event that caused a state change as an immutable record. Rather than managing an account balance as a single balance: 50000 column, the sequence of AccountCreated, MoneyDeposited, and MoneyWithdrawn events becomes the Source of Truth. The current balance is a value computed by applying those events in order.
Core Type Definitions
We'll use an account management system as our example. Start by establishing the basic structure of an event. Event names must always use past-tense verbs — not CreateAccount, but AccountCreated. We're recording something that has already happened, so the past tense is semantically accurate.
interface DomainEvent<T = unknown> {
readonly eventId: string;
readonly eventType: string;
readonly aggregateId: string; // same as streamId in this system (e.g., 'account-uuid')
readonly aggregateVersion: number;
readonly occurredAt: Date;
readonly payload: T;
}
interface AccountCreatedPayload {
ownerId: string;
initialBalance: number;
}
interface MoneyDepositedPayload {
amount: number;
description: string;
}
interface MoneyWithdrawnPayload {
amount: number;
description: string;
}
// Discriminated union of account domain events
// payload type is narrowed automatically in accountReducer without casting
type AccountEvent =
| DomainEvent<AccountCreatedPayload> & { eventType: 'AccountCreated' }
| DomainEvent<MoneyDepositedPayload> & { eventType: 'MoneyDeposited' }
| DomainEvent<MoneyWithdrawnPayload> & { eventType: 'MoneyWithdrawn' };The reason AccountEvent is defined separately becomes clear in the very next step: so that when discriminating on eventType in a switch statement, the type of event.payload is narrowed automatically without casting.
Practical Implementation
Step 1 — Implementing the EventStore (PostgreSQL)
The EventStore manages events in per-stream units. It never modifies or deletes — it only appends. Let's implement it directly with PostgreSQL. This is a perfectly practical choice for small-scale systems or as a validation step before introducing KurrentDB.
CREATE TABLE events (
global_position BIGSERIAL,
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
stream_id VARCHAR(255) NOT NULL,
event_type VARCHAR(255) NOT NULL,
aggregate_version INTEGER NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
payload JSONB NOT NULL,
UNIQUE (stream_id, aggregate_version)
);
CREATE INDEX idx_events_stream ON events (stream_id, aggregate_version);
CREATE INDEX idx_events_global_position ON events (global_position);The UNIQUE (stream_id, aggregate_version) constraint is the heart of concurrency control. If two transactions simultaneously try to write the same version to the same stream, one of them must fail. global_position serves as a safe cursor for event subscriptions and Projection rebuilds.
Define the interface first.
interface EventStore {
append(streamId: string, events: DomainEvent[], expectedVersion: number): Promise<void>;
readStream(streamId: string, fromVersion?: number): Promise<DomainEvent[]>;
readAllBatch(
fromPosition: number,
limit: number
): Promise<{ events: DomainEvent[]; nextPosition: number }>;
subscribeToAll(handler: (event: DomainEvent) => Promise<void>): void;
}Now the implementation. Pay close attention to the optimistic locking behavior in append.
import { Pool } from 'pg';
import { randomUUID } from 'crypto';
class OptimisticConcurrencyError extends Error {
constructor(streamId: string, expectedVersion: number) {
super(`Expected version ${expectedVersion} for stream ${streamId} did not match`);
this.name = 'OptimisticConcurrencyError';
}
}
class PostgresEventStore implements EventStore {
constructor(private pool: Pool) {}
async append(
streamId: string,
events: DomainEvent[],
expectedVersion: number
): Promise<void> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
// Version check for fast failure — the actual concurrency guard is the UNIQUE constraint
// PostgreSQL does not allow aggregate functions and FOR UPDATE in the same query,
// so no row lock is applied to the SELECT MAX
const { rows } = await client.query(
`SELECT MAX(aggregate_version) AS current FROM events WHERE stream_id = $1`,
[streamId]
);
const current = rows[0].current ?? -1;
if (current !== expectedVersion) {
throw new OptimisticConcurrencyError(streamId, expectedVersion);
}
for (const event of events) {
await client.query(
`INSERT INTO events
(id, stream_id, event_type, aggregate_version, occurred_at, payload)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
event.eventId, // use the ID generated by the handler as-is
streamId,
event.eventType,
event.aggregateVersion,
event.occurredAt,
JSON.stringify(event.payload),
]
);
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
// UNIQUE violation = concurrent write conflict → convert to OptimisticConcurrencyError
if ((err as { code?: string }).code === '23505') {
throw new OptimisticConcurrencyError(streamId, expectedVersion);
}
throw err;
} finally {
client.release();
}
}
async readStream(streamId: string, fromVersion = 0): Promise<DomainEvent[]> {
const { rows } = await this.pool.query(
`SELECT * FROM events
WHERE stream_id = $1 AND aggregate_version >= $2
ORDER BY aggregate_version ASC`,
[streamId, fromVersion]
);
return rows.map(this.rowToEvent);
}
async readAllBatch(
fromPosition: number,
limit: number
): Promise<{ events: DomainEvent[]; nextPosition: number }> {
const { rows } = await this.pool.query(
`SELECT * FROM events
WHERE global_position > $1
ORDER BY global_position ASC
LIMIT $2`,
[fromPosition, limit]
);
const events = rows.map(this.rowToEvent);
const nextPosition =
rows.length > 0 ? rows[rows.length - 1].global_position : fromPosition;
return { events, nextPosition };
}
subscribeToAll(handler: (event: DomainEvent) => Promise<void>): void {
let lastPosition = 0;
setInterval(async () => {
try {
const { events, nextPosition } = await this.readAllBatch(lastPosition, 100);
for (const event of events) {
await handler(event);
}
lastPosition = nextPosition;
} catch (err) {
console.error('Error processing event subscription:', err);
}
}, 100);
}
private rowToEvent(row: Record<string, unknown>): DomainEvent {
return {
eventId: row.id as string,
eventType: row.event_type as string,
aggregateId: row.stream_id as string,
aggregateVersion: row.aggregate_version as number,
occurredAt: row.occurred_at as Date,
payload: row.payload,
};
}
}The polling in subscribeToAll is far more reliable than timestamp-based polling thanks to the global_position cursor. However, because BIGSERIAL values are assigned at INSERT time, the possibility of intermittent gaps — where a slowly-committed transaction is left behind the cursor — is not completely eliminated. In production, it is safer to let an event bus such as PostgreSQL LISTEN/NOTIFY or a KurrentDB $all subscription handle this role.
Step 2 — Restoring Aggregates via Event Replay
In Event Sourcing, the current state of an aggregate is computed by folding the event stream with reduce.
interface AccountState {
id: string;
ownerId: string;
balance: number;
version: number;
isClosed: boolean;
}
// Taking the AccountEvent union as a parameter narrows payload automatically in each switch branch
const accountReducer = (
state: AccountState | null,
event: AccountEvent
): AccountState => {
switch (event.eventType) {
case 'AccountCreated':
return {
id: event.aggregateId,
ownerId: event.payload.ownerId, // inferred as AccountCreatedPayload automatically
balance: event.payload.initialBalance,
version: event.aggregateVersion,
isClosed: false,
};
case 'MoneyDeposited':
if (!state) throw new Error('Invalid event order');
return {
...state,
balance: state.balance + event.payload.amount, // inferred as MoneyDepositedPayload automatically
version: event.aggregateVersion,
};
case 'MoneyWithdrawn':
if (!state) throw new Error('Invalid event order');
return {
...state,
balance: state.balance - event.payload.amount,
version: event.aggregateVersion,
};
}
};
async function rehydrateAccount(
eventStore: EventStore,
accountId: string
): Promise<{ state: AccountState; version: number }> {
const streamId = `account-${accountId}`;
const events = await eventStore.readStream(streamId);
if (events.length === 0) {
throw new Error(`Account ${accountId} not found`);
}
// Boundary cast of DomainEvent[] returned by EventStore to AccountEvent[]
const state = (events as AccountEvent[]).reduce(
(acc, event) => accountReducer(acc, event),
null as AccountState | null
) as AccountState;
return { state, version: state.version };
}The key point is that accountReducer is a pure function. Given the same sequence of events, it always produces the same state. No side effects means it is easy to test and any point-in-time state can be reproduced.
Won't reading from the beginning every time get slow as events accumulate in the tens of thousands? Yes. That's when you use snapshots — storing the state at a particular version in a separate table and then reading only the events after that point.
interface Snapshot {
streamId: string;
state: AccountState;
version: number;
}
interface SnapshotStore {
save(snapshot: Snapshot): Promise<void>;
getLatest(streamId: string): Promise<Snapshot | null>;
}
async function rehydrateWithSnapshot(
eventStore: EventStore,
snapshotStore: SnapshotStore,
accountId: string
): Promise<{ state: AccountState; version: number }> {
const streamId = `account-${accountId}`;
const snapshot = await snapshotStore.getLatest(streamId);
const fromVersion = snapshot ? snapshot.version + 1 : 0;
const events = await eventStore.readStream(streamId, fromVersion);
const initialState = snapshot ? snapshot.state : null;
const state = (events as AccountEvent[]).reduce(
(acc, event) => accountReducer(acc, event),
initialState
) as AccountState;
return { state, version: state.version };
}The threshold for taking a snapshot varies by team, but taking one every 100 events is a common strategy.
Step 3 — Command Handler and Domain Logic
A Command expresses intent; the Handler validates domain rules, generates events, and saves them to the EventStore.
When creating a new stream for the first time, pass -1 as expectedVersion. This means "there are no events in this stream yet," and inside append it is compared against MAX(aggregate_version) NULL → -1 — an insert is only allowed when they match. It is worth documenting this convention explicitly within the team.
interface CreateAccountCommand {
type: 'CreateAccount';
accountId: string;
ownerId: string;
initialBalance: number;
}
interface WithdrawMoneyCommand {
type: 'WithdrawMoney';
accountId: string;
amount: number;
description: string;
}
class CreateAccountHandler {
constructor(private eventStore: EventStore) {}
async handle(command: CreateAccountCommand): Promise<void> {
const { accountId, ownerId, initialBalance } = command;
const streamId = `account-${accountId}`;
const event: DomainEvent<AccountCreatedPayload> = {
eventId: randomUUID(),
eventType: 'AccountCreated',
aggregateId: streamId,
aggregateVersion: 0, // version of the first event is 0
occurredAt: new Date(),
payload: { ownerId, initialBalance },
};
// New stream: expectedVersion = -1 (no events yet)
await this.eventStore.append(streamId, [event], -1);
}
}
class WithdrawMoneyHandler {
constructor(private eventStore: EventStore) {}
async handle(command: WithdrawMoneyCommand): Promise<void> {
const { accountId, amount, description } = command;
// 1. Restore current state via event replay
const { state, version } = await rehydrateAccount(this.eventStore, accountId);
// 2. Validate domain rules
if (state.isClosed) throw new Error('Cannot withdraw from a closed account');
if (state.balance < amount) throw new Error(`Insufficient balance: current balance is ${state.balance}`);
if (amount <= 0) throw new Error('Withdrawal amount must be greater than 0');
const streamId = `account-${accountId}`;
const event: DomainEvent<MoneyWithdrawnPayload> = {
eventId: randomUUID(),
eventType: 'MoneyWithdrawn',
aggregateId: streamId,
aggregateVersion: version + 1,
occurredAt: new Date(),
payload: { amount, description },
};
// 3. Save to EventStore — expectedVersion = current version
await this.eventStore.append(streamId, [event], version);
}
}What happens if two Handlers simultaneously try to withdraw from the same account? The one that succeeds in saving first increments the version, and the one that arrives later receives an OptimisticConcurrencyError. Concurrency is controlled without pessimistic locks at the DB level.
Let's visualize the command processing flow.
There is a brief inconsistency window between a successful command and the read model being updated. Eventual Consistency is an intrinsic characteristic of this pattern. If immediate consistency is a business requirement, the design warrants reconsideration.
Step 4 — Synchronizing Read Models with Projections
A Projection subscribes to events from the EventStore and builds and maintains views optimized for reading. The read model schema is as follows.
CREATE TABLE account_read_models (
account_id VARCHAR(255) PRIMARY KEY,
owner_id VARCHAR(255) NOT NULL,
balance BIGINT NOT NULL DEFAULT 0,
last_applied_version INTEGER NOT NULL DEFAULT -1,
last_updated_at TIMESTAMPTZ NOT NULL
);last_applied_version is the key to idempotency. Even if the same event is delivered twice, the WHERE last_applied_version < $version condition will not match, and the UPDATE quietly becomes a no-op. Without this guard, relative updates of the form balance = balance + $amount will be applied twice.
class AccountProjection {
constructor(private db: Pool) {}
async handle(event: DomainEvent): Promise<void> {
switch (event.eventType) {
case 'AccountCreated': return this.onAccountCreated(event);
case 'MoneyDeposited': return this.onMoneyDeposited(event);
case 'MoneyWithdrawn': return this.onMoneyWithdrawn(event);
}
}
private async onAccountCreated(event: DomainEvent): Promise<void> {
const payload = event.payload as AccountCreatedPayload;
// ON CONFLICT DO NOTHING: no duplicate insert even if the same event is processed twice
await this.db.query(
`INSERT INTO account_read_models
(account_id, owner_id, balance, last_applied_version, last_updated_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (account_id) DO NOTHING`,
[
event.aggregateId,
payload.ownerId,
payload.initialBalance,
event.aggregateVersion,
event.occurredAt,
]
);
}
private async onMoneyDeposited(event: DomainEvent): Promise<void> {
const payload = event.payload as MoneyDepositedPayload;
// last_applied_version < $2: prevents duplicate processing of the same event
await this.db.query(
`UPDATE account_read_models
SET balance = balance + $1,
last_applied_version = $2,
last_updated_at = $3
WHERE account_id = $4
AND last_applied_version < $2`,
[payload.amount, event.aggregateVersion, event.occurredAt, event.aggregateId]
);
}
private async onMoneyWithdrawn(event: DomainEvent): Promise<void> {
const payload = event.payload as MoneyWithdrawnPayload;
await this.db.query(
`UPDATE account_read_models
SET balance = balance - $1,
last_applied_version = $2,
last_updated_at = $3
WHERE account_id = $4
AND last_applied_version < $2`,
[payload.amount, event.aggregateVersion, event.occurredAt, event.aggregateId]
);
}
}Finally, the bootstrap code that connects the EventStore to the Projection. Without this wiring, the two components simply exist independently.
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const eventStore = new PostgresEventStore(pool);
const projection = new AccountProjection(pool);
// Subscribe to EventStore → connect to Projection
eventStore.subscribeToAll(async (event) => {
await projection.handle(event);
});Step 5 — Rebuilding Projections
When you fix a bug in Projection logic or need a new read model, you can reprocess all past events from scratch. This is one of the powerful advantages of Event Sourcing. The readAllBatch added to the EventStore interface is used as-is.
class ProjectionRebuilder {
constructor(private eventStore: EventStore, private db: Pool) {}
async rebuild(projection: AccountProjection): Promise<void> {
console.log('Starting read model rebuild...');
await this.db.query('TRUNCATE TABLE account_read_models');
let position = 0;
let processed = 0;
const BATCH_SIZE = 1000;
while (true) {
const { events, nextPosition } = await this.eventStore.readAllBatch(
position,
BATCH_SIZE
);
if (events.length === 0) break;
for (const event of events) {
await projection.handle(event);
processed++;
}
position = nextPosition;
console.log(`Processed ${processed} events`);
}
console.log(`Rebuild complete: processed ${processed} events total`);
}
}The TRUNCATE-then-rebuild approach is simple, but the read model is empty until the rebuild finishes. If zero-downtime is required, consider a blue-green approach: rebuild under a new table name and then swap the view.
Pros and Cons
When to Use It and When to Avoid It
| Category | Details |
|---|---|
| Suitable domains | Financial transactions, medical records, legal document management, e-commerce order processing |
| Why suitable | Mandatory audit trail, time-travel debugging, significant divergence between read and write models |
| Unsuitable domains | Simple configuration management, master data that is purely CRUD |
| Why unsuitable | Benefits do not justify the complexity; team learning cost exceeds business value |
Advantages and Disadvantages
| Advantages | Disadvantages |
|---|---|
| Complete audit trail built into the event log itself | High learning cost for the whole team |
| State can be reproduced at any point in time (time-travel debugging) | Inconsistency window due to eventual consistency |
| Read and write infrastructure can be scaled independently | Event schemas cannot be modified; an upcasting strategy is required |
| New Projections can be created instantly from historical data | Rebuild cost increases as event count grows |
| Natural event-driven integration between microservices | Idempotency handling must be implemented manually in every Projection |
Common Mistakes in Practice
Designing stream IDs too simply. The account-${id} format is recommended. Using just ${id} can collide with other aggregate types.
Attempting to modify event payloads after the fact. Events are immutable. If you need to change a schema, add a new versioned event such as MoneyWithdrawnV2 and handle both versions on the read side.
Omitting the idempotency guard on relative updates in Projection handlers. An UPDATE of the form balance = balance + $amount will apply the balance twice if the same event is processed twice. Always include the AND last_applied_version < $version condition.
Passing expectedVersion as 0 when creating a new stream. The current version of a stream with no events is -1. It is worth documenting explicitly within the team that expectedVersion: -1 must be passed when creating an account.
Applying the pattern indiscriminately to every domain. Adding Event Sourcing to simple configuration data or master code management is over-engineering. This pattern is justified in domains where the read and write models are genuinely different and the event history itself carries business value.
Closing Thoughts
Here is a summary of everything covered:
- The EventStore's core is append-only + optimistic locking. It can be implemented without
FOR UPDATE— just theUNIQUE (stream_id, aggregate_version)constraint and catching PostgreSQL error code23505. - Event replay is a pure-function pattern that computes state with
reduce. The same event sequence always produces the same state. - Projection idempotency is guaranteed by a version guard. A single
AND last_applied_version < $versioncondition prevents duplicate application of relative updates. - Eventual consistency and event schema immutability are trade-offs. Verify that your business requirements can accommodate them first.
If you want to get started now, try this sequence:
-
Build a simple EventStore directly with a PostgreSQL
eventstable. Pick a small domain, define 3–5 event types, and implementappend/readStream. You can see firsthand how optimistic locking works. -
Implement event replay with a reducer function. Writing out the flow of computing state as the
reduceresult of an event list — rather than a DB column — makes the core concept of Event Sourcing click. -
Attach one Projection and run a rebuild yourself. Once you watch the read model being reconstructed by truncating and reprocessing all events, you will naturally understand why this pattern can build new Views from historical data.
The reason I recommend building it yourself before reaching for a library is that the difference between knowing and not knowing what happens behind the abstractions of @nestjs/cqrs or KurrentDB is significant. That difference shows up when you debug an issue or resolve a performance problem.
References
- CQRS and Event Sourcing in TypeScript: A Production Walkthrough — Atomic Object
- Complete Event Sourcing Guide: Node.js, TypeScript, and EventStore Implementation Tutorial
- Building a NestJS Web Application with EventStoreDB — Kurrent Official Blog
- Building a CQRS and Event Sourcing Application for Hotel Management with NestJS and EventStoreDB — Medium
- Navigating CQRS and Event Sourcing: My Journey with NestJS and EventStoreDB — Digital Frontiers Blog
- castore Official Docs — Making Event Sourcing easy
- Event Sourcing Pattern — Microsoft Azure Architecture Center
- Beyond Aggregates: Lean, Functional Event Sourcing
- Event Sourcing in Backend Systems: Auditability, Replays, and Operational Trade-offs
- How to Build Event Sourcing Systems in Node.js — OneUptime Blog