The Modular Monolith: Vertical Slices, Bounded Contexts, and Module Boundaries in TypeScript
A practical guide to structuring a TypeScript backend as a modular monolith. Covers bounded context design, vertical slice architecture, enforcing module isolation at the code level, inter-module communication via events, and the concrete signals that justify extracting a module into a separate service.
Most teams that reach for microservices early are solving an organizational problem they have not yet diagnosed. The real problem is usually a codebase without module boundaries. Every file can import every other file. Business logic leaks across concerns. Changing the billing rules requires touching the user profile module because someone added a convenience shortcut six months ago. The proposed fix is to put a network boundary between those modules and call them services.
That fix is expensive. Distributed systems add operational surface area: serialization, network timeouts, distributed tracing, eventual consistency, independent deployments. If the underlying problem is that your code has no internal discipline, microservices will not solve it. You will have the same tangled logic, now distributed across HTTP calls.
The modular monolith is the alternative most teams skip. It enforces the same architectural discipline as microservices without the operational tax. Modules own their domain. Public APIs are explicit. Internal state is not shared. When you later need to extract a service, the boundary is already there. You are adding a network interface, not untangling five years of shared state.
What a Bounded Context Actually Means
Bounded context is a term from Domain-Driven Design that gets misused constantly. The definition is concrete: it is the boundary within which a particular domain model is valid and consistent.
Take a SaaS product with billing, identity, and order management. The word “account” means different things in each context:
- In identity, an account is a user entity with credentials, session state, and permission grants.
- In billing, an account is a billing profile with a plan, payment method, and invoice history.
- In orders, an account is a customer with a shipping address and order history.
If you share a single Account model across all three, it becomes a God object that satisfies no context well and satisfies every context partially. Changes for billing requirements break identity assumptions. Adding a shipping address field to satisfy orders clutters the billing view.
The bounded context solution: each module has its own model of the concept it needs. The identity module has User. The billing module has BillingAccount. The orders module has Customer. These are different types, even when they refer to the same underlying entity. They evolve independently.
// identity/types.ts
export interface User {
id: string;
email: string;
hashedPassword: string;
mfaEnabled: boolean;
createdAt: Date;
}
// billing/types.ts
export interface BillingAccount {
userId: string; // reference to identity, not an import of User
plan: "free" | "pro" | "enterprise";
stripeCustomerId: string;
trialEndsAt: Date | null;
}
// orders/types.ts
export interface Customer {
userId: string; // same reference, separate model
displayName: string;
defaultShippingAddress: Address;
orderCount: number;
}
This is not duplication. The billing module does not need to know whether MFA is enabled. The orders module does not need the Stripe customer ID. Each context carries exactly the data it needs, nothing more.
Organizing by Vertical Slice
The default organizing structure for most backends is horizontal layers: a controllers/ directory, a services/ directory, a repositories/ directory. Every feature cuts vertically through all layers. This means that adding a new billing feature requires touching four different directories, each containing unrelated code from every other feature.
Vertical slice architecture inverts this. Each feature or capability is organized together in one place. The layers exist within the feature, not above it.
A practical structure for a TypeScript backend:
src/
modules/
billing/
index.ts ← public API (the only import surface)
commands/
create-subscription.ts
cancel-subscription.ts
update-payment-method.ts
queries/
get-subscription.ts
list-invoices.ts
events/
subscription-created.event.ts
subscription-cancelled.event.ts
internal/
stripe-client.ts
proration-calculator.ts
invoice-builder.ts
db/
billing.schema.ts
billing.repository.ts
billing.types.ts
identity/
index.ts
commands/
register-user.ts
change-password.ts
queries/
get-user-by-email.ts
internal/
password-hasher.ts
session-manager.ts
db/
identity.schema.ts
identity.repository.ts
identity.types.ts
orders/
index.ts
...
Each module contains its own commands, queries, events, internal implementation, and database layer. Nothing is shared across module boundaries except through the index.ts public API.
Enforcing Module Boundaries
Structure is not discipline. Without enforcement, imports will leak. Someone under deadline pressure will write import { calculateProration } from '../billing/internal/proration-calculator' and now you have a hidden dependency on a private implementation detail.
There are three practical enforcement mechanisms.
Barrel exports as the only import surface
The index.ts of each module exports only what other modules are allowed to use.
// billing/index.ts
export { createSubscription } from "./commands/create-subscription";
export { cancelSubscription } from "./commands/cancel-subscription";
export { getSubscription } from "./queries/get-subscription";
export { listInvoices } from "./queries/list-invoices";
export type { BillingAccount, Invoice, SubscriptionPlan } from "./billing.types";
// Internal helpers, repository internals, Stripe client: not exported.
// Other modules never know they exist.
Any import of billing/* beyond the index is a boundary violation.
ESLint import rules
The eslint-plugin-boundaries package enforces import rules at lint time. Configure it once and the CI pipeline rejects violations without manual review.
// eslint.config.js (flat config)
import boundaries from "eslint-plugin-boundaries";
export default [
{
plugins: { boundaries },
rules: {
"boundaries/element-types": [
"error",
{
default: "disallow",
rules: [
// Modules can import from shared utilities
{ from: "module", allow: ["shared"] },
// Modules can import from other modules' public index only
// (path matching enforces no deep imports)
],
},
],
// No relative imports crossing module boundaries
"no-restricted-imports": [
"error",
{
patterns: [
"../billing/internal/*",
"../identity/internal/*",
"../orders/internal/*",
],
},
],
},
},
];
For stricter enforcement, TypeScript path aliases can make internal directories unreachable by convention. Map @billing to src/modules/billing/index.ts. Any import of src/modules/billing/internal/... bypasses the alias and can be flagged by lint or rejected in code review with a clear rule.
Database table ownership
Each module owns its tables. No other module queries those tables directly.
// billing/db/billing.repository.ts
// This is the ONLY file in the entire codebase that queries billing_accounts,
// invoices, or subscriptions tables.
export class BillingRepository {
async findAccountByUserId(userId: string): Promise<BillingAccount | null> {
return db
.selectFrom("billing_accounts")
.where("user_id", "=", userId)
.selectAll()
.executeTakeFirst() ?? null;
}
async createInvoice(input: CreateInvoiceInput): Promise<Invoice> {
return db
.insertInto("invoices")
.values({ ...input, created_at: new Date() })
.returningAll()
.executeTakeFirstOrThrow();
}
}
If the orders module needs billing information, it calls billing.getSubscription(userId) through the public API. It never touches the billing_accounts table directly. This is non-negotiable. Shared table access is how you get a distributed monolith even without microservices.
Inter-Module Communication via Events
Direct function calls between modules work well for synchronous queries: the orders module calling billing.getSubscription() to check a customer’s plan before creating an order. But some interactions should not be synchronous, and some should not create hard dependencies between modules.
When an order is confirmed, billing needs to create an invoice. The orders module should not have to know that billing exists. That is coupling in the wrong direction.
An in-process event bus solves this cleanly:
// shared/event-bus.ts
type EventHandler<T> = (event: T) => Promise<void>;
class EventBus {
private handlers: Map<string, EventHandler<unknown>[]> = new Map();
subscribe<T>(eventType: string, handler: EventHandler<T>): void {
const existing = this.handlers.get(eventType) ?? [];
this.handlers.set(eventType, [...existing, handler as EventHandler<unknown>]);
}
async publish<T>(eventType: string, payload: T): Promise<void> {
const handlers = this.handlers.get(eventType) ?? [];
// Run all handlers; collect errors without short-circuiting
const results = await Promise.allSettled(
handlers.map((h) => h(payload as unknown))
);
const failures = results.filter((r) => r.status === "rejected");
if (failures.length > 0) {
// Log failures; decide whether to rethrow based on your requirements
failures.forEach((f) =>
logger.error("event-handler-failed", {
eventType,
reason: (f as PromiseRejectedResult).reason,
})
);
}
}
}
export const eventBus = new EventBus();
The orders module publishes events without knowing who listens:
// orders/commands/confirm-order.ts
import { eventBus } from "@shared/event-bus";
import type { OrderConfirmedEvent } from "./events/order-confirmed.event";
export async function confirmOrder(orderId: string): Promise<Order> {
const order = await orderRepository.markConfirmed(orderId);
await eventBus.publish<OrderConfirmedEvent>("order.confirmed", {
orderId: order.id,
customerId: order.customerId,
lineItems: order.lineItems,
totalCents: order.totalCents,
confirmedAt: order.confirmedAt.toISOString(),
});
return order;
}
The billing module subscribes and handles the invoice creation independently:
// billing/index.ts (registration side)
import { eventBus } from "@shared/event-bus";
import type { OrderConfirmedEvent } from "@orders/events/order-confirmed.event";
import { createInvoiceFromOrder } from "./commands/create-invoice";
export function registerBillingEventHandlers(): void {
eventBus.subscribe<OrderConfirmedEvent>(
"order.confirmed",
async (event) => {
await createInvoiceFromOrder({
orderId: event.orderId,
customerId: event.customerId,
lineItems: event.lineItems,
totalCents: event.totalCents,
});
}
);
}
Registration happens at startup, not at import time. Wire everything in your application entry point:
// src/app.ts
import { registerBillingEventHandlers } from "@billing";
import { registerNotificationEventHandlers } from "@notifications";
registerBillingEventHandlers();
registerNotificationEventHandlers();
// Now start the server
startServer();
Notice that the event type definition lives in orders, but billing imports the type for handler registration. This is acceptable: the event schema is part of orders’ public contract. What billing does not import is any orders business logic.
Tradeoffs at Each Layer
| Concern | Modular monolith | Microservices |
|---|---|---|
| Operational complexity | Low: one deploy unit, one process | High: service registry, distributed tracing, independent CI/CD per service |
| Module isolation | Enforced by code conventions and linting | Enforced by the network (hard boundary) |
| Cross-module transactions | Simple: one database transaction | Complex: sagas, two-phase commit, compensating transactions |
| Team autonomy | Partial: teams can own modules but share a deploy pipeline | Full: teams deploy and scale independently |
| Failure isolation | Partial: process crash kills all modules | Full: one service fails, others continue |
| Development speed (early) | Fast: no serialization, no network calls, local debugging is straightforward | Slow: local orchestration, mocking, distributed debugging overhead |
| Refactoring | Easier: rename, move, restructure with IDE tooling | Harder: API changes require versioning, backward compatibility |
| Scaling granularity | Coarse: scale the whole process | Fine: scale individual services by load profile |
The modular monolith wins on every early-stage dimension. The microservices advantages become real only at a scale most startups never reach, and only when team autonomy and scaling granularity are genuine bottlenecks, not theoretical ones.
Production Considerations
Keeping the event bus durable
The in-process event bus described above is not durable. If the process crashes after publishing an event but before all handlers complete, the event is lost. For most intra-module events this is acceptable: re-triggering the original action (re-confirming the order) re-publishes the event. For events that trigger external side effects (sending emails, calling Stripe), you need durability.
The outbox pattern addresses this without leaving the modular monolith: write the event to an outbox table in the same database transaction as the state change. A background worker reads from the outbox and delivers events to handlers. Handlers mark the event as processed. No distributed coordination required.
// Transactional outbox: write state + event atomically
await db.transaction().execute(async (trx) => {
const order = await trx
.insertInto("orders")
.values(orderData)
.returningAll()
.executeTakeFirstOrThrow();
await trx.insertInto("outbox_events").values({
event_type: "order.confirmed",
payload: JSON.stringify({ orderId: order.id, ...eventData }),
created_at: new Date(),
processed_at: null,
}).execute();
return order;
});
Shared schema migrations
One operational advantage of the modular monolith: a single migration file covers the whole system. You do not need to coordinate schema changes across services. The risk is that a bad migration can affect all modules. Mitigate this with backward-compatible migrations (add columns before removing old ones, never drop columns in the same deploy that removes code that uses them) and a staging environment that mirrors production schema.
Testing module boundaries
Each module should have its own integration test suite that tests through the public API, not through internal functions.
// billing/billing.integration.test.ts
// Tests the billing module through its public index only.
// Never imports from billing/internal/*.
import { createSubscription, getSubscription, cancelSubscription } from "@billing";
describe("billing module", () => {
it("creates a subscription and returns it", async () => {
const sub = await createSubscription({
userId: "user-001",
plan: "pro",
paymentMethodId: "pm_test_xxx",
});
expect(sub.plan).toBe("pro");
expect(sub.status).toBe("active");
});
it("cancels an active subscription at period end", async () => {
const sub = await createSubscription({ userId: "user-002", plan: "pro", paymentMethodId: "pm_test_xxx" });
const cancelled = await cancelSubscription(sub.id, { atPeriodEnd: true });
expect(cancelled.cancelAtPeriodEnd).toBe(true);
expect(cancelled.status).toBe("active"); // still active until period ends
});
});
If a test needs to reach into billing/internal/ to set up a fixture, that is a signal that the public API is incomplete or that the test is testing the wrong thing.
When to Extract a Module into a Service
The modular monolith is not a permanent destination. It is a better starting position than either a big ball of mud or premature microservices. Some modules will eventually warrant extraction. The signals are concrete:
Independent scaling is measurable and costly. Your report generation module runs CPU-heavy aggregations that consume the same process resources as your user-facing API. Provisioning enough CPU for report generation means over-provisioning for the API and vice versa. Measure the cost delta before and after. If it is significant, extraction pays for itself.
Deployment independence has organizational value. A team that owns the billing module ships six times a week and every deploy runs the full test suite for all modules. If the full suite takes 20 minutes and billing tests take 2 minutes, 18 minutes per deploy is time that team is not shipping. When that friction is real and measured, service extraction gives the team a deploy pipeline they control.
Failure isolation is a hard business requirement. A bug in one module should not be able to take down checkout. In a monolith, a panic or memory exhaustion in any module can crash the process. If checkout uptime requirements are significantly higher than other parts of the system, process isolation may be worth the cost.
The module boundary is already clean. If you have followed the patterns above, extraction is a matter of putting a network interface in front of an existing clean boundary. The billing module already has a public API. The internal implementation is already hidden. Event-based communication is already working. You are wrapping what exists, not redesigning.
Do not extract a module when:
- The boundary is tangled and extraction would require a rewrite.
- You cannot name the specific problem extraction will solve.
- The team that will own the service does not exist yet.
- You lack the operational infrastructure (distributed tracing, per-service CI/CD, service discovery).
The modular monolith earns you the right to extract later from a position of strength. You are not racing to decompose. You are building something coherent that you can operate and evolve, and that can grow in complexity without collapsing under its own weight.
Structure is not the enemy of speed. The codebase that has no module boundaries is the one that slows you down at scale. The boundaries are what let you move fast without breaking everything adjacent to what you changed.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.