Architecture with ZudoJS Advanced
From monolith to modular monolith
Split one growing application into modules with clear boundaries. Each module has a small public API, owns its data, and talks to the others through that API, events and commands, all inside one deployable app.
What a monolith is
The Task API you built is a monolith: one program, one codebase, deployed as one unit. Every feature runs in the same process and can call any other feature directly.
That is a good thing. A monolith is simple to run, simple to debug, and a function call between two features costs nothing. Most successful products started as one.
The trouble starts when the code grows and nothing stops one part from reaching into another. Here is a tiny shop written that way. The orders code changes the catalog's data directly:
const products = new Map([["mug", { name: "Mug", price: 12, stock: 2 }]]);
const orders = [];
function placeOrder(sku, quantity) {
const product = products.get(sku);
product.stock -= quantity;
orders.push({ sku, quantity, total: product.price * quantity });
}
function blackFriday() {
for (const product of products.values()) product.price = 0;
}
placeOrder("mug", 3);
blackFriday();
console.log(products.get("mug"));
console.log(orders);
node tangled.js and of the browser terminal{ name: 'Mug', price: 0, stock: -1 }
[ { sku: 'mug', quantity: 3, total: 36 } ]Two bugs, and neither function looks wrong on its own:
- The stock is
-1. The rule "you cannot sell what you do not have" belongs to the catalog, but the orders code changed the stock itself and skipped it. - Someone added
blackFriday()months later. It changes every price, and nobody who owns products was asked.
In a real codebase these two functions sit in different folders, written by different people. When every part can touch every other part's data, you get what developers call a big ball of mud: a change in one place breaks something far away, and nobody can tell what depends on what.
The modular monolith
A modular monolith is still one program and one deployment. The difference is inside: the code is split into modules, and each module has a hard edge around it.
- A module owns one area of the business. In ShopFlow, the online shop you build in the capstone, that means
catalog(products and stock),orders(carts and checkout),usersandpayments. Such an area is called a domain boundary, or a bounded context. - A module has a small public API. Other modules may call only what it exports on purpose. Everything else is private.
- A module owns its data. Only the catalog module reads and writes the products table. Orders asks the catalog.
- Infrastructure is shared. The logger, the configuration, the event bus, the database connection and the HTTP server are the same for every module. Only the data and the rules are split.
Modules talk to each other in three ways, and this lesson shows each one:
| Way | Use it when | ZudoJS tool |
|---|---|---|
| Call the other module's public API | You need an answer right now ("is this in stock?") | A TypeScript interface, wired with @zudojs/container |
| Publish an event | Something happened and others may care ("an order was placed") | @zudojs/events |
| Send a command or query by name | You want to ask for work without importing the module at all | @zudojs/cqrs |
TIP
A modular monolith is also the best way to prepare for microservices, the topic of the next lesson. A module with a clean edge can later be moved into its own service. A tangled one cannot.Generate a modular monolith
The ZudoJS CLI you met in Create the Task API project has a template for this. Create ShopFlow with it, with the events and cqrs capabilities:
zudojs create shopflow --architecture modular-monolith --package-manager npm --capabilities "events,cqrs" │ ◇ Project structure created │ ◇ Backend project generated (40 files) Capabilities build on: messaging, events added 67 packages, and audited 68 packages in 36s … ◆ Dependencies installed │ ◇ Project validated │ ◇ Git repository initialized │ ◇ Next steps ──╮ │ │ │ cd shopflow │ │ npm run dev │ │ │ ├───────────────╯ │ └ Project created successfully. cd shopflow
Capabilities build on: messaging, events means CQRS needs two other packages, so the CLI added them too. A new modular monolith has no modules yet. Add two with zudojs generate module:
zudojs generate module catalog Detected architecture: modular-monolith Generated 8 files: - src/modules/catalog/catalog.module.ts - src/modules/catalog/index.ts - src/modules/index.ts - src/modules/catalog/features/catalog.feature.ts - src/modules/catalog/features/index.ts - src/modules/catalog/routes/index.ts - src/app.ts - src/routes/index.ts zudojs generate module orders … tree src/modules src/modules ├── catalog │ ├── catalog.module.ts │ ├── features │ │ ├── catalog.feature.ts │ │ └── index.ts │ ├── index.ts │ └── routes │ └── index.ts ├── index.ts └── orders ├── features │ ├── index.ts │ └── orders.feature.ts ├── index.ts ├── orders.module.ts └── routes └── index.ts 7 directories, 11 files
Each module is a folder with the same shape:
catalog.module.tsis the module class. The runtime calls itsonInitializewhen the app starts andonShutdownwhen it stops, as you saw in the runtime lesson.features/holds the module's code: services, repositories, handlers. It is private to the module.routes/index.tsregisters the module's HTTP routes. It is empty for now.index.tsis the module's public API. Whatever it exports is what other modules may use. Right now it exports only the module class.src/modules/index.tscollects the modules forapp.ts.
The last two files in the list were updated, not created: the CLI registered the module for you. It added the module to the list the runtime starts in src/app.ts, and its routes to registerRoutes in src/routes/index.ts. After both commands, src/app.ts contains:
import { CatalogModule } from "./modules/index.js";
import { OrdersModule } from "./modules/index.js";
// inside createApp():
for (const module of [
new OrdersModule(),
new CatalogModule(),
] as Module[]) {
modules.set(module.id, module);
}
The list order does not decide the start order. Orders will need the catalog, so tell the runtime. In src/modules/orders/orders.module.ts, add a dependencies list to the constructor:
public constructor() {
super({ version: "0.1.0", dependencies: ["catalog"] });
}
Start the app. The runtime starts the catalog first because orders depends on it, even though orders comes first in the list:
npm run dev > shopflow@0.1.0 dev > tsx watch src/server.ts 2026-09-23T17:35:22.035Z [INFO] [shopflow] catalog module initialized 2026-09-23T17:35:22.037Z [INFO] [shopflow] orders module initialized 2026-09-23T17:35:22.038Z [INFO] [shopflow] All modules initialized. modules=["integrations","catalog","orders"] durationMs=7 2026-09-23T17:35:22.040Z [INFO] [shopflow] All modules started. modules=["integrations","catalog","orders"] durationMs=1 2026-09-23T17:35:22.041Z [INFO] [shopflow] Runtime is ready. runtimeId=rt_82876e3fec4a4169b7a13157658af51d environment=development Listening on http://0.0.0.0:3000
Press Ctrl + C and the order reverses: orders module stopped, then catalog module stopped. A module never loses something it depends on while it is still running.
A module's public API
Now fill the catalog with real rules. The examples below are small single files so you can run them on this page. In the project, each lives in its module folder, as the file name comments say.
The catalog exports an interface, CatalogApi, and a function that creates it. The Map of products lives inside the function, so no other code can reach it:
// src/modules/catalog/index.ts: the catalog's public API
import { ConflictError, NotFoundError } from "@zudojs/errors";
export interface Product {
readonly sku: string;
readonly name: string;
readonly price: number;
readonly stock: number;
}
export interface CatalogApi {
getProduct(sku: string): Product;
reserve(sku: string, quantity: number): void;
}
export function createCatalog(): CatalogApi {
const products = new Map([
["mug", { name: "Mug", price: 12, stock: 2 }],
["tee", { name: "T-shirt", price: 20, stock: 10 }],
]);
function find(sku: string) {
const product = products.get(sku);
if (!product) throw new NotFoundError(`No product ${sku}`);
return product;
}
return {
getProduct: (sku) => ({ sku, ...find(sku) }),
reserve(sku, quantity) {
const product = find(sku);
if (quantity > product.stock) throw new ConflictError(`Only ${product.stock} ${sku} left`);
product.stock -= quantity;
},
};
}
The orders module receives a CatalogApi. It does not know how the catalog stores products, and it does not care:
// src/modules/orders/index.ts: the orders module's public API
import type { CatalogApi } from "./catalog.js";
export interface Order {
readonly id: number;
readonly sku: string;
readonly quantity: number;
readonly total: number;
}
export function createOrders(catalog: CatalogApi) {
const orders: Order[] = [];
return {
placeOrder(sku: string, quantity: number): Order {
const product = catalog.getProduct(sku);
catalog.reserve(sku, quantity);
const order = { id: orders.length + 1, sku, quantity, total: product.price * quantity };
orders.push(order);
return order;
},
};
}
import { createCatalog } from "./catalog.js";
import { createOrders } from "./orders.js";
const catalog = createCatalog();
const orders = createOrders(catalog);
console.log(orders.placeOrder("mug", 1));
try {
orders.placeOrder("mug", 3);
} catch (error) {
console.log((error as Error).name, (error as Error).message);
}
console.log(catalog.getProduct("mug"));
npx tsx main.ts and of the browser terminal{ id: 1, sku: 'mug', quantity: 1, total: 12 }
ConflictError Only 1 mug left
{ sku: 'mug', name: 'Mug', price: 12, stock: 1 }The same bug from the tangled shop cannot happen here. The only way to change stock is reserve, and reserve checks the rule. The second order was refused and the stock stayed at 1.
main.ts plays the role of app.ts: the one place that creates every module and hands each one what it needs. That place is called the composition root. In a bigger app you register these APIs in the container from the dependency injection lesson, under a token each module exports.
TypeScript also guards the edge. If the orders code tries to reach the catalog's data, it does not compile:
import { createCatalog } from "./catalog.js";
const catalog = createCatalog();
catalog.products.set("mug", { name: "Mug", price: 0, stock: 99 });
npx tsc --noEmit printssneaky.ts:4:9 - error TS2339: Property 'products' does not exist on type 'CatalogApi'.
4 catalog.products.set("mug", { name: "Mug", price: 0, stock: 99 });
~~~~~~~~
Found 1 error in sneaky.ts:4Types are a strong fence, but not the only one you need. A developer can still write import { something } from "../catalog/features/catalog.feature.js" and use a private file. The rule for your team is simple: import another module only through its index.ts. You will write a small check for that in the practice section.
Each module owns its data
When ShopFlow gets a real database, the boundary must hold there too. Two habits keep it:
- One schema per module. PostgreSQL can group tables into schemas, named folders inside one database:
catalog.products,orders.orders,orders.order_lines. Only the catalog module's code touchescatalog.*. - No joins across modules. Orders stores the
skuand the price it charged, a copy taken at checkout time. It does not joincatalog.productsto show an order. If the product is renamed later, the order still says what the customer bought.
Sharing one database server is fine and is what "shared infrastructure" means. Sharing tables between modules is what turns them back into a ball of mud.
Events between modules
A direct call is right when orders needs an answer from the catalog. It is wrong when orders only wants to say "this happened". If checkout called the e-mail module, the analytics module and the loyalty-points module one by one, orders would depend on all of them, and one slow module would slow down every checkout.
Instead, orders publishes an event with @zudojs/events, from the events lesson, and any module may subscribe. The event definition is part of the orders module's public API:
// src/modules/orders/index.ts exports this definition
import { defineEvent } from "@zudojs/events";
export interface OrderPlaced {
readonly orderId: number;
readonly sku: string;
readonly quantity: number;
readonly email: string;
}
export const OrderPlacedEvent = defineEvent<"order.placed", OrderPlaced>("order.placed");
import { createEventBus, type Event } from "@zudojs/events";
import { OrderPlacedEvent, type OrderPlaced } from "./contracts.js";
const bus = createEventBus();
// notifications module: subscribes, never imported by orders
bus.on<Event<OrderPlaced>>(OrderPlacedEvent.type, (event) => {
console.log(`notifications: e-mail ${event.payload.email} about order ${event.payload.orderId}`);
});
// analytics module: also subscribes, and has a bug
bus.on<Event<OrderPlaced>>(OrderPlacedEvent.type, () => {
throw new Error("analytics database is down");
});
// orders module: publishes, and does not know who listens
const result = await bus.publish(
OrderPlacedEvent.create({ orderId: 1, sku: "mug", quantity: 1, email: "ada@example.com" }),
);
console.log("handlers:", result.handlerCount, "ok:", result.succeeded, "failed:", result.failed);
for (const error of result.errors) {
const cause = error instanceof Error && error.cause instanceof Error ? error.cause.message : String(error);
console.log("logged:", cause);
}
npx tsx modules.ts and of the browser terminalnotifications: e-mail ada@example.com about order 1 handlers: 2 ok: 1 failed: 1 logged: analytics database is down
Three things to notice:
- The orders code only knows
OrderPlacedEvent. Adding a loyalty-points module later means adding one morebus.on, with no change to orders. - The analytics handler threw, and the e-mail was still sent. In its default
CONTINUEmode the bus runs every handler and collects the failures inresult.errors, so one broken module does not break checkout. Log those errors: a failure nobody sees is a failure nobody fixes. - The event carries everything a subscriber needs (
email,sku). A subscriber that had to call back into orders to learn more would be coupled to orders again.
IN-PROCESS EVENTS ARE NOT DURABLE
This bus lives in memory. If the process crashes right after the order is saved and before the event is handled, the e-mail is never sent. Inside one app that is often acceptable. When it is not, save the event in the same database transaction as the order and publish it afterwards. That is the outbox pattern, and the next lesson builds one.Commands and queries between modules
With @zudojs/cqrs, from the CQRS lesson, one module can ask another for work by name. The asking module imports only the message types, never the other module's code. A command changes something (PlaceOrder). A query only reads (GetProduct).
Inside a monolith this gives you one more thing: every write goes through one bus, so logging, timing and permission checks can wrap all of them in one place, as middleware.
// Shared contracts: the only thing modules import from each other
import type { CommandOf, QueryOf } from "@zudojs/cqrs";
export interface ProductView {
readonly sku: string;
readonly price: number;
}
export type GetProduct = QueryOf<"GetProduct", { sku: string }>;
export type PlaceOrder = CommandOf<"PlaceOrder", { sku: string; quantity: number }>;
import { createCommandBus, createQueryBus, timingMiddleware } from "@zudojs/cqrs";
import type { GetProduct, PlaceOrder, ProductView } from "./messages.js";
const queries = createQueryBus();
const commands = createCommandBus({
middleware: [timingMiddleware({ onTiming: ({ request }) => console.log(`audit: ${request.type}`) })],
});
// catalog module registers its query handler
const prices = new Map([["mug", 12], ["tee", 20]]);
queries.register<GetProduct, ProductView>("GetProduct", async (query) => ({
sku: query.sku,
price: prices.get(query.sku) ?? 0,
}));
// orders module registers its command handler; it asks the catalog by name
commands.register<PlaceOrder, { total: number }>("PlaceOrder", async (command) => {
const product = await queries.execute<GetProduct, ProductView>({ type: "GetProduct", sku: command.sku });
return { total: product.price * command.quantity };
});
// an HTTP controller sends the command
const result = await commands.execute<PlaceOrder, { total: number }>({ type: "PlaceOrder", sku: "tee", quantity: 2 });
console.log(result);
npx tsx app.ts and of the browser terminalaudit: PlaceOrder
{ total: 40 }The orders handler never imported the catalog. It knows that a query called GetProduct exists and what it returns, which is exactly what messages.ts says. The timingMiddleware saw the command without either module doing anything: that is where an audit log belongs.
NOTE
Do not put every call through a bus by reflex. A plain interface call, as inCatalogApi, is easier to read and to test. Reach for commands and queries when you want the extra middleware, or when you want the two modules to share nothing but message names.Wiring modules into the runtime
Here is the whole picture as the runtime sees it. The shared infrastructure (one logger, one container, one event bus) is created once and passed to the runtime. Each module gets what it needs through its constructor, and declares which modules it depends on:
import { createContainer } from "@zudojs/container";
import { BaseModule, type Module } from "@zudojs/core";
import { createEventBus, type EventBus } from "@zudojs/events";
import { createLogger, LoggerLevel } from "@zudojs/logger";
import { createRuntime } from "@zudojs/runtime";
class CatalogModule extends BaseModule {
readonly id = "catalog";
readonly name = "catalog";
constructor() { super({ version: "0.1.0" }); }
override async onInitialize() { console.log("catalog: ready"); }
override async onShutdown() { console.log("catalog: stopped"); }
}
class OrdersModule extends BaseModule {
readonly id = "orders";
readonly name = "orders";
constructor(private readonly bus: EventBus) { super({ version: "0.1.0", dependencies: ["catalog"] }); }
override async onInitialize() {
this.bus.on("order.placed", () => {});
console.log("orders: ready, subscribed to order.placed");
}
override async onShutdown() { console.log("orders: stopped"); }
}
const eventBus = createEventBus();
const modules = new Map<string, Module>();
for (const module of [new OrdersModule(eventBus), new CatalogModule()]) modules.set(module.id, module);
const runtime = createRuntime(
{ modules, eventBus, container: createContainer(), logger: createLogger({ name: "shopflow", level: LoggerLevel.ERROR }) },
{ applicationName: "shopflow", environment: "development", handleSignals: false },
);
await runtime.start();
console.log("state:", runtime.state);
await runtime.stop();
npx tsx runtime.tscatalog: ready orders: ready, subscribed to order.placed state: running orders: stopped catalog: stopped
Orders was listed first, but the catalog started first, because orders declared dependencies: ["catalog"]. On shutdown the order is reversed, so orders stops using the catalog before the catalog goes away. LoggerLevel.ERROR keeps the runtime's own log lines out of this output.
Rules of thumb
- Draw boundaries around business areas, not technical layers.
catalog,ordersandpaymentsare good modules.controllers,servicesandutilsare folders inside a module. - Keep the public API small. Export a few functions and types from
index.ts. Every export is a promise you have to keep. - Ask, don't reach. Call the public API, publish an event, or send a command. Never read another module's tables or private files.
- Stay in one process as long as you can. A modular monolith gives you most of the order of microservices, with none of the network problems. Move a module into its own service only when you have a concrete reason, such as a very different load or a separate team. The next lesson is about exactly that.
Practice
TRY IT YOURSELF
Add a stock-level query to the catalog
Give CatalogApi a third method, inStock(sku): boolean. Use it in createOrders so placeOrder refuses a sold-out product before calling reserve. Why is it still important that reserve checks the stock itself?
Show a solution
interface CatalogApi {
inStock(sku: string): boolean;
reserve(sku: string, quantity: number): void;
}
function createCatalog(): CatalogApi {
const stock = new Map([["mug", 1]]);
return {
inStock: (sku) => (stock.get(sku) ?? 0) > 0,
reserve(sku, quantity) {
const left = stock.get(sku) ?? 0;
if (quantity > left) throw new Error(`Only ${left} ${sku} left`);
stock.set(sku, left - quantity);
},
};
}
const catalog = createCatalog();
for (const quantity of [1, 1]) {
if (!catalog.inStock("mug")) {
console.log("sold out");
continue;
}
catalog.reserve("mug", quantity);
console.log("reserved", quantity);
}
npx tsx stock.ts and of the browser terminalreserved 1 sold out
inStock is a quick check for a nicer error message. Between that check and reserve, another request may buy the last mug. The rule must live in the one place that changes the stock, which is reserve.
TRY IT YOURSELF
Write a boundary check
Write a function checkImport(fromModule, specifier) that returns an error message when code in one module imports a file from inside another module, and null when the import is allowed. Allowed: anything inside the same module, and another module's index.js. Test it with the three imports below.
Show a solution
function checkImport(fromModule, specifier) {
const match = specifier.match(/modules\/([^/]+)\/(.+)$/);
if (!match) return null;
const [, target, rest] = match;
if (target === fromModule || rest === "index.js") return null;
return `${fromModule} may not import ${target}/${rest}: use ${target}/index.js`;
}
console.log(checkImport("orders", "../../modules/catalog/index.js"));
console.log(checkImport("orders", "../../modules/catalog/features/catalog.feature.js"));
console.log(checkImport("orders", "../../modules/orders/features/orders.feature.js"));
node boundaries.js and of the browser terminalnull orders may not import catalog/features/catalog.feature.js: use catalog/index.js null
Run a check like this over every file in a unit test, and the build fails the day someone crosses a boundary. Lint tools can do the same with a "restricted imports" rule.
TRY IT YOURSELF
Subscribe a loyalty module
Add a loyalty subscriber to the events example that gives one point per unit bought, and prints the running total for the customer. Do not change the orders code.
Show a solution
import { createEventBus, defineEvent, type Event } from "@zudojs/events";
interface OrderPlaced {
readonly email: string;
readonly quantity: number;
}
const OrderPlacedEvent = defineEvent<"order.placed", OrderPlaced>("order.placed");
const bus = createEventBus();
const points = new Map<string, number>();
bus.on<Event<OrderPlaced>>(OrderPlacedEvent.type, (event) => {
const { email, quantity } = event.payload;
points.set(email, (points.get(email) ?? 0) + quantity);
console.log(`loyalty: ${email} has ${points.get(email)} points`);
});
await bus.publish(OrderPlacedEvent.create({ email: "ada@example.com", quantity: 2 }));
await bus.publish(OrderPlacedEvent.create({ email: "ada@example.com", quantity: 3 }));
npx tsx loyalty.ts and of the browser terminalloyalty: ada@example.com has 2 points loyalty: ada@example.com has 5 points
Recap
- A monolith is one deployable program. It is a fine start; it only becomes a problem when every part can reach into every other part.
- A modular monolith keeps one deployment but splits the code into modules along business areas. Each module has a small public API and owns its data.
zudojs create --architecture modular-monolithandzudojs generate modulegive you the folders and register each module inapp.tsand its routes inregisterRoutes. You declare each module'sdependencies.- Modules talk through public interfaces (answers now), events (something happened) and commands or queries (work by name). Logger, config, event bus and database server are shared; tables are not.
Next, you take one of these modules out of the process and into its own service, and meet everything that the network makes harder.
Test yourself
Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.