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
Complete Guide to Integrating Next.js with Prisma ORM for Type-Safe Database Operations in 2024

Learn how to integrate Next.js with Prisma ORM for type-safe database operations. Build scalable web apps with seamless data management and improved developer experience.

Blog Image
Build a Type-Safe GraphQL API with NestJS, Prisma, and Apollo Server: Complete Developer Guide

Learn to build a complete type-safe GraphQL API using NestJS, Prisma, and Apollo Server. Master advanced features like subscriptions, auth, and production deployment.

Blog Image
Complete Guide to Integrating Prisma with Next.js for Modern Full-Stack Development

Learn how to integrate Prisma with Next.js for powerful full-stack development. Build type-safe web apps with seamless database operations and API routes.

Blog Image
Mastering Advanced Caching Strategies: From Cache-Aside to Multi-Layered Systems

Struggling with slow APIs and cache issues? Learn advanced caching patterns to boost performance and prevent stampedes.

Blog Image
Complete Production Guide to BullMQ Message Queue Processing with Redis and Node.js

Master BullMQ and Redis for production-ready Node.js message queues. Learn job processing, scaling, monitoring, and complex workflows with TypeScript examples.

Blog Image
Building Event-Driven Microservices with Node.js, EventStore and gRPC: Complete Architecture Guide

Learn to build scalable distributed systems with Node.js, EventStore & gRPC microservices. Master event sourcing, CQRS patterns & resilient architectures.