The Modular Monolith: Strong Boundaries Without Network Complexity
A monolith is one deployable application. It does not have to be one undifferentiated model. A modular monolith organizes business capabilities behind explicit in-process boundaries while retaining simple deployment, local transactions, and straightforward debugging. It is often the fastest way to learn where durable service boundaries really are.
This article is an implementation blueprint. We will structure a subscription application into Accounts, Billing, and Entitlements modules; enforce dependencies; manage module data and events; and define the evidence required before extraction.
Blueprint outcome
At the end, the application should satisfy:
One deployable process One repository (not mandatory, but convenient) Modules named for business capabilities No direct imports of another module's internals No direct access to another module's tables Public commands, queries, and events at seams Module-level tests and ownership Extraction possible, not promised
This ASCII contract demonstrates the distinction between deployment topology and code structure. A monolith can have strong logical boundaries even though function calls are local.
Lay out by capability
src/
accounts/
domain/
application/
adapters/
public.ts
billing/
domain/
application/
adapters/
public.ts
entitlements/
domain/
application/
adapters/
public.ts
platform/
database/
messaging/
observability/
bootstrap/
build-application.tsThis block demonstrates top-level business modules rather than global controllers/services/repositories folders. Each module can use internal clean boundaries without exposing them. The public.ts file is a deliberate facade, not a barrel export of every class.
Accounts owns account registration and status. Billing owns billing profiles, invoices, and payment state. Entitlements owns effective feature grants. “User,” “account,” and “subscription” should not become shared mutable entities merely because they appear in several modules.
Define a public surface
// billing/public.ts
export type BillingAccountId = string & { readonly __brand: 'BillingAccountId' };
export type OpenBillingAccount = {
organizationId: string;
legalName: string;
billingEmail: string;
};
export interface BillingModule {
openAccount(command: OpenBillingAccount): Promise<BillingAccountId>;
getStanding(id: BillingAccountId): Promise<'good' | 'restricted' | 'closed'>;
}
export type BillingAccountOpened = {
type: 'BillingAccountOpened.v1';
billingAccountId: BillingAccountId;
organizationId: string;
};This code demonstrates a compact public contract. Consumers do not import Billing's ORM models, repositories, or domain aggregate. The contract exposes business capabilities and a published event. The interface is useful even with one implementation because it marks an ownership seam, not because every class requires abstraction.
Avoid a universal module bus whose method accepts arbitrary command names and returns any. It hides dependencies from the compiler. A typed facade or typed command gateway keeps module use searchable.
Enforce import direction
Documentation alone decays. Add lint or architecture tests:
const rules = [
{
from: 'src/accounts/**',
disallow: ['src/billing/domain/**', 'src/billing/adapters/**'],
allow: ['src/billing/public.ts'],
},
{
from: 'src/entitlements/**',
disallow: ['src/accounts/domain/**', 'src/billing/adapters/**'],
allow: ['src/accounts/public.ts', 'src/billing/public.ts'],
},
];This code block demonstrates machine-enforced visibility. The exact tool may be dependency-cruiser, ESLint boundaries, ArchUnit, NetArchTest, or a custom graph check. The rule is the product: cross-module code can use only the public seam.
flowchart LR
Accounts --> AccountsPublic[Accounts Public API]
Billing --> BillingPublic[Billing Public API]
Entitlements --> EntitlementsPublic[Entitlements Public API]
AccountsPublic --> Billing
BillingPublic --> Entitlements
Bootstrap --> Accounts
Bootstrap --> Billing
Bootstrap --> EntitlementsThe diagram demonstrates that bootstrap knows concrete modules while modules depend only on one another's published surfaces. Review the actual dependency graph in CI and fail cycles.
Give data an owner
A single database can support module ownership. Use schemas, table prefixes, and separate migration directories:
CREATE SCHEMA billing;
CREATE TABLE billing.accounts (
id uuid PRIMARY KEY,
organization_id uuid NOT NULL UNIQUE,
standing text NOT NULL,
version integer NOT NULL
);
REVOKE ALL ON SCHEMA billing FROM application_accounts;
GRANT USAGE ON SCHEMA billing TO application_billing;This block demonstrates database-level ownership where the platform permits separate roles. Accounts does not join billing.accounts directly. It calls Billing's query or consumes a published projection. Even if one runtime credential makes grants impractical, static SQL checks and code review can enforce the rule.
| Data need | Preferred mechanism | Reason |
|---|---|---|
| current decision owned by Billing | Billing query API | authoritative answer |
| high-volume read for Accounts UI | Accounts-owned projection | independent read model |
| atomic Billing invariant | Billing repository/transaction | single owner |
| analytics across modules | warehouse/CDC pipeline | avoid operational joins |
| temporary migration validation | controlled reconciliation job | explicit exception |
The table demonstrates alternatives to cross-module joins. Data duplication is acceptable when ownership and update semantics are explicit.
Choose calls or events intentionally
Use a synchronous in-process call when the caller needs an immediate answer to continue and both modules share process availability. Use an event when the producer announces a completed fact and consumers may react independently.
await unitOfWork.run(async () => {
const accountId = await accounts.register(command.account);
const billingId = await billing.openAccount({
organizationId: accountId,
legalName: command.legalName,
billingEmail: command.billingEmail,
});
await onboarding.save({ accountId, billingId, status: 'ready' });
});This block demonstrates a legitimate local transaction spanning modules, but it also creates orchestration coupling. Use it when atomic onboarding is a real business invariant. Do not let arbitrary callers open cross-module transactions; place the workflow in an owning application module.
For eventual reaction:
await billingTransaction.run(async () => {
const event = billingAccount.restrict(reason);
await billingAccounts.save(billingAccount);
await internalOutbox.append(event);
});This code demonstrates an internal outbox. A dispatcher invokes Entitlements after commit, avoiding callbacks that observe uncommitted state. Keeping an outbox inside a monolith may seem excessive, but it teaches durable event semantics and provides an extraction seam. For low-risk local notifications, an after-commit in-memory dispatcher can be enough if loss on crash is acceptable.
Keep transactions bounded
One database makes distributed failure optional, not boundary discipline. Define which module or workflow owns a transaction. Long transactions across unrelated modules increase lock contention and make future extraction hard.
A useful rule:
- domain invariants stay within one module transaction;
- explicitly named workflows may coordinate a small number of module operations;
- post-commit reactions use events;
- external network calls never occur while holding a database transaction unless carefully justified.
This list demonstrates a graduated consistency model. It does not imitate microservices by forbidding valuable local atomicity.
Build modules independently in tests
describe('Billing module contract', () => {
for (const createModule of [inMemoryBilling, postgresBilling]) {
it('opens one billing account per organization', async () => {
const billing = await createModule();
const command = validOpenAccount({ organizationId: 'org-1' });
const first = await billing.openAccount(command);
const second = await billing.openAccount(command);
expect(second).toEqual(first);
});
}
});This block demonstrates a shared contract suite for public module behavior. The production-backed version verifies database constraints and mapping. Depending on business semantics, duplicate commands might return the existing ID or an explicit conflict; the contract makes the choice visible.
Test levels:
- Domain tests for invariants.
- Module component tests through public APIs with private infrastructure.
- Cross-module workflow tests for contracts and transaction semantics.
- A small number of HTTP-to-database journeys.
- Architecture tests for forbidden imports and SQL access.
Module tests should not reach private classes from other modules “for convenience.” Test-only boundary violations become production patterns.
Observe the monolith by module
One process can still emit module-scoped telemetry. Tag traces with module and operation. Measure public command outcomes, transaction duration, projection lag, and internal event age. Route alerts to module owners even if one platform team owns the runtime.
return tracer.inSpan('billing.open_account', { module: 'billing' }, async () => {
const result = await handler.execute(command);
metrics.increment('module_command_total', {
module: 'billing',
command: 'open_account',
outcome: result.kind,
});
return result;
});This code demonstrates a public-boundary instrumentation wrapper. Keep metric labels bounded; account IDs belong in trace attributes or structured logs, not metric dimensions.
Module telemetry creates evidence for extraction. If Billing consumes most CPU, deploys much more frequently, and produces incidents isolated to payment integrations, separate runtime scaling may be justified. Without module-level measurement, extraction decisions rely on anecdotes.
Manage module evolution
Treat public module surfaces as internal products. Compatible additions are straightforward in one repository, but persisted events and rolling deployments can still create version skew. Change contracts with expand/migrate/contract:
- Add the new field or operation while preserving old behavior.
- Update producers and consumers.
- Backfill data or replay events.
- Measure remaining old-path use.
- Remove only after evidence reaches zero.
The sequence demonstrates service-grade evolution without network deployment. This discipline prevents a later extraction from revealing years of hidden coupling.
Maintain an architecture decision for exceptions. A reporting job may temporarily read several schemas during migration. Give the exception an owner and removal date; otherwise “temporary” access becomes the real architecture.
Extraction readiness scorecard
| Evidence | Stay modular | Consider extraction |
|---|---|---|
| deployment cadence | changes with rest of app | independent frequent releases blocked |
| scaling | similar resource profile | distinct sustained demand |
| reliability | shared failure acceptable | isolation has business value |
| team ownership | one small team | stable autonomous team |
| data boundary | frequent cross-module access | owned schema and contracts |
| operations | simple runtime preferred | platform can support another service |
| latency | local calls valuable | network budget understood |
The table demonstrates that extraction is an economic decision. “Code has 100,000 lines” is not enough.
If extraction is justified:
- freeze direct module access with architecture tests;
- replace cross-module transactions with an explicit workflow model;
- move public calls behind a transport-neutral client contract;
- establish replicated data or synchronous APIs;
- migrate data with checksums and reconciliation;
- shadow traffic or compare outcomes;
- cut over gradually and retain rollback;
- remove old tables and imports.
This checklist demonstrates why prior modularity reduces risk. The service boundary already exists logically; migration adds distribution concerns rather than inventing ownership during a cutover.
Ways modular monoliths fail
Folders without enforcement: developers import private repositories across modules, making names cosmetic.
Shared domain model: all modules mutate Account or Customer objects, so no one owns invariants.
Common dumping ground: a shared module accumulates business rules. Shared code should be technical primitives or genuinely stable value semantics.
Internal event fantasy: events are published, but consumers also query producer tables, creating two coupling paths.
One giant transaction: every workflow relies on global atomicity, making extraction impossible and lock behavior fragile.
Premature extraction hooks: every call is serialized through an in-process fake HTTP bus, adding complexity before distribution.
Build boundaries that are valuable now. Extraction optionality is a benefit, not the only purpose.
Boundary health checklist
- [ ] Top-level modules follow business capabilities.
- [ ] Public surfaces are narrow, typed, and documented.
- [ ] Static checks prohibit private cross-module imports.
- [ ] Each table, migration, and invariant has one module owner.
- [ ] Synchronous calls and events reflect consistency needs.
- [ ] External I/O does not accidentally extend local transactions.
- [ ] Modules have component and contract tests.
- [ ] Telemetry attributes cost and failure by module.
- [ ] Exceptions to boundaries have owners and expiry.
- [ ] Extraction requires measured operational evidence.
Strong boundaries are useful before the network
The subscription application can now evolve Accounts, Billing, and Entitlements with clear ownership while preserving one deployment and database. Engineers can trace calls in one debugger, use local transactions where they protect real invariants, and run the system without a fleet of dependencies. At the same time, private code and data do not leak across modules.
That is not a transitional architecture waiting to become microservices. It can be the long-term design. If a module later needs independent scaling, release cadence, security, or ownership, its public contract and data boundary provide a credible starting point. Compare the distribution bill in Microservices: The Trade-Offs, refine business seams with Domain-Driven Design, and consult /resources for implementation material.

