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
- 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.
- 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.
- 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.
- 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.
Member discussion