Event Sourcing + CQRS TypeScript Implementation Guide — A practical architecture that separates commands and queries while preserving all state changes as an event log
Upcaster transforms records at read time.
There are two version management strategies:
- Version included in type name: Include the version in the event type name, like
OrderPlacedEvent@1,OrderPlacedEvent@2. This is explicit and the version is visible just by looking at the event record. - Separate version column: Add a
schema_versioncolumn to theevent_storetable. The type name stays clean, but you have to open the record to see the version.
The Upcaster applies transformations before EventStoreService.readStream() returns records. Aggregates and Projections always see only the latest version. When event structure changes accumulate, chaining upcasters like v1→v2→v3 for step-by-step transformation makes management easier. Each upcaster handles exactly one version difference.
// event-upcaster.ts
@Injectable()
export class EventUpcaster {
upcast(record: EventRecord): EventRecord {
// v1 record: missing currency field → upgrade to v2
if (
record.eventType === 'OrderPlacedEvent' &&
!('currency' in record.payload)
) {
return {
...record,
payload: { ...record.payload, currency: 'KRW' },
};
}
return record;
}
}Call the upcaster above inside EventStoreService.readStream() (as shown earlier in the readStream implementation: records.map((r) => this.upcaster.upcast(r))).
Pros and Cons
Pros
| Item | Description |
|---|---|
| Complete audit history | Every state change is recorded as an event. You can always trace "who changed what, when, and why" |
| Time-travel debugging | You can precisely reproduce the state at any point in time by replaying events |
| Projection flexibility | When business requirements change, new read models can be generated from the event log at any time |
| Read performance optimization | The Query Side can be structured with an entirely different schema tailored to query patterns |
| Loose coupling | New event consumers can be added without modifying existing code |
Cons and Challenges
| Item | Real-world pain points |
|---|---|
| Eventual consistency | A Query immediately after a Command may not return the latest data. Optimistic UI or polling strategies must be designed upfront |
| Event schema evolution | If the event structure is poorly defined due to insufficient domain understanding early on, changing it later is costly |
| Operational complexity | Event Store, Projection DB, message broker... the number of components to manage increases |
| Learning curve | If the whole team doesn't understand the pattern, code consistency breaks down |
| Storage growth | Events are never deleted, so a long-term storage strategy is needed |
When Not to Adopt
This pattern is not always the right answer. In the following cases, it is overengineering.
- Simple CRUD domains: Cases where a history of state changes is not needed, such as user profiles or app settings
- Early stages of domain understanding: When domain knowledge is insufficient to finalize event structure. Changing event schemas later is far more painful than you'd expect
- Small team + fast MVP: Structural overhead will eat into development speed
As of 2026, the practical consensus is selective adoption. The realistic approach is to apply it only to Bounded Contexts with high complexity and important audit history — such as payments, orders, and inventory — while keeping the rest in the conventional style.
Common Mistakes in Practice
- Confusing events and commands:
CreateOrder(command) andOrderCreated(event) are different. Events must be past tense, immutable, and non-deletable. - Setting Aggregate boundaries too wide: Putting Customer information inside an Order Aggregate increases contention. Keep Aggregates small.
- Putting Projections in the same DB as the Event Store: This defeats the purpose of separation. Read models must use an independent store for optimization to be meaningful.
- Storing events without a serialization strategy:
JSON.stringify(eventInstance)loses prototype information.instanceofchecks break during deserialization. Handle serialization/deserialization explicitly with a type field (discriminator) and factory functions.
Closing Thoughts
Event Sourcing and CQRS structurally eliminate the problem of "being unable to trace the cause of state changes." Since every change is recorded in the event log, audit trails come naturally, and separating the read model with CQRS allows query performance to be optimized independently. However, selectively applying it only to high-complexity Bounded Contexts is the practical consensus as of 2026.
Here are three steps you can start with right now.
Step 1: Pick one domain in your existing service where audit history is most needed. A good candidate is somewhere where the question "why did this data change?" comes up frequently — like payments or orders.
Step 2: Start by creating a simple Event Store table based on PostgreSQL. Dedicated solutions like KurrentDB can be introduced later. The right order is to first get comfortable with Command/Query separation using @nestjs/cqrs.
Step 3: Apply Event Sourcing to a single Aggregate, build a Projection, and verify that event replay actually works. Through that process, decide on the event serialization strategy, eventual consistency handling, and version management conventions together with your team.
References
- CQRS and Event Sourcing in TypeScript: A Production Walkthrough — Atomic Object
- CQRS Pattern — Azure Architecture Center (Microsoft Learn)
- Event Sourcing Pattern — Azure Architecture Center (Microsoft Learn)
- NestJS Official CQRS Documentation
- @ocoda/event-sourcing Official Documentation
- Navigating CQRS and Event Sourcing with NestJS and EventStoreDB — Medium
- Building CQRS and Event Sourcing for Hotel Management with NestJS and EventStoreDB — Medium
- Snapshots in Event Sourcing — Kurrent Official Blog
- Building a scalable event-driven architecture with KurrentDB and Kafka — Kurrent
- Event Sourcing, CQRS and Micro Services: Real FinTech Example — DEV Community
- How to Build Event Sourcing Systems in Node.js — OneUptime Blog
- GitHub: yerinadler/typescript-event-sourcing-sample-app
- GitHub: ocoda/event-sourcing