Modular monolith
A modular monolith is one deployable, but inside the codebase each business capability is a clearly bounded module. You get the operational simplicity of a monolith and most of the decoupling of microservices — without the network, the orchestration, or the early commitment.
DaloyJS is a great fit for this style because plugins are encapsulated, every route is a typed contract, and the same OpenAPI document + typed client you ship to consumers also lets your own modules call each other safely. When you eventually extract a module into its own service, the contract is already there.
Mental model
- Module — one bounded context (e.g.
catalog,orders,identity). Owns its routes, domain logic, persistence, and tests. Exposes only a public surface. - Shared kernel — cross-cutting infrastructure (db client, logger, http hooks, config). Knows nothing about any specific module.
- Platform — wiring code: which modules to register, in what order, with which prefixes. Builds the
Appand exposes the typed client.
Reference folder structure
This is the layout we recommend for new projects. create-daloy can scaffold a small version of it, and it scales cleanly from one module to dozens.
src/
├── server.ts # runtime entrypoint (node | edge | bun | deno)
├── app.ts # builds the App, calls registerModules(app)
│
├── config/
│ ├── env.ts # zod-validated process.env
│ └── index.ts
│
├── shared/ # cross-cutting kernel — NO business logic
│ ├── db/
│ │ └── client.ts # single ORM, ODM, or query-client instance
│ ├── http/
│ │ ├── errors.ts # problem+json helpers
│ │ └── hooks.ts # auth, requestId, rateLimit, secureHeaders
│ ├── observability/
│ │ ├── logger.ts
│ │ └── tracer.ts
│ └── types.ts # framework-agnostic shared types
│
├── modules/ # one folder per bounded context
│ ├── catalog/
│ │ ├── index.ts # the plugin: registers routes + decorators
│ │ ├── routes/
│ │ │ ├── list-books.ts
│ │ │ ├── get-book.ts
│ │ │ └── create-book.ts
│ │ ├── domain/ # pure business rules — no framework imports
│ │ │ ├── book.ts
│ │ │ └── catalog-service.ts
│ │ ├── infra/ # adapters: db, search, external APIs
│ │ │ ├── book-repo.ts
│ │ │ └── search-index.ts
│ │ ├── contracts/
│ │ │ ├── schemas.ts # zod request/response schemas
│ │ │ └── public.ts # types other modules may import
│ │ └── catalog.test.ts
│ │
│ ├── orders/
│ │ ├── index.ts
│ │ ├── routes/
│ │ ├── domain/
│ │ ├── infra/
│ │ ├── contracts/
│ │ └── orders.test.ts
│ │
│ └── identity/
│ ├── index.ts
│ ├── routes/
│ ├── domain/
│ ├── infra/
│ └── contracts/
│
├── platform/ # wiring only — no domain logic
│ ├── modules.ts # ordered list of modules to register
│ ├── openapi.ts # generateOpenAPI(app) → openapi.json
│ └── client.ts # in-process typed client wiring
│
└── tests/
├── contract/ # OpenAPI-driven contract tests
└── e2e/ # full HTTP scenarios per user journey
openapi/ # generated artifacts checked into VCS
└── openapi.json
generated/ # Hey API typed client output
└── client/
Module dependency rules
The whole point of a modular monolith is that the rules are enforceable, not just documented. There are only three rules and a linter can keep you honest.
┌──────────────────────────────────────────────┐
│ app.ts │
│ builds App, registers modules in order │
└──────────────────────┬───────────────────────┘
│
┌─────────────────────────┼─────────────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ catalog │ │ orders │ │ identity │
│ plugin │ │ plugin │ │ plugin │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ uses │ uses │ uses
▼ ▼ ▼
┌──────────────────────────────────────────┐
│ shared/ │
│ db · http · logger · config · types │
└──────────────────────────────────────────┘
Allowed: modules/* → shared/*
Allowed: modules/* → other-module/contracts/public (types only)
Allowed: modules/* → platform/client (in-process typed client)
Forbidden: modules/A → modules/B/{domain,infra,routes}
Forbidden: shared/* → modules/*
Forbidden: domain/* → infra/* or framework code
Anatomy of a module
A module is just a DaloyJS plugin. The folder structure is what gives it long-term shape; the framework only cares about the register() function in index.ts.
// src/modules/catalog/index.ts
import type { App } from "@daloyjs/core";
import { listBooks } from "./routes/list-books";
import { getBook } from "./routes/get-book";
import { createBook } from "./routes/create-book";
import { CatalogService } from "./domain/catalog-service";
import { BookRepo } from "./infra/book-repo";
export const catalogModule = {
name: "catalog",
register(app: App) {
// Wire the module's own dependencies into a single decorator.
// Other modules cannot see this — encapsulation is per-plugin.
app.decorate("catalog", new CatalogService(new BookRepo(app.state.db)));
listBooks(app);
getBook(app);
createBook(app);
},
};Each route file contains exactly one app.route(...) call. That keeps OpenAPI diffs small, makes test scoping obvious, and lets new contributors find the right file from an operationId in seconds.
Public contracts: how modules talk
Every module has a contracts/public.ts. It is the only file other modules are allowed to import. Treat it like a public package boundary inside your monorepo.
// src/modules/catalog/contracts/public.ts
import { z } from "zod";
export const BookId = z.string().uuid().brand<"BookId">();
export type BookId = z.infer<typeof BookId>;
export const Book = z.object({
id: BookId,
title: z.string(),
authorId: z.string().uuid(),
priceCents: z.number().int().nonnegative(),
});
export type Book = z.infer<typeof Book>;Inside the module, domain/ and infra/ may use richer internal types. Across modules, only the public schema is visible. This is the same pattern that makes future extraction painless — the cross-module type surface is already minimal and already validated.
Cross-module calls without coupling
When orders needs a book, it does not import BookRepo. It calls catalog through the same typed client consumers use — pointed at the in-process app instead of HTTP.
// src/platform/client.ts
import { createInProcessClient } from "@daloyjs/core/client";
import { app } from "@/app";
// Same shape as the public typed client; zero network hops.
export const internal = createInProcessClient(app);// src/modules/orders/domain/place-order.ts
import { internal } from "@/platform/client";
export async function placeOrder(input: { bookId: string; userId: string }) {
const { body: book } = await internal.getBook({ params: { id: input.bookId } });
if (!book) throw new Error("book not found");
// ... charge, persist, emit event, return order
}Two wins: orders has no compile-time dependency on catalog's implementation, and the day you extract catalog into a separate service, the only change in orders is a base URL.
Wiring modules into the app
Keep registration explicit and ordered. A single list is far easier to review than auto-discovery, and it makes startup deterministic across runtimes.
// src/platform/modules.ts
import type { App } from "@daloyjs/core";
import { identityModule } from "@/modules/identity";
import { catalogModule } from "@/modules/catalog";
import { ordersModule } from "@/modules/orders";
export function registerModules(app: App) {
app.register(identityModule, { prefix: "/identity", tags: ["Identity"] });
app.register(catalogModule, { prefix: "/catalog", tags: ["Catalog"] });
app.register(ordersModule, { prefix: "/orders", tags: ["Orders"] });
}// src/app.ts
import { App, secureHeaders, rateLimit, requestId } from "@daloyjs/core";
import { env } from "@/config";
import { registerModules } from "@/platform/modules";
import { openDatabase } from "@/shared/db/client";
import { createLogger } from "@/shared/observability/logger";
export const app = new App({ bodyLimitBytes: 1 << 20 });
app.use(requestId());
app.use(secureHeaders());
app.use(rateLimit({ windowMs: 60_000, max: 600 })); // global unless you configure keyGenerator or trustProxyHeaders
app.decorate("db", await openDatabase(env.DATABASE_URL));
app.decorate("logger", createLogger({ level: env.LOG_LEVEL }));
registerModules(app);
await app.ready();Enforcing boundaries with the linter
Documentation drifts. Tooling does not. Add an eslint-plugin-import rule that bans the patterns the architecture forbids.
// .eslintrc.json (excerpt)
{
"rules": {
"import/no-restricted-paths": ["error", {
"zones": [
{
"target": "src/shared",
"from": "src/modules",
"message": "shared/ must not depend on any module"
},
{
"target": "src/modules/*/domain",
"from": ["src/modules/*/infra", "src/modules/*/routes"],
"message": "domain/ must stay framework- and infra-free"
},
{
"target": "src/modules/*/!(contracts)/**",
"from": "src/modules/!(self)/!(contracts)/**",
"message": "cross-module imports are only allowed via contracts/public"
}
]
}]
}
}Testing layout
Tests follow the module boundary. Each module owns its unit and integration tests; the repository keeps a small top-level tests/contract suite that runs against the generated OpenAPI document so any unintended schema change fails CI.
src/modules/catalog/catalog.test.ts # unit + module-level integration
src/modules/orders/orders.test.ts # unit + module-level integration
tests/contract/openapi.spec.ts # diff-against-frozen-snapshot
tests/e2e/checkout.e2e.ts # cross-module user journeysScaling the monolith
Most teams never need to leave this layout. When you do — usually because one module needs independent scaling, a different runtime, or a separate on-call rotation — the path is straightforward.
- Move
src/modules/<name>into its own repo or workspace package and keep its plugin entry intact. - Re-export
contracts/public.tsas a published package so the original repo can still import the types. - Swap the in-process typed client for a real HTTP base URL in the original repo — the callsites do not change.
- Re-run
generateOpenAPIin both repos; the contract-test suite immediately tells you if anything drifted.
Because every cross-module call already went through a typed contract, extraction becomes a configuration change rather than an architectural rewrite.
Anti-patterns to avoid
- Reaching into another module's
domain/orinfra/. The instant this is allowed, the modules collapse back into a tangle. Keep the lint rule enforced. - Putting domain logic in
shared/.shared/is for plumbing only. If you need a helper that knows aboutBook, it belongs insidemodules/catalog. - One giant
routes.tsper module. Prefer one file per route — it keeps OpenAPI diffs reviewable and gives you obvious test boundaries. - Auto-loading modules from the filesystem. Explicit registration in
platform/modules.tsis easier to audit, diff, and reason about across runtimes. - Premature service extraction. Stay a monolith until the operational benefit is concrete. The contracts you build along the way are what makes splitting cheap later.
Where to next
- Plugins & encapsulation — the primitive every module is built on.
- OpenAPI generation — the contract that powers the typed client and contract tests.
- Typed clients — how cross-module calls stay decoupled.
- Testing — pairing module-level tests with contract-level guarantees.