Building an Order and Payment State Machine with TypeScript and KurrentDB Using Event Sourcing and Point-in-Time Reconstruction
"What was the exact payment status of this order yesterday at 2:32 PM?" When your client's audit team asks a question like this, how do you answer? You might think the updated_at column is enough — but when you actually open the DB, only the current state remains and all prior traces are gone. To answer accurately in this situation, you need to store the change itself, not just the state.
CQRS (Command Query Responsibility Segregation) and Event Sourcing used together solve this problem structurally. Instead of overwriting state, you accumulate events like OrderCreated → PaymentProcessed → OrderShipped as immutable records in order, and you can rewind to any specific point in time. In 2025, when EventStoreDB rebranded to KurrentDB, a gRPC API-based client ecosystem took root, and production patterns on the Node.js side matured considerably.
In this article, we implement an order and payment system using the NestJS + TypeScript + KurrentDB stack, walking through event stream design and storage, building read models with Projections, and the Temporal Query pattern for reconstructing state at an arbitrary point in time — in that order. Complexity and common mistakes are covered honestly as well.
Core Concepts
CQRS: Reads and Writes Are Different Problems
The essence of CQRS is fully separating the Command (write) path that modifies data from the Query (read) path that retrieves it. The Command side focuses on domain logic and validation, while the Query side provides Read Models (Projections) optimized for querying. The two paths can scale independently and can use different data stores.
Between reads and writes there is Eventual Consistency. This means the read model may not be updated immediately right after a write completes. This is a drawback, but it is also the flip side of the strength that allows the read model to be regenerated from the event log at any time.
Event Sourcing: Storing Change, Not State
Ordinary CRUD systems store the current state of an entity. Update the status column in the orders table to PAID and all prior traces disappear. Event Sourcing is the opposite. Instead of the current state, all events that caused state changes are stored as immutable records in order. The current state is calculated by replaying events from the beginning whenever needed.
-- Ordinary CRUD: only the current state remains
UPDATE orders SET status = 'PAID', updated_at = NOW() WHERE id = '123';[
{ "type": "OrderCreated", "orderId": "123", "createdAt": "2026-07-15T09:00:00Z" },
{ "type": "PaymentProcessed", "orderId": "123", "amount": 59000, "processedAt": "2026-07-15T09:01:23Z" },
{ "type": "OrderShipped", "orderId": "123", "trackingNumber": "KR123456", "shippedAt": "2026-07-15T14:30:00Z" }
]Core Components
| Component | Role | Example |
|---|---|---|
| Aggregate | Consistency boundary that handles commands and emits events | Order, Payment |
| Event Stream | Sequence of events for a specific aggregate | order-{orderId} |
| Projection | Subscribes to events to create and update read models | Order list view, payment status dashboard |
| Snapshot | Optimization that stores state at a specific point to reduce replay cost | Saved every N events |
Reconstructing State at an Arbitrary Point: Temporal Query
This is a core part of the practical value of Event Sourcing. KurrentDB records a server-side event.created timestamp when storing events. By replaying only events recorded before a specific point in time based on this timestamp, you can restore the exact state at that moment.
There is one important design principle. Time fields in business payloads (createdAt, processedAt, etc.) can be arbitrarily manipulated by clients and cannot be trusted for audit purposes. The correct design is to use event.created — the server-recorded timestamp — as the reference clock for Temporal Queries.
This lets you reproduce at the code level exactly what happened at that moment in time.
Practical Application
Environment Setup: Running KurrentDB Locally
KurrentDB can be started quickly with Docker. The management UI is available at http://localhost:2113.
docker run -d --name kurrentdb \
-p 2113:2113 \
-p 2114:2114 \
--env KURRENTDB_INSECURE=true \
kurrent/kurrentdb:latestNote: After the KurrentDB rebrand, the legacy TCP client protocol was removed. In Node.js, use the official gRPC-based client package. Exact image tags and environment variable names may differ between versions, so refer to the KurrentDB official documentation as the authoritative source.
npm install @kurrent/kurrentdb-client @nestjs/cqrs zod drizzle-ormDefining Event Types and Metadata
It is best to define EventMetadata together with domain event types from the start. Adding it later requires modifying every event handler.
// src/order/events/order.events.ts
import { z } from 'zod';
export interface EventMetadata {
correlationId: string; // ID that groups the same business flow together
causationId: string; // ID of the event that caused this event
userId?: string;
}
export const OrderCreatedSchema = z.object({
orderId: z.string().uuid(),
customerId: z.string().uuid(),
items: z.array(z.object({
productId: z.string(),
quantity: z.number().int().positive(),
price: z.number().positive(),
})),
totalAmount: z.number().positive(),
createdAt: z.string().datetime(),
});
export const PaymentProcessedSchema = z.object({
orderId: z.string().uuid(),
paymentId: z.string().uuid(),
amount: z.number().positive(),
method: z.enum(['CARD', 'BANK_TRANSFER', 'VIRTUAL_ACCOUNT']),
processedAt: z.string().datetime(),
});
export const OrderShippedSchema = z.object({
orderId: z.string().uuid(),
trackingNumber: z.string(),
carrier: z.string(),
shippedAt: z.string().datetime(),
});
export type OrderCreated = z.infer<typeof OrderCreatedSchema>;
export type PaymentProcessed = z.infer<typeof PaymentProcessedSchema>;
export type OrderShipped = z.infer<typeof OrderShippedSchema>;
export type OrderEvent =
| { type: 'OrderCreated'; data: OrderCreated; metadata: EventMetadata }
| { type: 'PaymentProcessed'; data: PaymentProcessed; metadata: EventMetadata }
| { type: 'OrderShipped'; data: OrderShipped; metadata: EventMetadata };Implementing the Aggregate
The Aggregate is responsible for receiving commands, validating them, emitting events, and reconstituting its own state by replaying events. Keeping the apply method pure is the key. fromSnapshot and replayEvents for snapshot support are defined alongside it.
// src/order/aggregates/order.aggregate.ts
import { OrderEvent, EventMetadata } from '../events/order.events';
export type OrderStatus = 'CREATED' | 'PAYMENT_PROCESSED' | 'SHIPPED' | 'CANCELLED';
export interface OrderItem {
productId: string;
quantity: number;
price: number;
}
export interface OrderState {
orderId: string;
customerId: string;
status: OrderStatus;
totalAmount: number;
items: OrderItem[];
paymentId?: string;
trackingNumber?: string;
version: number;
}
export class OrderAggregate {
private state: Partial<OrderState> = { version: 0 };
private uncommittedEvents: OrderEvent[] = [];
static reconstitute(events: OrderEvent[]): OrderAggregate {
const aggregate = new OrderAggregate();
for (const event of events) aggregate.apply(event);
return aggregate;
}
static fromSnapshot(state: OrderState): OrderAggregate {
const aggregate = new OrderAggregate();
aggregate.state = { ...state };
return aggregate;
}
replayEvents(events: OrderEvent[]): void {
for (const event of events) this.apply(event);
}
createOrder(
orderId: string,
customerId: string,
items: OrderItem[],
totalAmount: number,
metadata: EventMetadata,
): void {
if (this.state.orderId) throw new Error('Order already exists');
this.raise({
type: 'OrderCreated',
data: { orderId, customerId, items, totalAmount, createdAt: new Date().toISOString() },
metadata,
});
}
processPayment(
paymentId: string,
amount: number,
method: 'CARD' | 'BANK_TRANSFER' | 'VIRTUAL_ACCOUNT',
metadata: EventMetadata,
): void {
if (this.state.status !== 'CREATED') {
throw new Error(`Cannot process payment: current status is ${this.state.status}`);
}
this.raise({
type: 'PaymentProcessed',
data: {
orderId: this.state.orderId!,
paymentId,
amount,
method,
processedAt: new Date().toISOString(),
},
metadata,
});
}
private raise(event: OrderEvent): void {
this.apply(event);
this.uncommittedEvents.push(event);
}
private apply(event: OrderEvent): void {
const nextVersion = (this.state.version ?? 0) + 1;
switch (event.type) {
case 'OrderCreated':
this.state = {
...this.state,
orderId: event.data.orderId,
customerId: event.data.customerId,
totalAmount: event.data.totalAmount,
items: event.data.items,
status: 'CREATED',
version: nextVersion,
};
break;
case 'PaymentProcessed':
this.state = {
...this.state,
status: 'PAYMENT_PROCESSED',
paymentId: event.data.paymentId,
version: nextVersion,
};
break;
case 'OrderShipped':
this.state = {
...this.state,
status: 'SHIPPED',
trackingNumber: event.data.trackingNumber,
version: nextVersion,
};
break;
}
}
getState(): Readonly<Partial<OrderState>> { return this.state; }
getUncommittedEvents(): OrderEvent[] { return [...this.uncommittedEvents]; }
clearUncommittedEvents(): void { this.uncommittedEvents = []; }
}EventRepository: Storing Events in KurrentDB
There are two important points. First, storing the very first event when the stream does not yet exist must be expressed with the NO_STREAM constant. A bigint type alone cannot express new stream creation and will cause a runtime error. Second, since all events must be read to the end, drain the async iterator without placing an upper bound on maxCount. The server-recorded timestamp (event.created) used for Temporal Queries is also returned alongside.
// src/order/repositories/event.repository.ts
import { Injectable } from '@nestjs/common';
import {
KurrentDBClient,
jsonEvent,
FORWARDS,
START,
NO_STREAM,
StreamNotFoundError,
} from '@kurrent/kurrentdb-client';
import { OrderEvent } from '../events/order.events';
export interface StoredEvent {
event: OrderEvent;
recordedAt: Date; // Timestamp recorded by the KurrentDB server (event.created)
revision: bigint;
}
@Injectable()
export class OrderEventRepository {
constructor(private readonly client: KurrentDBClient) {}
async append(
orderId: string,
events: OrderEvent[],
expectedRevision: typeof NO_STREAM | bigint,
): Promise<void> {
const streamName = `order-${orderId}`;
const toWrite = events.map(e =>
jsonEvent({ type: e.type, data: e.data, metadata: e.metadata })
);
await this.client.appendToStream(streamName, toWrite, { expectedRevision });
}
async loadEvents(orderId: string): Promise<StoredEvent[]> {
return this.readFrom(orderId, START);
}
async loadEventsAfterVersion(orderId: string, afterVersion: number): Promise<StoredEvent[]> {
return this.readFrom(orderId, BigInt(afterVersion + 1));
}
private async readFrom(
orderId: string,
fromRevision: typeof START | bigint,
): Promise<StoredEvent[]> {
const streamName = `order-${orderId}`;
const results: StoredEvent[] = [];
try {
const stream = this.client.readStream(streamName, {
direction: FORWARDS,
fromRevision,
});
for await (const { event } of stream) {
if (!event) continue;
results.push({
event: {
type: event.type as OrderEvent['type'],
data: event.data as OrderEvent['data'],
metadata: event.metadata as OrderEvent['metadata'],
},
recordedAt: event.created,
revision: event.revision,
});
}
} catch (err) {
if (err instanceof StreamNotFoundError) return [];
throw err;
}
return results;
}
}Implementing Temporal Query: Reconstructing State at an Arbitrary Point in Time
Filtering by recordedAt (server-recorded timestamp) makes this work reliably without missing business payload fields or Invalid Date issues.
// src/order/queries/get-order-at-time.query.ts
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { OrderEventRepository } from '../repositories/event.repository';
import { OrderAggregate } from '../aggregates/order.aggregate';
export class GetOrderAtTimeQuery {
constructor(
public readonly orderId: string,
public readonly timestamp: Date,
) {}
}
@QueryHandler(GetOrderAtTimeQuery)
export class GetOrderAtTimeQueryHandler
implements IQueryHandler<GetOrderAtTimeQuery>
{
constructor(private readonly repo: OrderEventRepository) {}
async execute(query: GetOrderAtTimeQuery) {
const stored = await this.repo.loadEvents(query.orderId);
const events = stored
.filter(({ recordedAt }) => recordedAt <= query.timestamp)
.map(({ event }) => event);
return OrderAggregate.reconstitute(events).getState();
}
}// Example usage: querying the order state at 2:32 PM yesterday
const state = await queryBus.execute(
new GetOrderAtTimeQuery(
'order-uuid-here',
new Date('2026-07-14T14:32:00Z'),
),
);
// → { status: 'PAYMENT_PROCESSED', paymentId: 'pay-xxx', totalAmount: 59000, ... }Updating the Read Model with Projections
Apply a stream name prefix filter to subscribeToAll so only events from order- streams are received. Using a full subscription without filtering causes KurrentDB internal system events (with $-prefix) to flow in as well, increasing processing overhead.
Saving checkpoints is critical. Without saving them, every restart replays from the beginning, causing duplicate key conflicts on insert. DrizzleService is a thin NestJS injectable wrapper around the drizzle-orm connection.
// src/order/projections/order-summary.projection.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { KurrentDBClient, START } from '@kurrent/kurrentdb-client';
import { eq } from 'drizzle-orm';
import { DrizzleService } from '../db/drizzle.service';
import { orderSummaries, projectionCheckpoints } from '../db/schema';
import { OrderCreated, PaymentProcessed, OrderShipped } from '../events/order.events';
const PROJECTION_ID = 'order-summary';
@Injectable()
export class OrderSummaryProjection implements OnModuleInit {
constructor(
private readonly client: KurrentDBClient,
private readonly db: DrizzleService,
) {}
onModuleInit() {
this.startProjection().catch(console.error);
}
private async startProjection() {
const savedPosition = await this.loadCheckpoint();
// Stream name prefix filter to exclude system events — see current client docs for exact filter API
const subscription = this.client.subscribeToAll({
fromPosition: savedPosition ?? START,
filter: { streamName: { prefix: ['order-'] } },
});
for await (const resolved of subscription) {
const { event, commitPosition } = resolved;
if (!event) continue;
switch (event.type) {
case 'OrderCreated': {
const data = event.data as OrderCreated;
await this.db
.insert(orderSummaries)
.values({
orderId: data.orderId,
customerId: data.customerId,
status: 'CREATED',
totalAmount: data.totalAmount,
createdAt: data.createdAt,
})
.onConflictDoNothing(); // Skip already-processed events
break;
}
case 'PaymentProcessed': {
const data = event.data as PaymentProcessed;
await this.db
.update(orderSummaries)
.set({ status: 'PAYMENT_PROCESSED', paymentId: data.paymentId })
.where(eq(orderSummaries.orderId, data.orderId));
break;
}
case 'OrderShipped': {
const data = event.data as OrderShipped;
await this.db
.update(orderSummaries)
.set({ status: 'SHIPPED', trackingNumber: data.trackingNumber })
.where(eq(orderSummaries.orderId, data.orderId));
break;
}
}
if (commitPosition !== undefined) {
await this.saveCheckpoint(commitPosition);
}
}
}
private async loadCheckpoint(): Promise<{ commit: bigint; prepare: bigint } | undefined> {
const row = await this.db.query.projectionCheckpoints.findFirst({
where: eq(projectionCheckpoints.projectionId, PROJECTION_ID),
});
if (!row) return undefined;
return { commit: BigInt(row.commitPosition), prepare: BigInt(row.preparePosition) };
}
private async saveCheckpoint(position: { commit: bigint; prepare: bigint }): Promise<void> {
await this.db
.insert(projectionCheckpoints)
.values({
projectionId: PROJECTION_ID,
commitPosition: position.commit.toString(),
preparePosition: position.prepare.toString(),
})
.onConflictDoUpdate({
target: projectionCheckpoints.projectionId,
set: {
commitPosition: position.commit.toString(),
preparePosition: position.prepare.toString(),
},
});
}
}Reducing Replay Cost with Snapshots
Once thousands or more events have accumulated, the cost of replaying from the beginning every time becomes non-trivial. Save snapshots periodically and replay only events after the snapshot when reconstituting.
// src/order/repositories/snapshot.repository.ts
import { Injectable } from '@nestjs/common';
import { OrderState } from '../aggregates/order.aggregate';
export interface Snapshot {
streamName: string;
version: number;
state: string; // JSON.stringify(OrderState)
savedAt: string;
}
@Injectable()
export class SnapshotRepository {
// Replace with a persistent store such as PostgreSQL or Redis in production
private readonly store = new Map<string, Snapshot>();
async save(snapshot: Snapshot): Promise<void> {
this.store.set(snapshot.streamName, snapshot);
}
async findLatest(streamName: string): Promise<Snapshot | null> {
return this.store.get(streamName) ?? null;
}
}// src/order/services/aggregate-loader.service.ts
import { Injectable } from '@nestjs/common';
import { OrderAggregate, OrderState } from '../aggregates/order.aggregate';
import { OrderEventRepository } from '../repositories/event.repository';
import { SnapshotRepository } from '../repositories/snapshot.repository';
const SNAPSHOT_THRESHOLD = 100;
@Injectable()
export class AggregateLoaderService {
constructor(
private readonly eventRepo: OrderEventRepository,
private readonly snapshotRepo: SnapshotRepository,
) {}
async load(orderId: string): Promise<OrderAggregate> {
const streamName = `order-${orderId}`;
const snapshot = await this.snapshotRepo.findLatest(streamName);
if (snapshot) {
const state = JSON.parse(snapshot.state) as OrderState;
const aggregate = OrderAggregate.fromSnapshot(state);
const remaining = await this.eventRepo.loadEventsAfterVersion(orderId, snapshot.version);
aggregate.replayEvents(remaining.map(s => s.event));
return aggregate;
}
const stored = await this.eventRepo.loadEvents(orderId);
const aggregate = OrderAggregate.reconstitute(stored.map(s => s.event));
const currentVersion = aggregate.getState().version ?? 0;
if (currentVersion > 0 && currentVersion % SNAPSHOT_THRESHOLD === 0) {
await this.snapshotRepo.save({
streamName,
version: currentVersion,
state: JSON.stringify(aggregate.getState()),
savedAt: new Date().toISOString(),
});
}
return aggregate;
}
}Pros and Cons
Advantages
| Item | Description |
|---|---|
| Complete audit trail | Every state change is recorded as an event, immediately usable for compliance and debugging |
| Time-travel queries | Temporal Queries that reconstruct state at any arbitrary point in time can be implemented at the code level |
| Independent scaling | Read and write workloads can be optimized and scaled separately |
| Fault tolerance | Even if the Read DB is corrupted, it can be regenerated at any time from the event log |
| Domain expressiveness | Business events appear explicitly in the code |
| Event-driven integration | Events can be published to Kafka for loose coupling with other microservices |
Disadvantages and Caveats
| Item | Description |
|---|---|
| Increased complexity | Architectural complexity is significantly higher compared to a simple CRUD app |
| Eventual consistency | The read model may not update immediately right after a write |
| Storage growth | Events are never deleted, so storage grows continuously. An archiving strategy is required |
| Event schema evolution | Backward compatibility (Upcasting) must be maintained when event types change |
| Learning curve | The entire team must understand DDD and Aggregate design principles beforehand |
Upcasting is the process of converting stored older-version events to the current schema. For example, if a channel field not present in OrderCreated v1 is added in v2, a transformation layer is needed to fill in channel: 'UNKNOWN' when replaying old events. Because events cannot be modified once stored, this transformation logic must be accumulated and managed per version. This is a burden of an entirely different order from ordinary code refactoring.
Three Common Mistakes in Production
1. Over-segmenting the event schema from the start
Once an event type accumulates millions of records, a new Upcasting layer must be written every time the schema changes. Starting with coarse-grained domain boundaries and splitting only when separation is genuinely needed is a far more practical strategy.
2. Underestimating Projection replay time
Creating a new Read DB or modifying Projection logic requires replaying all events. With millions of events, this can take hours. Designing a snapshot strategy and running replay speed tests early is much better than doing so later.
3. Trying to add Correlation ID / Causation ID later
Tracking event chains in a distributed system requires correlationId (business flow ID) and causationId (ID of the event that caused this one) in each event's metadata. This is why the EventMetadata interface was included from the moment event types were defined in this article. Adding them later requires modifying every command handler and every piece of event-creation code.
When This Pattern Fits vs. When It Doesn't
| Fits | Does Not Fit |
|---|---|
| Systems where an audit trail is legally or business-critically required | Simple CRUD apps |
| Cases requiring time-travel queries or state history | Domains where strong immediate consistency is absolutely mandatory |
| Systems with a heavily imbalanced read/write ratio | Early stages with a small team and low DDD proficiency |
| Cases requiring event-driven integration between microservices | Cases where the operational cost of an event store is prohibitive |
Closing Thoughts
CQRS + Event Sourcing is a paradigm shift that views state not as a "current value" but as a "record of changes." Once this structure is in place, you can reproduce in code exactly why a given state came to be.
Three key takeaways:
- The event stream becomes the Single Source of Truth. Even if the Read DB is wiped out, the event log alone is enough to recover.
- The reference clock for Temporal Queries must be
event.created(the server-recorded timestamp). Time fields in business payloads can be manipulated by clients and cannot be trusted for audit purposes. - The added complexity is real. Before adopting this approach, confirming that the entire team understands DDD and Aggregate concepts is more important than anything else.
3 steps you can start with right now:
-
Spin up KurrentDB locally with Docker and try storing and retrieving a single event — that alone gives you a feel for the whole system. Viewing the stream visually in the
http://localhost:2113UI is the fastest way to build intuition at the start. -
Clone Oskar Dudycz's
EventSourcing.NodeJSrepository and run through the shopping cart example step by step. It contains well-organized, genuinely production-level patterns. -
Pick one domain from an existing system and just try defining the event types. Naming things in past-tense verbs like
OrderCreatedandPaymentProcessedwill make you see your domain from a completely different perspective.
References
- EventSourcing.NodeJS - Oskar Dudycz (GitHub)
- Straightforward Event Sourcing with TypeScript and NodeJS - Event-Driven.io
- Building A NestJS Web Application With EventStoreDB - Kurrent Official Blog
- KurrentDB Official Documentation
- @kurrent/kurrentdb-client - npm
- CQRS Pattern - Azure Architecture Center (Microsoft)
- Microservices.io - Event Sourcing Pattern
- CQRS and Event Sourcing in Practice - Java Code Geeks
- KurrentDB GitHub
- NestJS CQRS + Event Sourcing Official Course