Every architecture debate eventually defaults to false dichotomies: a spaghetti monolith where every service queries every table directly, versus twenty-four microservices run by a four-person team drowning in Kubernetes manifests and distributed tracing.
The modular monolith is not an evasive compromise; it is an active discipline. It gives you the operational simplicity of a single deployable artifact alongside the strict architectural encapsulation needed as a team and codebase mature.
Module boundaries in NestJS
In NestJS, module encapsulation is enforced at compile time. A module exports only explicit providers, while internal services, repositories, and domain helpers remain completely invisible to the rest of the application.
When building domain boundaries—such as separating BillingModule from CatalogModule—we disallow direct imports between internal services. If BillingModule needs catalog data, it communicates through a public domain facade interface or an asynchronous event emitter. This stops accidental tight coupling before it starts.
@Module({
imports: [TypeOrmModule.forFeature([InvoiceEntity])],
providers: [BillingService, InvoiceCalculator],
exports: [BillingService], // Only public facade exposed
})
export class BillingModule {}Shared types without tight coupling
The biggest trap in a TypeScript monorepo is sharing database entities across layers. When your frontend components import a Prisma model directly, any internal column rename breaks the client bundle.
We isolate contracts into dedicated packages containing pure Zod schemas and derived TypeScript types. The backend maps internal Prisma models into published data transfer contracts, ensuring that database schema evolution never inadvertently leaks into consumer interfaces.
import { z } from 'zod';
export const InvoiceSchema = z.object({
id: z.string().uuid(),
amountCents: z.number().int().positive(),
status: z.enum(['pending', 'settled', 'cancelled']),
issuedAt: z.string().datetime(),
});
export type Invoice = z.infer<typeof InvoiceSchema>;Knowing when to split a service
You do not split a service because it feels large; you split it when operational requirements diverge. If our PDF rendering service consumes 2GB of RAM and spikes CPU for fifteen seconds, running it inside the primary API process risks starving HTTP connection pools.
Extracting that single worker into an independent Docker container connected via BullMQ queue jobs preserves the modular monolith for 95% of the system while giving the resource-intensive worker dedicated hardware scaling.
