Skip to content

0008. Modular monolith with extractable modules

  • Status: Proposed
  • Date: 2026-10-01

Context

ADR-0002 chose a modular monolith: one API app and one worker app, split by module inside. It says a module "can be extracted into a separate service later", but nothing yet makes that cheap. Today a module may import another module's NestJS providers, entities, and types in-process, may share a database transaction with it, and may join its tables. Every one of those couplings has to be found and rewritten before a module can run in its own process.

We want the operational simplicity of one deployable now, and the option to move a module (for example assistant, data-import, or integrations) into its own service later by changing wiring and infrastructure, not business code.

Options considered

  1. Keep the current rule (modules import each other's index.ts): simplest, but callers depend on in-process NestJS providers and rich types; extraction means rewriting every caller.
  2. Microservices now: real isolation, but network calls, distributed data, and per-service operations are too costly for the team size and stage (already rejected in ADR-0002).
  3. Separate database per module now: strongest data isolation, but multiplies databases on top of one database per tenant.
  4. Modular monolith that already behaves like services inside one process: modules talk only through serializable contracts and integration events, and each module owns a PostgreSQL schema. Extraction swaps in-process adapters for remote ones.

Decision

Option 4. A module is the unit of extraction; a feature is never extracted on its own. Every module follows these rules from its first line of code:

  1. Contracts are separate from implementation. What other modules may use lives in a contract library, libs/api/contracts/<module> (@bizos/api-<module>-contracts, tag type:contract): ports (abstract classes used as NestJS injection tokens), their request and response types, and integration event types. Contracts are plain, serializable TypeScript with no NestJS, ORM, or entity imports.
  2. Modules never import modules. A type:module library may depend only on type:contract and type:shared. Only apps compose modules. Lint enforces this.
  3. Synchronous calls go through a port. The owning module binds the port to its implementation; callers inject the port. Inputs and outputs are serializable (no class instances, ISO strings for dates), ports offer batch reads instead of per-item calls, and no transaction spans two modules. Prefer synchronous calls for reads; changes that must happen in another module go through events.
  4. Side effects across modules go through integration events. Domain events (@nestjs/cqrs) stay inside a module; the module translates the ones others need into versioned integration events with a standard envelope (id, type such as crm.customer-created, version, occurred-at, tenant id, correlation id, payload). Delivery is at least once and asynchronous, so consumers are idempotent and never assume in-process timing.
  5. Each module owns a PostgreSQL schema. Its tables live in a schema named after the module (crm, iam, tenancy) in the tenant or control-plane database. No foreign keys, joins, views, or transactions across module schemas; other modules' records are referenced by UUID only, and data needed for queries is copied locally from events.
  6. Each module owns its HTTP path. All controllers of a module are served under /api/<module>/, so a reverse proxy can later route that prefix to a separate service without changing the frontend.
  7. Context crosses boundaries explicitly. Inside the process the tenant and user context is implicit. In events it travels in the envelope; in future remote calls, in headers. Authentication must issue credentials that any service can verify without a synchronous call to iam.
  8. Each module owns its configuration and queues. A module reads only its own settings (prefixed with its name, for example CRM_) and shared infrastructure settings from the shared kernel; its BullMQ queue names start with the module name.

To extract a module later: create apps/<module>-service that imports the module, give the module its own database and migration history (starting from a baseline of its schema), replace the in-process port bindings in the monolith with remote adapters (an HTTP client implementing the same port), move its event consumers to the new app, and route /api/<module>/ to it.

Consequences

  • Extraction becomes mostly wiring and infrastructure work; callers and business rules do not change.
  • Boundaries are visible in code review and enforced by lint: a module that needs another one must go through a contract library.
  • More upfront work per cross-module interaction: a port or an event, plus serializable types, instead of a direct call.
  • Eventual consistency between modules: screens that combine data from several modules use local copies fed by events, or call ports for batch reads.
  • Follow-up work: an integration event bus with a transactional outbox relayed by the worker; a request context (tenant, user, correlation id) on AsyncLocalStorage; moving the existing public tables into module schemas; a per-module OpenAPI document so the API client can be split.