7 min read

CQRS in Action: Building Scalable TypeScript APIs with Next.js

Learn how to split reads and writes in a Next.js app using TypeScript‑driven CQRS, with a complete e‑commerce example.
CQRS in Action: Building Scalable TypeScript APIs with Next.js

Introduction

Modern web applications often start simple—one API route that both validates input and returns data. As traffic grows, that single responsibility becomes a bottleneck: read‑heavy workloads compete with write‑heavy transactions, and the codebase starts to mix concerns. Command Query Responsibility Segregation (CQRS) offers a clean architectural split: commands mutate state, while queries return data.

In this article we’ll walk through a practical CQRS implementation using Next.js 13+ API Routes, TypeScript, and a minimal in‑memory store (replaceable with any persistence layer). By the end you’ll have a reusable pattern you can drop into any Next.js project.


1. Core Concepts Refresher

Concept Responsibility Typical HTTP Verb
Command Change the system state (create, update, delete). POST, PUT, PATCH, DELETE
Query Retrieve data without side effects. GET
Command Handler Validates, authorises, and executes a command.
Query Handler Reads from a data source, maps to DTOs.
Event (optional) Notifies other parts of the system after a command succeeds.

The key rule: Commands never return data other than an acknowledgement (e.g., an ID). Queries never cause mutations.


2. Project Layout

/src
 ├─ /api
 │   ├─ /commands
 │   │   └─ order.ts          // POST /api/commands/order
 │   └─ /queries
 │       └─ order.ts          // GET  /api/queries/order/[id]
 ├─ /domain
 │   ├─ /order
 │   │   ├─ Order.ts           // Entity / value objects
 │   │   ├─ OrderRepository.ts // Interface + InMemory impl
 │   │   ├─ CreateOrderCmd.ts // Command DTO
 │   │   ├─ CreateOrderHandler.ts
 │   │   └─ GetOrderQuery.ts
 └─ /shared
     └─ Result.ts             // Simple success / error wrapper

The folder separation mirrors the Command and Query boundaries, making it obvious where new features belong.


3. Defining Types with TypeScript

3.1. Result Wrapper

// src/shared/Result.ts
export type Ok<T> = { ok: true; value: T };
export type Err<E = string> = { ok: false; error: E };
export type Result<T, E = string> = Ok<T> | Err<E>;

export const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
export const err = <E = string>(error: E): Err<E> => ({ ok: false, error });

All handlers return a Result, forcing callers to handle failures explicitly.

3.2. Order Entity

// src/domain/order/Order.ts
export type OrderStatus = 'PENDING' | 'PAID' | 'SHIPPED';

export interface OrderProps {
  id: string;
  userId: string;
  items: { productId: string; qty: number }[];
  total: number;
  status: OrderStatus;
  createdAt: Date;
}

export class Order implements OrderProps {
  id: string;
  userId: string;
  items: { productId: string; qty: number }[];
  total: number;
  status: OrderStatus;
  createdAt: Date;

  constructor(props: OrderProps) {
    this.id = props.id;
    this.userId = props.userId;
    this.items = props.items;
    this.total = props.total;
    this.status = props.status;
    this.createdAt = props.createdAt;
  }

  // Domain behaviour – e.g., transition status
  markPaid() {
    if (this.status !== 'PENDING') throw new Error('Only pending orders can be paid');
    this.status = 'PAID';
  }
}

3.3. Command DTO

// src/domain/order/CreateOrderCmd.ts
export interface CreateOrderCmd {
  userId: string;
  items: { productId: string; qty: number }[];
}

3.4. Query DTO

// src/domain/order/GetOrderQuery.ts
export interface GetOrderQuery {
  id: string;
}

4. Repository Abstraction

// src/domain/order/OrderRepository.ts
import { Order } from './Order';
import { Result } from '../../shared/Result';

export interface OrderRepository {
  save(order: Order): Promise<Result<void>>;
  findById(id: string): Promise<Result<Order | null>>;
}

// In‑memory implementation (swap for Prisma, TypeORM, etc.)
export class InMemoryOrderRepo implements OrderRepository {
  private store = new Map<string, Order>();

  async save(order: Order) {
    this.store.set(order.id, order);
    return ok(undefined);
  }

  async findById(id: string) {
    const order = this.store.get(id) ?? null;
    return ok(order);
  }
}

The repository is deliberately interface‑first; the command handler depends only on the contract, not the concrete storage.


5. Command Handler

// src/domain/order/CreateOrderHandler.ts
import { CreateOrderCmd } from './CreateOrderCmd';
import { OrderRepository } from './OrderRepository';
import { Order } from './Order';
import { v4 as uuid } from 'uuid';
import { ok, err, Result } from '../../shared/Result';

export class CreateOrderHandler {
  constructor(private readonly repo: OrderRepository) {}

  async execute(cmd: CreateOrderCmd): Promise<Result<string>> {
    // 1️⃣ Validate business rules
    if (cmd.items.length === 0) {
      return err('Order must contain at least one item');
    }

    // 2️⃣ Compute total (simplified: each item = $10)
    const total = cmd.items.reduce((sum, i) => sum + i.qty * 10, 0);

    // 3️⃣ Build aggregate
    const order = new Order({
      id: uuid(),
      userId: cmd.userId,
      items: cmd.items,
      total,
      status: 'PENDING',
      createdAt: new Date(),
    });

    // 4️⃣ Persist
    const saveResult = await this.repo.save(order);
    if (!saveResult.ok) return err('Failed to persist order');

    // 5️⃣ Return identifier (no domain data)
    return ok(order.id);
  }
}

Notice the absence of any response payload beyond the generated ID—exactly what CQRS prescribes.


6. Query Handler

// src/domain/order/GetOrderHandler.ts
import { GetOrderQuery } from './GetOrderQuery';
import { OrderRepository } from './OrderRepository';
import { ok, err, Result } from '../../shared/Result';
import { Order } from './Order';

export class GetOrderHandler {
  constructor(private readonly repo: OrderRepository) {}

  async execute(q: GetOrderQuery): Promise<Result<Order>> {
    const res = await this.repo.findById(q.id);
    if (!res.ok) return err('Repository error');
    if (!res.value) return err('Order not found');
    return ok(res.value);
  }
}

The query handler returns the full Order aggregate because reads are free to expose as much as needed.


7. Wiring It Up in Next.js API Routes

7.1. Shared Repository Instance

// src/api/_repo.ts
import { InMemoryOrderRepo } from '../../domain/order/OrderRepository';

export const orderRepo = new InMemoryOrderRepo();

In a real project you’d replace this with a singleton that wraps a database client.

7.2. Command Route (POST /api/commands/order)

// src/pages/api/commands/order.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { CreateOrderHandler } from '../../../domain/order/CreateOrderHandler';
import { orderRepo } from '../_repo';
import { err, ok } from '../../../shared/Result';

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
  if (req.method !== 'POST') {
    res.setHeader('Allow', 'POST');
    return res.status(405).end('Method Not Allowed');
  }

  const cmd = req.body; // TypeScript will infer any; you can add Zod/Yup validation later
  const createHandler = new CreateOrderHandler(orderRepo);
  const result = await createHandler.execute(cmd);

  if (!result.ok) {
    return res.status(400).json({ error: result.error });
  }

  // 201 Created + location header is a nice REST touch
  res.setHeader('Location', `/api/queries/order/${result.value}`);
  return res.status(201).json({ orderId: result.value });
};

export default handler;

7.3. Query Route (GET /api/queries/order/[id])

// src/pages/api/queries/order/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { GetOrderHandler } from '../../../../domain/order/GetOrderHandler';
import { orderRepo } from '../../_repo';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'GET') {
    res.setHeader('Allow', 'GET');
    return res.status(405).end('Method Not Allowed');
  }

  const { id } = req.query;
  if (typeof id !== 'string') {
    return res.status(400).json({ error: 'Invalid order id' });
  }

  const queryHandler = new GetOrderHandler(orderRepo);
  const result = await queryHandler.execute({ id });

  if (!result.ok) {
    return res.status(404).json({ error: result.error });
  }

  // Serialize Date to ISO string for JSON transport
  const order = {
    ...result.value,
    createdAt: result.value.createdAt.toISOString(),
  };

  return res.status(200).json(order);
}

Both routes are thin: they only translate HTTP ↔︎ domain objects and delegate all business logic to handlers.


8. Adding an Event Bus (Optional)

CQRS often pairs with Event Sourcing or simple domain events. Here’s a minimal in‑process bus:

// src/shared/EventBus.ts
type EventHandler<E> = (event: E) => Promise<void>;

export class EventBus {
  private handlers = new Map<string, EventHandler<any>[]>();

  on<E>(type: string, handler: EventHandler<E>) {
    const list = this.handlers.get(type) ?? [];
    list.push(handler);
    this.handlers.set(type, list);
  }

  async emit<E>(type: string, event: E) {
    const list = this.handlers.get(type) ?? [];
    await Promise.all(list.map((h) => h(event)));
  }
}

Inject the bus into the command handler and emit an OrderCreated event after persisting. Consumers (e.g., a background email sender) can subscribe without touching the API layer. This keeps the write side pure while enabling event‑driven read models later.


9. Testing the Handlers (Unit Focus)

// tests/CreateOrderHandler.test.ts
import { InMemoryOrderRepo } from '../src/domain/order/OrderRepository';
import { CreateOrderHandler } from '../src/domain/order/CreateOrderHandler';

test('creates order and returns id', async () => {
  const repo = new InMemoryOrderRepo();
  const handler = new CreateOrderHandler(repo);

  const cmd = { userId: 'u1', items: [{ productId: 'p1', qty: 2 }] };
  const result = await handler.execute(cmd);

  expect(result.ok).toBe(true);
  expect(typeof result.value).toBe('string');

  const stored = await repo.findById(result.value!);
  expect(stored.ok && stored.value).toBeTruthy();
  expect(stored.value?.total).toBe(20); // 2 * $10
});

Because the API routes are thin, you can achieve high test coverage by focusing on the handlers and repository contracts.


10. Scaling the Pattern

Scaling Concern CQRS‑Friendly Approach
Read‑heavy traffic Deploy a separate read‑only service that uses a materialized view built from domain events.
Write contention Queue commands behind a lightweight broker (e.g., BullMQ) to serialize critical sections.
Cross‑service consistency Publish events to a message bus (Kafka, NATS) and let downstream services react asynchronously.
Versioned APIs Because commands and queries are decoupled, you can evolve the read model without breaking writes.

In a Next.js monolith you can still reap many benefits: the write side stays fast and deterministic, while the read side can be cached aggressively (e.g., swr on the client) without worrying about stale writes.


11. Common Pitfalls & How to Avoid Them

  1. Returning data from commands – Resist the temptation to embed a DTO in the command response; use a query if the client needs the fresh state.
  2. Over‑engineering the event bus – Start with a simple in‑process bus; only move to a distributed system when you truly need cross‑process communication.
  3. Mixing repositories – Keep a single source of truth per aggregate. If you introduce a read‑model repository, treat it as derived data, not the authoritative store.
  4. Neglecting validation – Commands should be validated before they hit the domain layer (e.g., using Zod). Validation errors belong to the command side, not the query side.

12. Recap

  • Separate concerns: Commands mutate, queries read.
  • Leverage TypeScript: Strongly typed DTOs, result wrappers, and repository contracts keep the codebase self‑documenting.
  • Thin API routes: Let Next.js handle HTTP plumbing; keep business logic in dedicated handlers.
  • Future‑proof: Adding events, read‑model caches, or even a separate microservice becomes a matter of wiring, not refactoring.

By adopting CQRS in a Next.js project you gain a clearer mental model, easier testing, and a path to scale without rewriting the core API. The pattern fits naturally with the file‑system routing of Next.js—just place commands under /api/commands and queries under /api/queries.

Give it a try in a small feature, iterate on the abstractions, and you’ll quickly see the payoff in maintainability and performance.