Build a CQRS Order Management API
Separate reads from writes to build a fast, scalable order API.
Introduction
30 Second Summary
Think about how an online store dashboard loads. The same database tables that handle incoming orders also need to serve up fast summaries for the admin view.
In this project, you will build an order management API that separates writes from reads using the CQRS pattern. You'll start with a naive single-model approach, watch it struggle under a realistic dashboard query, then refactor into clean separated command and query paths.
What You'll Build
A running Express API where placing an order writes to normalized tables but querying the dashboard reads from a single flat table that returns exactly what a frontend needs in one shot.
By the end of this project, you'll have:
- A POST endpoint that validates and processes orders through a dedicated command handler, writing to normalized tables and syncing the read model in one transaction.
- A GET endpoint that serves a flat, UI-ready dashboard response from a single denormalized table with zero JOINs.
- A comparison endpoint that shows the performance difference between the naive multi-JOIN approach and the CQRS read model side by side.
- Secret Mission: Add a product analytics read model that tracks top-selling products, proving how CQRS supports multiple query perspectives without touching the write side.
Are there any prerequisites?
This project assumes you are comfortable with TypeScript basics, have worked with REST APIs before, and know enough SQL to read simple queries.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up the Project
To demonstrate CQRS, you need a write path, a read path, and a database that shows the difference between them.
This project uses TypeScript with an embedded SQLite database so you can focus entirely on the architecture pattern. In this step, you will initialize the project, install dependencies, and create a minimal Express server to confirm everything works.
In this step, get ready to:
- Initialize a Node.js project with TypeScript dependencies.
- Create a minimal Express server with a health-check endpoint.
- Verify the server starts and responds on port 3000.
Initialize the project and install dependencies
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder, move into it, and initialize npm by running these commands:
mkdir cqrs-orders
cd cqrs-orders
npm init -y
You should see the contents of a new package.json printed in your terminal.
- Install the runtime dependencies by running:
npm install express@5.1.0 better-sqlite3@13.0.2
- Install the development dependencies by running:
npm install -D tsx@4.23.1 typescript@5.9.3 @types/express@5.0.2 @types/better-sqlite3@7.6.13
You should see "added" followed by a package count in the output, confirming everything installed.
What are these packages?
- express is the API framework for handling HTTP requests and routing.
- better-sqlite3 is an embedded SQLite database with a synchronous API, so no async/await noise obscuring the CQRS pattern.
- tsx runs TypeScript files directly without a separate compilation step.
- typescript and the @types/* packages provide type checking and editor autocomplete.
Seeing install errors?
- Make sure you are inside the cqrs-orders folder before running npm install. Run pwd to confirm.
- If you see network timeouts, check your internet connection and try again.
- help me fix npm install errors in my CQRS project
Create the project structure and entry point
- Create the src directory by running:
mkdir src
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the cqrs-orders folder, and click Open.
You should see the cqrs-orders folder name in the VS Code title bar, with src visible in the Explorer sidebar.
- In VS Code's Explorer sidebar, right-click the src folder and select New File.
- Name the file index.ts.
- Add the following code to src/index.ts:
import express from "express";
const app = express();
app.use(express.json());
// Health-check route to confirm the server is alive
app.get("/health", (req, res) => {
res.json({ status: "ok" });
});
// Start the server on port 3000
app.listen(3000, () => {
console.log("Server running on http://localhost:3000");
});
What does this code do?
- express() creates a new Express application instance.
- app.use(express.json()) enables the server to parse incoming JSON request bodies.
- The /health route returns a simple JSON object to confirm the server is running.
- app.listen(3000, ...) starts the server on port 3000 and prints a confirmation message to the terminal.
- Save src/index.ts.
Seeing red squiggly lines in VS Code?
- TypeScript might show import errors briefly while the editor indexes your packages. Wait a few seconds for the errors to resolve.
- If errors persist, try closing and reopening the src/index.ts tab.
- help me fix TypeScript errors in my Express server file
✔️ Awesome, I've got everything!
Great. Double check you have saved src/index.ts and move on to running the server.
ⓧ I'd like to double check the full code
Here is the complete src/index.ts file. Make sure yours matches exactly:
import express from "express";
const app = express();
app.use(express.json());
app.get("/health", (req, res) => {
res.json({ status: "ok" });
});
app.listen(3000, () => {
console.log("Server running on http://localhost:3000");
});
Run the server
Before you run the next command, what do you think you will see in the terminal?
- Switch back to your terminal (make sure you are still in the cqrs-orders folder).
- Start the server by running:
npx tsx src/index.ts
You should see Server running on http://localhost:3000 printed in the terminal.
What is tsx doing here?
tsx compiles and runs your TypeScript file in one step using esbuild under the hood. No separate build command needed.
- Open a new terminal tab or window (your server is using the current one).
- Test the health endpoint by running:
curl http://localhost:3000/health
You should see {"status":"ok"} in the response.
Server not starting?
- Make sure you saved src/index.ts before running the command.
- Confirm you are in the cqrs-orders directory by running pwd.
- If you see "Cannot find module", make sure the src directory exists and contains index.ts.
- help me fix my Express server that won't start with tsx
Your development environment is ready and the server is responding. Next up, you will build a traditional CRUD API and discover firsthand why complex queries get painful fast.
Build the Naive Single-Model API
Your server responds to health checks. Now it's time to build the real thing: an API that stores and retrieves orders.
You'll start with the traditional approach. A single set of normalized tables handles both reading and writing order data. This is how most backend APIs begin, and it works fine at first. But as you'll see, reading data back out in a useful shape gets painful fast.
In this step, get ready to:
- Create a normalized database schema with four related tables.
- Build routes that read and write from the same shared model.
- Seed the database and test the multi-JOIN query endpoint.
Create the normalized database schema
A normalized schema splits data into separate tables to avoid duplication. Customers go in one table, products in another, orders in a third, and the line items connecting orders to products in a fourth. This keeps writes clean but forces reads to reassemble data from multiple places.
- Create a new file src/database.ts inside your cqrs-orders folder.
- Add the database connection setup by pasting this code:
import Database from "better-sqlite3";
import path from "path";
// Singleton database connection
let db: Database.Database;
export function getDb(): Database.Database {
if (!db) {
db = new Database(path.join(process.cwd(), "orders.db"));
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
}
return db;
}
What does this code do?
The getDb() function creates a singleton connection to a SQLite database file called orders.db. It only creates the connection once, then reuses it on every subsequent call.
WAL (Write-Ahead Logging) mode improves concurrent read performance. Enabling foreign keys ensures the database enforces relationships between tables.
- Below the getDb function, add the initDatabase function that creates all four tables:
export function initDatabase(): void {
const database = getDb();
database.exec(`
CREATE TABLE IF NOT EXISTS customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
price REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (customer_id) REFERENCES customers(id)
);
CREATE TABLE IF NOT EXISTS order_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
quantity INTEGER NOT NULL,
unit_price REAL NOT NULL,
FOREIGN KEY (order_id) REFERENCES orders(id),
FOREIGN KEY (product_id) REFERENCES products(id)
);
`);
}
What does this schema look like?
Four tables, each with a single responsibility:
- customers stores who placed the order.
- products stores what can be ordered.
- orders stores each order's metadata (customer, status, timestamp).
- order_items links orders to products with quantities and prices.
This is textbook normalization. No data is duplicated. But to answer "show me all orders with customer names and item totals," you must JOIN all four tables back together.
- Save src/database.ts.
Now wire the database into your server so the tables are created on startup.
- In src/index.ts, find this line at the top:
import express from "express";
- Replace it with these two imports:
import express from "express";
import { initDatabase } from "./database";
- Then find this line:
app.use(express.json());
- Add the initDatabase() call directly below it, so the two lines look like this:
app.use(express.json());
initDatabase();
- Save src/index.ts and restart your server by running:
npx tsx src/index.ts
- Confirm an orders.db file appeared in your project folder by running this in a second terminal:
ls orders.db
You should see orders.db listed. The database file was created when initDatabase() ran on server startup.
Don't see orders.db?
- Make sure you saved both src/database.ts and src/index.ts before restarting the server.
- Check your terminal for import errors. A typo in the import path (./database vs ./Database) will crash the server on startup.
- Run ls from the cqrs-orders directory, not a subdirectory.
Help me debug why orders.db isn't being created
Build CRUD routes using the single shared model
Now you'll build two endpoints that read and write order data. Both operate on the same normalized tables. The GET route needs to JOIN all four tables together just to assemble a useful response.
- Create a new folder src/routes inside your project.
- Create src/routes/orders.ts and add the GET endpoint with its multi-JOIN query:
import { Router } from "express";
import { getDb } from "../database";
export const orderRoutes = Router();
// Naive approach: JOIN all tables to assemble order data
orderRoutes.get("/orders", (req, res) => {
const db = getDb();
const orders = db.prepare(`
SELECT
o.id AS order_id,
c.name AS customer_name,
SUM(oi.quantity * oi.unit_price) AS total_amount,
COUNT(oi.id) AS item_count,
o.status,
o.created_at
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
GROUP BY o.id
ORDER BY o.created_at DESC
`).all();
res.json({ orders });
});
What does this query do?
To return each order with its customer name and total, the query must JOIN four tables and GROUP BY order ID to aggregate line items. This is three JOINs and a GROUP BY for what is essentially a list view.
The more data you need in the response (item names, most recent product, customer email), the more JOINs and subqueries you pile on. This is the pain that builds up in a single-model approach.
- Below the GET route, add the POST endpoint that inserts into multiple tables:
// Naive approach: inline insert across multiple tables
orderRoutes.post("/orders", (req, res) => {
const db = getDb();
const { customerId, items } = req.body;
if (!customerId || !items || items.length === 0) {
res.status(400).json({ error: "customerId and items required" });
return;
}
const orderInfo = db.prepare(
"INSERT INTO orders (customer_id, status) VALUES (?, 'pending')"
).run(customerId);
const orderId = Number(orderInfo.lastInsertRowid);
const insertItem = db.prepare(
"INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES (?, ?, ?, ?)"
);
for (const item of items) {
const product = db.prepare(
"SELECT price FROM products WHERE id = ?"
).get(item.productId) as { price: number } | undefined;
if (product) {
insertItem.run(orderId, item.productId, item.quantity, product.price);
}
}
res.status(201).json({ orderId, status: "pending" });
});
What does the POST handler do?
The POST handler inserts into two tables: first into orders to create the order record, then loops through items and inserts each into order_items. It looks up each product's price to store it at the time of purchase.
Notice how the read and write logic both live in the same file, using the same tables. Neither job is done particularly well. The write has no real validation, and the read requires complex SQL gymnastics.
- Save src/routes/orders.ts.
- Now register these routes in your server. In src/index.ts, find the line:
import { initDatabase } from "./database";
- Add this import directly below it:
import { orderRoutes } from "./routes/orders";
- Then find the app.get("/health", ...) route block. Add this line below it:
app.use(orderRoutes);
- Save src/index.ts and restart your server:
npx tsx src/index.ts
The server starts, but the database has no data yet. You'll fix that next.
Seeing an import error on startup?
- Check that the file path is ./routes/orders (no file extension, relative to src/).
- Confirm src/routes/orders.ts exports orderRoutes (not a default export).
Help me fix my import errors in index.ts
Seed the database and test the endpoints
The tables exist but are empty. You need sample data to see the multi-JOIN query in action. You'll create a seed script that populates customers, products, and a batch of orders with multiple items each.
- Create src/seed.ts and add the setup with prepared statements:
import { initDatabase, getDb } from "./database";
initDatabase();
const db = getDb();
// Prepared statements for batch inserts
const insertCustomer = db.prepare(
"INSERT OR IGNORE INTO customers (name, email) VALUES (?, ?)"
);
const insertProduct = db.prepare(
"INSERT OR IGNORE INTO products (name, price) VALUES (?, ?)"
);
const insertOrder = db.prepare(
"INSERT INTO orders (customer_id, status, created_at) VALUES (?, ?, datetime('now', ?))"
);
const insertOrderItem = db.prepare(
"INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES (?, ?, ?, ?)"
);
- Below the prepared statements, add the sample data arrays:
const customers = [
["Alice Johnson", "alice@example.com"],
["Bob Smith", "bob@example.com"],
["Carol White", "carol@example.com"],
["Dave Brown", "dave@example.com"],
["Eve Davis", "eve@example.com"],
];
const products = [
["Mechanical Keyboard", 89.99],
["USB-C Hub", 45.0],
["Monitor Stand", 34.99],
["Webcam HD", 65.0],
["Desk Lamp", 28.5],
["Mouse Pad XL", 19.99],
["Cable Organizer", 12.0],
["Laptop Stand", 55.0],
["Blue Light Glasses", 25.0],
["Wireless Charger", 32.0],
];
- Finally, add the transaction that generates 20 orders with varying items:
const seed = db.transaction(() => {
for (const [name, email] of customers) {
insertCustomer.run(name, email);
}
for (const [name, price] of products) {
insertProduct.run(name, price);
}
const statuses = ["pending", "shipped", "delivered"];
for (let i = 0; i < 20; i++) {
const customerId = (i % 5) + 1;
const status = statuses[i % 3];
const daysAgo = `-${20 - i} days`;
const info = insertOrder.run(customerId, status, daysAgo);
const orderId = info.lastInsertRowid;
const itemCount = (i % 3) + 1;
for (let j = 0; j < itemCount; j++) {
const productId = ((i + j) % 10) + 1;
const quantity = (j % 3) + 1;
const product = products[productId - 1];
insertOrderItem.run(orderId, productId, quantity, product[1]);
}
}
});
seed();
console.log("Database seeded with sample data.");
What does the seed script do?
It wraps all inserts in a single transaction for speed and atomicity. It creates 5 customers, 10 products, and 20 orders. Each order gets 1-3 items with different products and quantities, simulating realistic e-commerce data.
The INSERT OR IGNORE on customers and products means you can safely re-run the seed without duplicate errors on those tables.
- Save src/seed.ts.
- Stop your running server (Ctrl+C) and run the seed script:
npx tsx src/seed.ts
You should see this output:
Database seeded with sample data.
- Now restart the server:
npx tsx src/index.ts
Before you test, think about what the response should look like. You have 20 orders spread across 5 customers, each with computed totals from their line items.
- What do you expect to see when you hit GET /orders? Will it be a flat list or nested objects? Test it by running this in a second terminal:
curl http://localhost:3000/orders
You'll see a JSON array of 20 order objects, each with a customer name, computed total, item count, status, and timestamp. All of that data was assembled on the fly from a four-table JOIN with a GROUP BY.
Why does this matter?
Every time a client requests this list, the database re-runs that entire JOIN and aggregation. The SQL is already complex for a simple list view. Imagine adding filters, sorting, or additional fields like "most recent item name." Each addition makes the query harder to read, maintain, and optimize.
This is the fundamental tension of the single-model approach: the shape of the data at rest (normalized, split across tables) does not match the shape the consumer needs (flat, pre-computed). You'll feel this pressure intensify in the next step.
Getting an empty array or an error?
- If you see {"orders":[]}, the seed script may not have run successfully. Re-run npx tsx src/seed.ts and look for the confirmation message.
- If you see a SQL error about a missing table, make sure initDatabase() is called in both src/index.ts and at the top of src/seed.ts.
- If curl returns a connection error, confirm the server is running in another terminal.
Help me debug why my GET /orders endpoint isn't returning data
✔️ Awesome, I've got everything!
Great. Double check you've saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is src/index.ts at this point:
import express from "express";
import { initDatabase } from "./database";
import { orderRoutes } from "./routes/orders";
const app = express();
app.use(express.json());
initDatabase();
app.get("/health", (req, res) => {
res.json({ status: "ok" });
});
app.use(orderRoutes);
app.listen(3000, () => {
console.log("Server running on http://localhost:3000");
});
For src/database.ts and src/seed.ts, scroll up and confirm each chunk is present in the correct order. For src/routes/orders.ts, confirm you have the GET /orders route with the four-table JOIN followed by the POST /orders route with multi-table inserts.
Your API works, but look at what it took. A simple order list required four JOINs, a GROUP BY, and aggregation functions. Next, you'll add a dashboard endpoint that pushes this approach to its breaking point.
Hit the Dashboard Wall
Your API can fetch and create orders. But a real frontend dashboard needs data shaped completely differently from how it is stored.
In this step, you will build a dashboard endpoint that pushes the naive single-model approach to its breaking point. The query will join all four tables, compute aggregates, and reshape data on every single request. You will see the complexity pile up firsthand.
In this step, get ready to:
- Create a dashboard endpoint that assembles flat order summaries from normalized tables.
- Add dynamic filtering and watch the query complexity compound.
Add the dashboard endpoint
A dashboard wants a flat summary for each order: the customer name, total amount, item count, and status. None of that exists in a single table. You have to join orders, customers, and order_items together and compute aggregates on every request.
- Create the file src/routes/dashboard.ts by pasting the following code:
import { Router } from "express";
import { getDb } from "../database";
export const dashboardRoutes = Router();
dashboardRoutes.get("/dashboard/orders", (req, res) => {
const db = getDb();
const start = performance.now();
const sql = `
SELECT
o.id AS order_id,
c.name AS customer_name,
SUM(oi.quantity * oi.unit_price) AS total_amount,
COUNT(oi.id) AS item_count,
o.status,
o.created_at
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id
ORDER BY o.created_at DESC
`;
const rows = db.prepare(sql).all();
// Include timing so we can see how long this monster query takes
const queryTimeMs = Number((performance.now() - start).toFixed(3));
res.json({ orders: rows, queryTimeMs });
});
What does this code do?
This single query joins three tables to reconstruct what the dashboard needs: a flat summary per order. It computes the total amount and item count with aggregate functions, then groups by order.
The performance.now() timing wraps the entire query so you can measure how long this approach takes. That number shows up in the response as queryTimeMs.
Now you need to register this route in your server entry point.
- In src/index.ts, find this line near the top of the file:
import { orderRoutes } from "./routes/orders";
- Add this import directly below it:
import { dashboardRoutes } from "./routes/dashboard";
- Then find this line further down:
app.use(orderRoutes);
- Add this line directly below it:
app.use(dashboardRoutes);
- Save both src/routes/dashboard.ts and src/index.ts.
- Restart your server by pressing Ctrl+C in the terminal, then running:
npx tsx src/index.ts
- Test the dashboard endpoint by running this in a second terminal:
curl http://localhost:3000/dashboard/orders
You should see a JSON response with an orders array (each entry has order_id, customer_name, total_amount, item_count) and a queryTimeMs field at the bottom.
Getting an empty orders array?
Make sure you seeded the database in the previous step. Run npx tsx src/seed.ts again if needed. The seed script uses INSERT OR IGNORE for customers and products, so running it twice is safe.
help me debug my empty dashboard response
Add filtering and observe the compounding complexity
A real dashboard lets users filter by status or search by customer name. Adding those filters to an already complex JOIN query makes the code even harder to read and maintain.
- Replace the entire contents of src/routes/dashboard.ts with this updated version that supports query parameters:
import { Router } from "express";
import { getDb } from "../database";
export const dashboardRoutes = Router();
dashboardRoutes.get("/dashboard/orders", (req, res) => {
const db = getDb();
const start = performance.now();
const { status, customer } = req.query;
// Build the query dynamically based on filters
let sql = `
SELECT
o.id AS order_id,
c.name AS customer_name,
SUM(oi.quantity * oi.unit_price) AS total_amount,
COUNT(oi.id) AS item_count,
o.status,
o.created_at
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
WHERE 1=1
`;
const params: any[] = [];
if (status) { sql += " AND o.status = ?"; params.push(status); }
if (customer) { sql += " AND c.name LIKE ?"; params.push(`%${customer}%`); }
sql += " GROUP BY o.id ORDER BY o.created_at DESC";
const rows = db.prepare(sql).all(...params);
const queryTimeMs = Number((performance.now() - start).toFixed(3));
res.json({ orders: rows, queryTimeMs });
});
What changed here?
The query string changed from const to let because you now concatenate WHERE clauses onto it. The WHERE 1=1 trick lets you always append AND ... without worrying about whether it is the first filter or not.
Look at what a single dashboard endpoint now does: three-table JOIN, aggregate functions, dynamic string concatenation, parameter tracking, grouping, and sorting. Every new filter makes this harder to read and easier to break.
- Save src/routes/dashboard.ts and restart your server.
- Test the status filter by running:
curl "http://localhost:3000/dashboard/orders?status=shipped"
You should see only orders with "status": "shipped" in the response.
- Test the customer filter by running:
curl "http://localhost:3000/dashboard/orders?customer=Alice"
You should see only orders where customer_name contains "Alice".
Filters returning all orders instead of a subset?
Make sure you wrapped the URL in quotes in your terminal. Without quotes, the ? character can be interpreted by your shell instead of being sent as part of the URL.
help me fix my dashboard filters
Before you run the final check, think about this: the query joins three tables, computes two aggregates, handles dynamic WHERE clauses, groups rows, and sorts the result. What do you expect the queryTimeMs value to look like with 20 orders?
- Run the unfiltered dashboard request one more time to see the timing:
curl http://localhost:3000/dashboard/orders
The queryTimeMs field in the response shows how long this multi-JOIN query takes. With only 20 orders it is fast. Imagine thousands of orders, or adding more filters, or needing a different shape for a mobile app. This single query serves both the write path and the read path, and neither job is done well. That is the wall you just hit.
Why is this a problem?
The dashboard query reconstructs data that was scattered across normalized tables during writes. Every request pays the cost of reassembling it. The read path is shackled to the write path's schema decisions.
In the next step, you will fix this by maintaining a pre-computed read model that is always shaped exactly like the API response. Reads become trivial. That is the core idea behind CQRS.
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/routes/dashboard.ts and src/index.ts.
ⓧ I'd like to double check the full code
Here is the full src/routes/dashboard.ts:
import { Router } from "express";
import { getDb } from "../database";
export const dashboardRoutes = Router();
dashboardRoutes.get("/dashboard/orders", (req, res) => {
const db = getDb();
const start = performance.now();
const { status, customer } = req.query;
let sql = `
SELECT
o.id AS order_id,
c.name AS customer_name,
SUM(oi.quantity * oi.unit_price) AS total_amount,
COUNT(oi.id) AS item_count,
o.status,
o.created_at
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
WHERE 1=1
`;
const params: any[] = [];
if (status) { sql += " AND o.status = ?"; params.push(status); }
if (customer) { sql += " AND c.name LIKE ?"; params.push(`%${customer}%`); }
sql += " GROUP BY o.id ORDER BY o.created_at DESC";
const rows = db.prepare(sql).all(...params);
const queryTimeMs = Number((performance.now() - start).toFixed(3));
res.json({ orders: rows, queryTimeMs });
});
Here is the full src/index.ts:
import express from "express";
import { initDatabase } from "./database";
import { orderRoutes } from "./routes/orders";
import { dashboardRoutes } from "./routes/dashboard";
const app = express();
app.use(express.json());
initDatabase();
app.get("/health", (req, res) => {
res.json({ status: "ok" });
});
app.use(orderRoutes);
app.use(dashboardRoutes);
app.listen(3000, () => {
console.log("Server running on http://localhost:3000");
});
You have built the dashboard the "normal" way and felt the complexity. The query works, but it is fragile, hard to extend, and does expensive computation on every request. Next up, you will introduce a denormalized read model that collapses this entire query into a single flat table lookup.
Introduce the CQRS Read Model
Your dashboard endpoint works, but its query is complex, fragile, and slow. The read side is doing expensive work on every request to reconstruct data that the write side scattered across four normalized tables.
CQRS fixes this by maintaining a separate denormalized read model. Instead of computing JOINs at query time, you pre-compute a flat table shaped exactly like the API response. In this step, you will create that read model and watch the query time drop.
In this step, get ready to:
- Create a denormalized order_summaries table shaped for the dashboard response.
- Build a sync function and backfill existing orders into the read model.
- Replace the complex JOIN query with a simple SELECT from the flat table.
Create the read model table
The read model is a table whose columns match the dashboard response exactly. Every field a frontend needs is already computed and stored, so queries are trivial.
- In src/database.ts, find the closing }); of the order_items table creation.
- Add the following table definition directly below it, inside the same exec template string:
CREATE TABLE IF NOT EXISTS order_summaries (
order_id INTEGER PRIMARY KEY,
customer_name TEXT NOT NULL,
total_amount REAL NOT NULL,
item_count INTEGER NOT NULL,
latest_item_name TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL
);
What does this table do?
Each row in order_summaries stores pre-computed values that previously required joining four tables. The dashboard can now read this table directly without any JOINs or aggregation.
Notice the column names match what the dashboard API returns: customer_name, total_amount, item_count. The table is shaped for the query, not for storage normalization.
- Save src/database.ts.
- Restart your server to confirm there are no syntax errors:
npx tsx src/index.ts
You should see Server running on http://localhost:3000 with no errors.
Seeing a syntax error?
Make sure you placed the new CREATE TABLE block inside the backtick template string, after the order_items table. A misplaced backtick or missing semicolon after the closing parenthesis will break the SQL.
help me fix a syntax error in my database.ts file
Build the sync function
The read model table is empty. You need a function that computes a summary for a given order and writes it to order_summaries.
- Create a new folder src/read-model/ and a file sync.ts inside it.
- Add the following code that queries normalized tables for a single order:
import { getDb } from "../database";
export function syncOrderSummary(orderId: number): void {
const db = getDb();
// Compute the summary by joining normalized tables for one order
const summary = db
.prepare(`
SELECT
o.id AS order_id,
c.name AS customer_name,
SUM(oi.quantity * oi.unit_price) AS total_amount,
COUNT(oi.id) AS item_count,
(SELECT p.name FROM order_items oi2
JOIN products p ON p.id = oi2.product_id
WHERE oi2.order_id = o.id
ORDER BY oi2.id DESC LIMIT 1) AS latest_item_name,
o.status,
o.created_at
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
WHERE o.id = ?
GROUP BY o.id
`)
.get(orderId) as Record<string, any> | undefined;
if (!summary) return;
What does this query compute?
- It joins orders, customers, and order_items for a single order ID.
- It computes total_amount and item_count as aggregates.
- The subquery finds the most recently added product name for latest_item_name.
- This expensive JOIN happens once at write time, not on every dashboard read.
- Directly below the if (!summary) return; line, add the upsert that persists the computed summary:
// Upsert the pre-computed summary into the read model
db.prepare(`
INSERT OR REPLACE INTO order_summaries
(order_id, customer_name, total_amount, item_count, latest_item_name, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
summary.order_id,
summary.customer_name,
summary.total_amount,
summary.item_count,
summary.latest_item_name,
summary.status,
summary.created_at
);
}
Why INSERT OR REPLACE?
If the summary for this order already exists, INSERT OR REPLACE overwrites it with fresh data. This makes the function idempotent. You can call it repeatedly and always get a correct, up-to-date summary.
- Save src/read-model/sync.ts.
Backfill existing orders and verify the read model
You already have 20 seeded orders in the normalized tables. A backfill script will iterate them and populate order_summaries so the read model starts with data.
- Create src/read-model/backfill.ts with the following code:
import { initDatabase, getDb } from "../database";
import { syncOrderSummary } from "./sync";
initDatabase();
const db = getDb();
// Fetch all order IDs from the write model
const orders = db.prepare("SELECT id FROM orders").all() as Array<{ id: number }>;
// Run all syncs in a single transaction for speed
const backfill = db.transaction(() => {
for (const order of orders) {
syncOrderSummary(order.id);
}
});
backfill();
console.log(`Backfilled ${orders.length} order summaries.`);
What does this script do?
- It loads every order ID from the orders table.
- It wraps all sync calls in a transaction so the entire backfill is atomic and fast.
- After this runs, order_summaries will have one row for each existing order, fully computed.
- Save src/read-model/backfill.ts.
- Stop your server (Ctrl+C) and run the backfill:
npx tsx src/read-model/backfill.ts
You should see Backfilled 20 order summaries. printed in the terminal.
Seeing "table order_summaries has no column named latest_item_name"?
Your database file was created before you added the new table. Delete orders.db from your project folder, then re-seed and re-backfill:
rm orders.db
npx tsx src/seed.ts
npx tsx src/read-model/backfill.ts
help me fix my backfill script
Replace the dashboard query with the read model
The read model is populated. Now you will swap out the complex JOIN query in the dashboard route for a simple SELECT against order_summaries.
- Create a new file src/queries/getOrderDashboard.ts with the query handler that reads from the flat table:
import { getDb } from "../database";
// Defines the shape of optional filters
interface GetOrderDashboardQuery {
status?: string;
customer?: string;
}
export function getOrderDashboardHandler(query: GetOrderDashboardQuery) {
const db = getDb();
const start = performance.now();
// Simple SELECT from the denormalized read model
let sql = "SELECT * FROM order_summaries WHERE 1=1";
const params: string[] = [];
if (query.status) {
sql += " AND status = ?";
params.push(query.status);
}
if (query.customer) {
sql += " AND customer_name LIKE ?";
params.push(`%${query.customer}%`);
}
sql += " ORDER BY created_at DESC";
- Directly below the sql += " ORDER BY created_at DESC"; line, add the execution and return:
// Execute and return with timing info
const rows = db.prepare(sql).all(...params);
const queryTimeMs = performance.now() - start;
return {
orders: rows,
queryTimeMs: Number(queryTimeMs.toFixed(3)),
source: "read_model",
};
}
What changed from the old query?
The old dashboard handler joined four tables and ran GROUP BY with aggregates. This handler runs SELECT * FROM order_summaries with optional WHERE clauses. Zero JOINs, zero aggregation, zero reshaping.
The source: "read_model" field in the response makes it clear which path served the data.
- Save src/queries/getOrderDashboard.ts.
- Now replace the contents of src/routes/dashboard.ts with this slim route that delegates to the query handler:
import { Router } from "express";
import { getOrderDashboardHandler } from "../queries/getOrderDashboard";
export const dashboardRoutes = Router();
dashboardRoutes.get("/dashboard/orders", (req, res) => {
// Parse optional filters from query string
const filters = {
status: req.query.status as string | undefined,
customer: req.query.customer as string | undefined,
};
// Delegate to the query handler
const result = getOrderDashboardHandler(filters);
res.json(result);
});
Why is this route so thin?
The route only parses the HTTP request and returns the response. All data-access logic lives in the query handler. This separation is the Q in CQRS. Query handlers know nothing about HTTP, and routes know nothing about SQL.
- Save src/routes/dashboard.ts.
Before you restart the server, what do you think queryTimeMs will look like now compared to the multi-JOIN version?
- Start the server and test the dashboard endpoint:
npx tsx src/index.ts
curl http://localhost:3000/dashboard/orders
You will see a JSON response with your order summaries, a queryTimeMs value that is dramatically lower than before, and "source": "read_model" confirming the new path.
Getting an import error for getOrderDashboard?
Make sure the src/queries/ folder exists and contains getOrderDashboard.ts. Check that the import path in dashboard.ts is "../queries/getOrderDashboard" (one level up from routes/).
help me fix my dashboard route import
✔️ Awesome, I've got everything!
Great. Double check you have saved all files: src/database.ts, src/read-model/sync.ts, src/read-model/backfill.ts, src/queries/getOrderDashboard.ts, and src/routes/dashboard.ts.
ⓧ I'd like to double check the full code
Scroll up and confirm these chunks are present in your project, in order:
- src/database.ts has the order_summaries CREATE TABLE block after order_items.
- src/read-model/sync.ts has the SELECT query chunk followed by the INSERT OR REPLACE chunk.
- src/queries/getOrderDashboard.ts has the filter-building chunk followed by the execute-and-return chunk.
Here is the complete src/read-model/backfill.ts for comparison:
import { initDatabase, getDb } from "../database";
import { syncOrderSummary } from "./sync";
initDatabase();
const db = getDb();
const orders = db.prepare("SELECT id FROM orders").all() as Array<{ id: number }>;
const backfill = db.transaction(() => {
for (const order of orders) {
syncOrderSummary(order.id);
}
});
backfill();
console.log(`Backfilled ${orders.length} order summaries.`);
Here is the complete src/routes/dashboard.ts for comparison:
import { Router } from "express";
import { getOrderDashboardHandler } from "../queries/getOrderDashboard";
export const dashboardRoutes = Router();
dashboardRoutes.get("/dashboard/orders", (req, res) => {
const filters = {
status: req.query.status as string | undefined,
customer: req.query.customer as string | undefined,
};
const result = getOrderDashboardHandler(filters);
res.json(result);
});
Your dashboard now reads from a single flat table with zero JOINs. Next up, you will restructure the write side into explicit command handlers that keep this read model in sync every time an order is placed.
Wire Up Commands and Queries
Your read model delivers fast, flat queries. But the write side still uses naive inline SQL that never updates the read model when new orders come in.
This step restructures both sides into explicit handlers. Command handlers own the writes and keep the read model in sync inside a single transaction. Query handlers own the reads, pulling exclusively from the denormalized table. Routes become thin wrappers that parse requests and delegate.
In this step, get ready to:
- Create a command handler that validates, writes to normalized tables, and syncs the read model in one transaction.
- Create a query handler that reads exclusively from the denormalized read model.
- Wire up routes to handlers and add a performance comparison endpoint.
Create the command handler
A command handler is a single function responsible for validating input, performing the write, and keeping the read model in sync. Wrapping all of this in one database transaction guarantees the read model never drifts from the write tables.
- Create a new folder src/commands and a file inside it called placeOrder.ts.
- Add the following code to set up the command type, validation, and the start of the transaction:
import { getDb } from "../database";
import { syncOrderSummary } from "../read-model/sync";
interface PlaceOrderCommand {
customerId: number;
items: Array<{ productId: number; quantity: number }>;
}
export function placeOrderHandler(command: PlaceOrderCommand) {
const db = getDb();
if (!command.customerId || !command.items || command.items.length === 0) {
throw new Error("customerId and at least one item are required");
}
const execute = db.transaction(() => {
const customer = db
.prepare("SELECT id FROM customers WHERE id = ?")
.get(command.customerId) as { id: number } | undefined;
if (!customer) {
throw new Error(`Customer ${command.customerId} not found`);
}
What does this code do?
- PlaceOrderCommand defines the exact shape a write request must have: a customer ID and an array of items.
- The handler validates the command before touching the database. If the input is malformed, it throws immediately.
- db.transaction() wraps everything in a single atomic operation. If any part fails, the entire write rolls back.
- The first thing inside the transaction is verifying the customer exists. No orphaned orders allowed.
- Add the following code directly below what you just typed (still inside the transaction):
const orderInfo = db
.prepare("INSERT INTO orders (customer_id, status) VALUES (?, 'pending')")
.run(command.customerId);
const orderId = Number(orderInfo.lastInsertRowid);
const insertItem = db.prepare(
"INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES (?, ?, ?, ?)"
);
for (const item of command.items) {
const product = db
.prepare("SELECT id, price FROM products WHERE id = ?")
.get(item.productId) as { id: number; price: number } | undefined;
if (!product) {
throw new Error(`Product ${item.productId} not found`);
}
insertItem.run(orderId, item.productId, item.quantity, product.price);
}
syncOrderSummary(orderId);
return { orderId, status: "pending" };
});
return execute();
}
What does this second half do?
- It inserts a new row into the orders table and grabs the auto-generated ID.
- For each item, it verifies the product exists (using the real price from the database, not the client) and inserts into order_items.
- syncOrderSummary(orderId) recomputes and upserts the denormalized row. Because it runs inside the same transaction, the read model is always consistent with the write tables.
- Save src/commands/placeOrder.ts.
- Now update src/routes/orders.ts to delegate to this handler. Replace the entire contents of the file with:
import { Router } from "express";
import { placeOrderHandler } from "../commands/placeOrder";
export const orderRoutes = Router();
orderRoutes.post("/orders", (req, res) => {
try {
const result = placeOrderHandler(req.body);
res.status(201).json(result);
} catch (error: any) {
res.status(400).json({ error: error.message });
}
});
Why did the GET route disappear?
In CQRS, reads go through dedicated query handlers, not through the same route file as writes. The naive GET /orders with its multi-JOIN query is gone. The dashboard endpoint is your proper read path now.
The route itself is now thin. It parses the request body, calls the handler, and returns the result. All logic lives in the command handler.
- Save src/routes/orders.ts.
- Restart your server by pressing Ctrl+C in the terminal, then running:
npx tsx src/index.ts
- Test the command handler by posting a new order:
curl -X POST http://localhost:3000/orders -H "Content-Type: application/json" -d '{"customerId": 1, "items": [{"productId": 2, "quantity": 3}]}'
You should see a JSON response with an orderId and status: "pending".
Seeing an error instead?
- If you see Cannot find module, check that your file is saved at exactly src/commands/placeOrder.ts (not src/command/ without the s).
- If you get a 400 response about missing fields, make sure your curl command includes the -H "Content-Type: application/json" header.
- If the customer or product is not found, confirm you ran the seed script earlier (npx tsx src/seed.ts).
Why is my POST /orders command failing?
Create the query handler
The query handler is the mirror image of the command handler. It reads exclusively from the denormalized order_summaries table. Zero JOINs, zero business logic, zero reshaping.
- Create a new folder src/queries and a file inside it called getOrderDashboard.ts.
- Add the import and interface that defines the query shape:
import { getDb } from "../database";
interface GetOrderDashboardQuery {
status?: string;
customer?: string;
}
- Add the handler function below the interface:
export function getOrderDashboardHandler(query: GetOrderDashboardQuery) {
const db = getDb();
const start = performance.now();
let sql = "SELECT * FROM order_summaries WHERE 1=1";
const params: string[] = [];
if (query.status) {
sql += " AND status = ?";
params.push(query.status);
}
if (query.customer) {
sql += " AND customer_name LIKE ?";
params.push(`%${query.customer}%`);
}
sql += " ORDER BY created_at DESC";
const rows = db.prepare(sql).all(...params);
const queryTimeMs = performance.now() - start;
return {
orders: rows,
queryTimeMs: Number(queryTimeMs.toFixed(3)),
source: "read_model",
};
}
What makes this a proper query handler?
- It reads from a single table (order_summaries) with no JOINs.
- Filters are optional and additive. The WHERE 1=1 trick makes it easy to append AND clauses conditionally.
- It includes queryTimeMs in the response so you can see how fast the read model is compared to the old JOIN approach.
- The handler contains zero business logic. It just selects and returns.
- Save src/queries/getOrderDashboard.ts.
- Restart your server (Ctrl+C, then npx tsx src/index.ts).
- Verify the order you just created appears in the dashboard by running:
curl http://localhost:3000/dashboard/orders
You should see an array of order summaries including the one you just posted (customer ID 1, with a USB-C Hub item). The source field reads read_model confirming it came from the denormalized table.
New order not appearing in dashboard?
- Make sure the syncOrderSummary(orderId) call is present inside the transaction in placeOrder.ts. Without it, the write tables update but the read model stays stale.
- If you see zero orders, run the backfill again: npx tsx src/read-model/backfill.ts.
Why is my new order missing from the dashboard response?
Add the comparison endpoint
The final piece is an endpoint that runs both the old JOIN query and the new read-model query side by side. This lets you see the performance difference in one response.
- Create a new file src/routes/compare.ts and add the route setup and the JOIN-based query timing:
import { Router } from "express";
import { getDb } from "../database";
export const compareRoutes = Router();
compareRoutes.get("/compare", (req, res) => {
const db = getDb();
const startJoin = performance.now();
db.prepare(`
SELECT
o.id AS order_id,
c.name AS customer_name,
SUM(oi.quantity * oi.unit_price) AS total_amount,
COUNT(oi.id) AS item_count,
o.status,
o.created_at
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id
ORDER BY o.created_at DESC
`).all();
const joinTimeMs = performance.now() - startJoin;
- Add the read-model query and the response directly below:
const startReadModel = performance.now();
db.prepare(`
SELECT * FROM order_summaries
ORDER BY created_at DESC
`).all();
const readModelTimeMs = performance.now() - startReadModel;
res.json({
joinQuery: { timeMs: Number(joinTimeMs.toFixed(3)) },
readModelQuery: { timeMs: Number(readModelTimeMs.toFixed(3)) },
speedup: `${(joinTimeMs / readModelTimeMs).toFixed(1)}x faster with read model`,
});
});
What does this endpoint prove?
It runs the exact same data retrieval two ways. The JOIN query reconstructs order summaries from four normalized tables. The read-model query fetches pre-computed rows from one flat table. Both return the same data, but the timing difference shows why denormalization matters.
- Save src/routes/compare.ts.
- Now register the new route in src/index.ts. Find the import block at the top of the file:
import { orderRoutes } from "./routes/orders";
import { dashboardRoutes } from "./routes/dashboard";
- Add the compare import directly below the dashboard import, so the imports look like this:
import { orderRoutes } from "./routes/orders";
import { dashboardRoutes } from "./routes/dashboard";
import { compareRoutes } from "./routes/compare";
- Find the app.use lines further down:
app.use(orderRoutes);
app.use(dashboardRoutes);
- Add the compare route below them:
app.use(orderRoutes);
app.use(dashboardRoutes);
app.use(compareRoutes);
- Save src/index.ts.
- Restart your server (Ctrl+C, then npx tsx src/index.ts).
Before you run the comparison, what do you expect the speed difference to be between a multi-JOIN query and a single-table read?
- Test the comparison endpoint by running:
curl http://localhost:3000/compare
You should see a JSON response with joinQuery.timeMs, readModelQuery.timeMs, and a speedup field showing how many times faster the read model is.
Seeing a route not found error?
- Check that you added both the import line and the app.use(compareRoutes) line in src/index.ts. Missing either one means the route is never registered.
- Make sure you restarted the server after saving. Changes to index.ts require a restart.
Why is my /compare endpoint not working?
Now for the final end-to-end test. Do you think a newly placed order will appear immediately in the dashboard response, or will there be a delay?
- Place a fresh order and immediately query the dashboard:
curl -X POST http://localhost:3000/orders -H "Content-Type: application/json" -d '{"customerId": 3, "items": [{"productId": 5, "quantity": 2}, {"productId": 1, "quantity": 1}]}'
curl http://localhost:3000/dashboard/orders?customer=Carol
The dashboard response includes the order you just placed, with the correct total, item count, and customer name. There is no delay because the command handler syncs the read model inside the same transaction as the write.
Why is this instant consistency important?
Many CQRS implementations use eventual consistency (writes publish events, a separate process updates the read model later). This project uses synchronous sync inside the transaction. The tradeoff is simplicity: you get immediate consistency without message queues or event buses. The cost is that writes are slightly slower because they do extra work.
For most applications, this synchronous approach is the right starting point. You only need eventual consistency when write throughput becomes a bottleneck.
✔️ Awesome, I've got everything!
Great. Double check you have saved all files before moving on.
ⓧ I'd like to double check the full code
Here is src/routes/orders.ts in full:
import { Router } from "express";
import { placeOrderHandler } from "../commands/placeOrder";
export const orderRoutes = Router();
orderRoutes.post("/orders", (req, res) => {
try {
const result = placeOrderHandler(req.body);
res.status(201).json(result);
} catch (error: any) {
res.status(400).json({ error: error.message });
}
});
Here is src/index.ts in full:
import express from "express";
import { initDatabase } from "./database";
import { orderRoutes } from "./routes/orders";
import { dashboardRoutes } from "./routes/dashboard";
import { compareRoutes } from "./routes/compare";
const app = express();
app.use(express.json());
initDatabase();
app.get("/health", (req, res) => {
res.json({ status: "ok" });
});
app.use(orderRoutes);
app.use(dashboardRoutes);
app.use(compareRoutes);
app.listen(3000, () => {
console.log("Server running on http://localhost:3000");
});
For the longer files, scroll up and confirm each chunk is present in order:
- src/commands/placeOrder.ts: chunk 1 (imports, interface, validation, customer check) followed by chunk 2 (order insert, item loop, syncOrderSummary, return).
- src/queries/getOrderDashboard.ts: chunk 1 (import, interface) followed by chunk 2 (getOrderDashboardHandler function with dynamic SQL, timing, and return).
- src/routes/compare.ts: chunk 1 (imports, router, JOIN query timing) followed by chunk 2 (read-model query timing, JSON response).
Your API now has a clean CQRS structure. Commands write to normalized tables and sync the read model. Queries pull from a single flat table. Next up, you'll push this further in the Secret Mission by adding a completely new read model without touching the command side at all.
Secret mission
Add a Product Analytics Read Model
You built one read model for the order dashboard. But what if a product manager wants to know which products sell the most? The real power of CQRS shows up when you add a second read model shaped for a completely different consumer, without touching a single line of command code.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files for further experimentation or delete them entirely.
Resources you used:
- The cqrs-orders project folder on your Desktop (source code, dependencies, and database).
- The orders.db SQLite database file inside the project folder.
Keep everything running
No action needed. Choose this if you want to keep experimenting with CQRS patterns, adding new read models, or extending the API.
- Your project folder stays on your Desktop at ~/Desktop/cqrs-orders.
- The orders.db file takes minimal disk space and costs nothing.
- To restart the server later, open a terminal in the project folder and run npx tsx src/index.ts.
Pause - I'll come back to this later
Stop the running server to free up the terminal. Your files stay on disk for when you return.
- If your server is still running, press Ctrl+C in the terminal to stop it.
- Everything else stays in place. No further action needed.
Delete - I don't want to use this again
Remove all project files and start fresh. This deletes your source code, dependencies, and the database.
- If your server is still running, press Ctrl+C in the terminal to stop it.
- Delete the project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/cqrs-orders
On Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\cqrs-orders
- Confirm deletion by running ls ~/Desktop (macOS/Linux) or dir %USERPROFILE%\Desktop (Windows). The cqrs-orders folder should no longer appear.
That's a Wrap!
That's a Wrap!
Nice work. You just built a fully functional CQRS order management API that separates reads from writes into distinct, purpose-built paths.
You've learned how to:
- Introduce a denormalized read model that collapsed a complex multi-JOIN dashboard query into a single flat table lookup with zero JOINs.
- Structure an API into explicit command handlers for validated writes and query handlers for optimized reads, keeping routes thin and responsibilities clear.
- Keep read models in sync with writes using transactional synchronous updates that guarantee consistency without eventual-consistency complexity.
- Secret Mission: Created a product analytics read model that demonstrates how CQRS lets you add new query perspectives without touching any existing command code.
Ready to quiz yourself?