js

How to Scale Web Apps with CQRS, Event Sourcing, and Bun + Fastify

Learn to build scalable web apps using CQRS, event sourcing, Bun, Fastify, and PostgreSQL for fast reads and reliable writes.

How to Scale Web Apps with CQRS, Event Sourcing, and Bun + Fastify

I’ve been thinking about how we build web applications lately. Most of us start with a simple model: one database, one set of logic for reading and writing. It works, until it doesn’t. When your app grows, the needs for reading data and writing data start to pull in opposite directions. This tension is what led me to explore a different way of structuring things. Today, I want to walk you through building a system that handles this growth gracefully, using some of the fastest tools available.

Why does this matter? Imagine a popular e-commerce site. The act of placing an order (a write) is critical and must be perfectly accurate. But the product listing page (a read) needs to be blisteringly fast and might show aggregated data from thousands of orders. Trying to serve both needs from the same database table can slow everything down. This is where separating the command (write) side from the query (read) side becomes a powerful strategy.

Let’s build this together. We’ll use Bun for its incredible speed, Fastify for a lean web framework, and PostgreSQL for its rock-solid reliability. The goal is a system where writes are secure and consistent, while reads are fast and scalable.

First, we need to set up our project. Create a new directory and initialize it with Bun.

bun init -y
bun add fastify @fastify/cors @fastify/helmet postgres zod

Our folder structure will separate concerns from the start. We’ll have distinct folders for commands, queries, events, and our core domain logic. This separation is the foundation of our architecture.

The heart of our write side is the domain entity. This is where business rules live. Let’s define an Order entity. Notice how it focuses on behavior—what can you do to an order?—rather than just holding data.

// src/domain/entities/order.entity.ts
export class Order {
  private id: string;
  private customerId: string;
  private status: 'PENDING' | 'CONFIRMED' | 'CANCELLED';
  private items: OrderItem[];
  private version: number = 1;

  constructor(data: { id: string; customerId: string; items: OrderItem[] }) {
    this.id = data.id;
    this.customerId = data.customerId;
    this.status = 'PENDING';
    this.items = data.items;
    this.validate();
  }

  confirm() {
    if (this.status !== 'PENDING') {
      throw new Error('Only pending orders can be confirmed.');
    }
    this.status = 'CONFIRMED';
    // In a full system, we'd record a 'OrderConfirmed' event here
  }

  private validate() {
    if (this.items.length === 0) {
      throw new Error('Order must have at least one item.');
    }
  }
}

With our entity defined, how do we save its state? We don’t just dump the final object into a table. Instead, we record every change as an event. This is called event sourcing. Think of it like a bank statement: you see every transaction, not just the final balance. This gives us a complete history and makes complex business logic easier to manage.

Our command handler’s job is to load past events, create the entity, perform the action, and store the new event.

// src/commands/handlers/confirmOrder.handler.ts
export async function handleConfirmOrder(command: { orderId: string }) {
  // 1. Load all past events for this order from the event store
  const pastEvents = await eventStore.getEventsForAggregate(command.orderId);
  
  // 2. Recreate the Order entity by replaying those events
  const order = reconstituteOrderFromEvents(pastEvents);
  
  // 3. Execute the domain behavior
  order.confirm();
  
  // 4. Save the new 'OrderConfirmed' event
  const newEvent = createEvent(order.id, 'OrderConfirmed', { orderId: order.id });
  await eventStore.saveEvent(newEvent);
  
  // 5. The event is published for any interested listeners (like our read side)
  await eventBus.publish(newEvent);
}

Now, what about reading data? This is where the magic of separation pays off. Our read database doesn’t need the complex, normalized structure of the write side. It can be a simple, flat table optimized for the questions our application needs to answer. How would you design a table if your only job was to display order summaries on a dashboard?

We build these optimized views using projections. A projection is a piece of code that listens for events (like OrderConfirmed) and updates the read-side tables accordingly.

// src/queries/projections/orderSummary.projection.ts
export async function handleOrderConfirmed(event: OrderConfirmedEvent) {
  // Fetch any additional data needed for the view
  const orderDetails = await writeDb.getOrderDetails(event.orderId);
  
  // Update the optimized read table
  await readDb.query(`
    INSERT INTO order_summaries (id, customer_id, status, total_amount, item_count)
    VALUES ($1, $2, $3, $4, $5)
    ON CONFLICT (id) DO UPDATE SET
      status = EXCLUDED.status,
      updated_at = NOW()
  `, [orderDetails.id, orderDetails.customerId, 'CONFIRMED', orderDetails.total, orderDetails.items.length]);
}

This separation introduces a crucial concept: eventual consistency. When a user confirms an order, the command side processes it immediately. The read side, however, updates a few milliseconds later when it receives the event. For a moment, the user might not see their change on the order list screen. Is this delay acceptable? For many features, like updating a product listing, it is. For others, like showing an account balance, you might need a different approach.

How do we connect these two sides? An event bus acts as the nervous system. PostgreSQL itself can help here with its LISTEN and NOTIFY features, creating a simple, reliable channel.

// src/infrastructure/eventBus.ts
import { Client } from 'postgres';

const client = new Client(process.env.DATABASE_URL);
await client.connect();

// Listen for new events
await client.query('LISTEN new_event');

client.on('notification', async (msg) => {
  const event = JSON.parse(msg.payload);
  // Route this event to all registered projection handlers
  await projectionDispatcher.dispatch(event);
});

export async function publishEvent(event: any) {
  // Save to event store
  await eventStore.save(event);
  // Notify all listeners
  await client.query(`NOTIFY new_event, '${JSON.stringify(event)}'`);
}

With the core flow in place, we need to expose it via a fast API. Fastify lets us create separate routes for commands and queries, making the separation clear even in our HTTP layer.

// src/api/routes/commands.routes.ts
import { FastifyInstance } from 'fastify';
import { z } from 'zod';

export async function commandRoutes(app: FastifyInstance) {
  app.post('/order/confirm', {
    schema: {
      body: z.object({
        orderId: z.string().uuid()
      })
    }
  }, async (request, reply) => {
    const command = request.body;
    await handleConfirmOrder(command);
    reply.code(202).send({ accepted: true, message: 'Command processed' });
  });
}

// src/api/routes/queries.routes.ts
export async function queryRoutes(app: FastifyInstance) {
  app.get('/orders/summary', async (request, reply) => {
    // Query goes straight to the optimized read table
    const result = await readDb.query('SELECT * FROM order_summaries ORDER BY created_at DESC LIMIT 50');
    reply.send(result.rows);
  });
}

Building this way requires a shift in thinking. You’re not just creating a database schema; you’re modeling business processes as a series of events and designing views specifically for your user’s needs. The initial complexity brings long-term benefits in scalability and flexibility. When a new reporting feature is requested, you can create a new projection and a new read table without touching the complex, critical command side.

What happens when things go wrong? A robust CQRS system needs monitoring. You must track the lag between an event being published and its projection being updated. Simple logging can help you see the system’s heartbeat.

// Monitor projection lag
const state = await readDb.query('SELECT projection_name, last_processed_event_id FROM projection_state');
const latestEvent = await eventStore.getLatestEventId();
const lag = latestEvent - state.rows[0].last_processed_event_id;
console.log(`Projection lag is currently ${lag} events.`);

This approach isn’t for every project. If you’re building a simple blog or a basic admin panel, the traditional way is perfectly fine. But when you feel the pain of complex business logic tangled with demanding reporting needs, this separation offers a path forward. It allows each part of your system to be the best at what it does.

I encourage you to start small. Try modeling a single, bounded process in your application using events. Build one projection to power a specific screen. Feel the difference in clarity and performance. The tools we used—Bun, Fastify, PostgreSQL—are just vehicles. The real value is in the architectural pattern that lets your application evolve without becoming a tangled mess.

What part of your current system struggles the most under load? Is it the writes that need to be transactional, or the reads that need to be fast? Share your thoughts in the comments below. If you found this walk-through helpful, please like and share it with another developer who might be wrestling with these same scaling challenges. Let’s build more resilient systems together.


As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!


📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!


Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Keywords: cqrs, event sourcing, bun, fastify, postgresql



Similar Posts
Blog Image
Build High-Performance GraphQL APIs with NestJS, Prisma, and Redis Caching for Scalable Applications

Learn to build a scalable GraphQL API using NestJS, Prisma ORM, and Redis caching. Master DataLoader patterns, authentication, and performance optimization techniques.

Blog Image
Event-Driven Microservices Mastery: Build Scalable Systems with NestJS, RabbitMQ, and MongoDB

Learn to build scalable event-driven microservices with NestJS, RabbitMQ & MongoDB. Master async patterns, event sourcing & distributed systems. Start building today!

Blog Image
Build Real-Time GraphQL Subscriptions with Apollo Server, Redis and WebSockets: Complete Implementation Guide

Learn to build scalable GraphQL subscriptions with Apollo Server, Redis PubSub & WebSockets. Complete guide with TypeScript, auth & real-time messaging. Build now!

Blog Image
Complete NestJS Authentication Guide: JWT, Prisma, and Advanced Security Patterns

Build complete NestJS authentication with JWT, Prisma & PostgreSQL. Learn refresh tokens, RBAC, email verification, security patterns & testing for production-ready apps.

Blog Image
Complete Guide to Next.js Prisma ORM Integration: Build Type-Safe Full-Stack Applications 2024

Learn how to integrate Next.js with Prisma ORM for type-safe database operations. Build full-stack TypeScript apps with seamless data handling and migrations.

Blog Image
Complete Guide to Next.js and Prisma Integration: Build Type-Safe Full-Stack Applications

Learn how to integrate Next.js with Prisma for powerful full-stack development. Get type-safe database access, seamless TypeScript support, and scalable web apps.