Search docs

Jump between documentation pages.

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 App and 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.

text
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.

text
           ┌──────────────────────────────────────────────┐
           │                   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.

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.

ts
// 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.

ts
// 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);
ts
// 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.

ts
// 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"]   });
}
ts
// 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.

json
// .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.

text
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 journeys

Scaling 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.

  1. Move src/modules/<name> into its own repo or workspace package and keep its plugin entry intact.
  2. Re-export contracts/public.ts as a published package so the original repo can still import the types.
  3. Swap the in-process typed client for a real HTTP base URL in the original repo — the callsites do not change.
  4. Re-run generateOpenAPI in 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/ or infra/. 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 about Book, it belongs inside modules/catalog.
  • One giant routes.ts per 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.ts is 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