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¶
- 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. - 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).
- Separate database per module now: strongest data isolation, but multiplies databases on top of one database per tenant.
- 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:
- Contracts are separate from implementation. What other modules may use lives in a contract library,
libs/api/contracts/<module>(@bizos/api-<module>-contracts, tagtype: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. - Modules never import modules. A
type:modulelibrary may depend only ontype:contractandtype:shared. Only apps compose modules. Lint enforces this. - 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.
- 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 ascrm.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. - 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. - 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. - 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. - 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
publictables into module schemas; a per-module OpenAPI document so the API client can be split.