Event Sourcing & CQRS with Node.js + PostgreSQL: Don’t just store results, store the journey

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Why bother with Event Sourcing and CQRS?

The traditional CRUD (Create, Read, Update, Delete) mindset is very familiar. When a user changes their name, you simply execute an UPDATE command. Done! However, this action inadvertently erases the old trace. You certainly don’t want to end up in a situation where a customer asks: “Why did my wallet balance drop by 500k at 2 AM?” while the DB only shows the final figure.

I once participated in restructuring an e-wallet system processing over 50,000 transactions per day. The hard-earned lesson was that without a proper audit log mechanism, debugging or data reconciliation becomes a disaster. That’s where Event Sourcing and CQRS shine, especially when Applying Domain-Driven Design (DDD) to Node.js Projects. Instead of storing a static state, we record every action that has occurred. Simultaneously, separating the Read and Write models prevents the system from bottlenecking during traffic spikes.

Quick Implementation: Turning PostgreSQL into an Event Store

You don’t necessarily need Kafka or EventStoreDB from the start. PostgreSQL with its JSONB data type is extremely powerful and capable of handling this role for medium to large projects, similar to how one might build a professional-grade REST API with NestJS and PostgreSQL.

Step 1: Design the Event Storage Table

The table structure should be minimalist yet contain enough information to reconstruct the past.

CREATE TABLE events (
    id BIGSERIAL PRIMARY KEY,
    aggregate_id UUID NOT NULL, -- Example: Order ID or User ID
    event_type VARCHAR(50) NOT NULL, -- MoneyDeposited, OrderCancelled
    payload JSONB NOT NULL, -- Raw event data
    version INT NOT NULL, -- Prevent data conflicts (Concurrency)
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

Step 2: Handling the Command Side with Node.js

Let’s see how we record an event for depositing money into an account.

async function depositMoney(userId, amount) {
    const event = {
        aggregate_id: userId,
        event_type: 'MoneyDeposited',
        payload: { amount, currency: 'VND' },
        version: await getNextVersion(userId)
    };

    await db.query(
        'INSERT INTO events (aggregate_id, event_type, payload, version) VALUES ($1, $2, $3, $4)',
        [event.aggregate_id, event.event_type, event.payload, event.version]
    );
    
    console.log(`[Event Saved] User ${userId} successfully deposited ${amount} VND`);
}

Decoding the Combination: When the Past Defines the Present

Event Sourcing: The Past is the Only Truth

In this world, an account balance is not a fixed number in the DB. It is the result of replaying the entire transaction history.

  • Transaction 1: +1,000,000 VND
  • Transaction 2: -200,000 VND
  • Transaction 3: +50,000 VND
  • => Current State: 850,000 VND

If the calculation logic has a bug, you just need to fix the code and replay. Your data will always be absolutely accurate without fear of being overwritten or lost.

CQRS: Separate to Scale

CQRS (Command Query Responsibility Segregation) splits the application into two distinct branches:

  1. Write Side (Command): Receives requests, validates logic, and records Events. This branch prioritizes data integrity.
  2. Read Side (Query): Uses a denormalized database for lightning-fast queries.

In reality, replaying 1 million events to check a balance every time a user opens the App is impossible. Therefore, we need a Worker to listen for the latest events and update a user_balances table. At this point, the Frontend only needs to execute a simple SELECT command.

Optimizing the Read Model (Projections)

This is the bridge that helps the system achieve Eventual Consistency.

async function projectMoneyDeposited(event) {
    // Immediately update the Read Model table so the user sees the new balance
    await db.query(
        'UPDATE user_balances SET balance = balance + $1, updated_at = NOW() WHERE user_id = $2',
        [event.payload.amount, event.aggregate_id]
    );
}

To make the system smoother, consider using Redis Pub/Sub or RabbitMQ. As soon as the Write Side finishes saving an Event, the Read Side receives a signal and updates the data within milliseconds.

Battle-Tested Experience: Pitfalls to Avoid

Applying this duo to real projects isn’t as rosy as the theory. Here are 3 important notes I’ve gathered:

1. Don’t over-engineer too early

If you’re just building a Todo list app or a news site, use CRUD to keep things simple. Event Sourcing is only worth the investment when the business logic is extremely complex or requires strict auditing.

2. Snapshots – The Performance Solution

When a loyal customer has over 5,000 transactions, replaying will start to slow down. The solution is using Snapshots. Every 100 events, save the state at that point in time. When you need to calculate the balance, just take the latest Snapshot and add the few newly occurred events.

3. Versioning and Immutability

Once event data is written, it is Immutable. However, business requirements always change. If you add a mandatory field to a new Event, ensure the old code can still handle version 1.0 Events without crashing the system.

Conclusion

Implementing Event Sourcing and CQRS with Node.js and PostgreSQL isn’t overly difficult. The hardest part is training your mindset to shift from “storing state” to “storing actions.” Once you master this technique and embrace Clean Architecture in Node.js, you’ll be more confident designing large-scale systems that demand high reliability.

Are you ready to give up UPDATE and start INSERT-ing everything? Try applying it to a small module in your next project to see the difference.

Share: