# 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](/docs/plugins), every route is a typed contract, and the same [OpenAPI document](/docs/openapi) + [typed client](/docs/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.

```
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*, and a linter can check them. There are only three rules.

**Diagram: Dependency direction**

- **app.ts** - builds the App, registers modules in order - [registerModules(app)]
- **modules/** - one bounded context per folder - [catalog, orders, identity]
- **shared/** - cross-cutting kernel, no business logic - [db, http, logger, config, types]

Dependencies only ever point downward. app.ts wires the modules. Each module may use shared/ and other modules' public contracts. shared/ never reaches back up into a module. The exact allow/forbid rules follow below.

```
           ┌──────────────────────────────────────────────┐
           │                   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 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 { listBooksRoute }  from "./routes/list-books.ts";
import { getBookRoute }    from "./routes/get-book.ts";
import { createBookRoute } from "./routes/create-book.ts";

import { CatalogService } from "./domain/catalog-service.ts";
import { BookRepo }       from "./infra/book-repo.ts";

// The literal tuple is the module's public HTTP surface.
export const catalogRoutes = [
  listBooksRoute,
  getBookRoute,
  createBookRoute,
] as const;

export function installCatalog(app: App) {
  app.decorate("catalog", new CatalogService(new BookRepo(app.state.db)));
}
```

Each route file exports one `defineRoute(...)` contract. That keeps OpenAPI diffs small and, unlike passing a bare `App` through registration functions, preserves every operation's literal type across file boundaries.

Use plugin `register()` and `group()` for runtime encapsulation, hooks, and decorations. Their callback bodies cannot widen the already-created parent's TypeScript generic, so routes that must appear in the no-codegen client should also be exported in module tuples and composed once through `registerRoutes()`.

```ts
// src/modules/catalog/routes/get-book.ts
import { defineRoute } from "@daloyjs/core";
import { z } from "zod";
import { Book } from "../contracts/public";

export const getBookRoute = defineRoute({
  method: "GET",
  path: "/catalog/books/:id",
  operationId: "getBook",
  request: { params: z.object({ id: z.string() }) },
  responses: {
    200: { description: "Book", body: Book },
    404: { description: "Not found" },
  },
  handler: async ({ params, state }) => {
    const book = await state.catalog.get(params.id);
    return book
      ? { status: 200, body: book }
      : { status: 404, body: undefined };
  },
});
```

```ts
// src/app.ts
import { App } from "@daloyjs/core";
import { catalogRoutes, installCatalog } from "./modules/catalog";
import { identityRoutes, installIdentity } from "./modules/identity";
import { orderRoutes, installOrders } from "./modules/orders";

const base = new App({ docs: true });
installCatalog(base);
installIdentity(base);
installOrders(base);

export const app = base.registerRoutes([
  ...catalogRoutes,
  ...identityRoutes,
  ...orderRoutes,
] as const);
```

## 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.uuid().brand<"BookId">();
export type BookId = z.infer<typeof BookId>;

export const Book = z.object({
  id: BookId,
  title: z.string(),
  authorId: z.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](/docs/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";

// Exact operation-id surface from every module, 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 response = await internal.getBook({ params: { id: input.bookId } });
  if (response.status !== 200) throw new Error("book not found");
  const book = response.body;

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

```
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

- [Plugins & encapsulation](/docs/plugins): the primitive every module is built on.
- [OpenAPI generation](/docs/openapi): the contract that powers the typed client and contract tests.
- [Typed clients](/docs/typed-client): how cross-module calls stay decoupled.
- [Testing](/docs/testing): pairing module-level tests with contract-level guarantees.

---

Source: https://daloyjs.dev/docs/architecture/modular-monolith