Appearance
Modular Monolith in NestJS
Building well-structured NestJS applications with clear module boundaries
Implements: Modular Monolith | Coupling and Cohesion
Overview
NestJS's module system provides an ideal foundation for building modular monoliths. Each NestJS module can represent a bounded context with its own domain logic, use cases, and infrastructure—all while sharing a single deployment unit.
Module Architecture
Feature-Based Module Structure
Each business domain is encapsulated in its own NestJS module following Clean Architecture:
typescript
// src/modules/orders/orders.module.ts
@Module({
imports: [CommonModule],
controllers: [OrdersController],
providers: [
// Use Cases
CreateOrderUseCase,
ProcessPaymentUseCase,
ShipOrderUseCase,
// Repositories
{
provide: 'ORDER_REPOSITORY',
useClass: PrismaOrderRepository,
},
// Services
OrderDomainService,
],
exports: [
// Only export public interfaces
OrdersService, // Public API for other modules
],
})
export class OrdersModule {}Directory Organization
Each domain module lives in the /src/modules directory with a consistent structure:
src/modules/
├── auth/
│ ├── domain/
│ │ ├── entities/
│ │ │ └── user.entity.ts
│ │ └── errors/
│ ├── use-cases/
│ │ ├── login.use-case.ts
│ │ └── register.use-case.ts
│ ├── infrastructure/
│ │ ├── auth.controller.ts
│ │ ├── user.repository.ts
│ │ └── dto/
│ ├── public/ # Public API for other modules
│ │ ├── auth.public-service.interface.ts
│ │ └── auth.public-service.ts
│ ├── adapters/ # Imports from other modules
│ │ └── organization.adapter.ts
│ ├── tokens.ts # DI tokens
│ ├── index.ts # Module exports
│ └── auth.module.ts
├── organization/
│ ├── domain/
│ │ ├── entities/
│ │ │ └── organization.entity.ts
│ │ └── errors/
│ ├── use-cases/
│ │ ├── get-organization.use-case.ts
│ │ └── update-organization.use-case.ts
│ ├── infrastructure/
│ │ └── organization.repository.ts
│ ├── public/ # Public API
│ │ ├── organization.public-service.interface.ts
│ │ └── organization.public-service.ts
│ ├── tokens.ts
│ ├── index.ts
│ └── organization.module.ts
└── task/
├── domain/
├── use-cases/
├── infrastructure/
├── adapters/ # Uses other modules
│ └── organization.adapter.ts
└── task.module.tsModule Boundaries
Public API Design
Each module exposes a public API through a dedicated /public directory that other modules can depend on. The public directory contains both an interface and implementation:
typescript
// modules/organization/public/organization.public-service.interface.ts
export interface OrganizationPublicService {
getOrganizationById(id: string): Promise<Organization | null>;
getOrganizationByIdentityOrgId(identityOrganizationId: string): Promise<Organization | null>;
assignIdentityOrganization(organizationId: string, identityOrganizationId: string): Promise<Organization>;
}
// modules/organization/public/organization.public-service.ts
@Injectable()
export class OrganizationPublicServiceImpl implements OrganizationPublicService {
constructor(
private readonly getOrganizationUseCase: GetOrganizationUseCase,
private readonly getOrganizationByIdentityOrgIdUseCase: GetOrganizationByIdentityOrgIdUseCase,
private readonly updateOrganizationUseCase: UpdateOrganizationUseCase,
) {}
async getOrganizationById(id: string): Promise<Organization | null> {
return this.getOrganizationUseCase.execute(id);
}
async getOrganizationByIdentityOrgId(identityOrganizationId: string): Promise<Organization | null> {
return this.getOrganizationByIdentityOrgIdUseCase.execute(identityOrganizationId);
}
async assignIdentityOrganization(
organizationId: string,
identityOrganizationId: string,
): Promise<Organization> {
try {
return await this.updateOrganizationUseCase.execute({
id: organizationId,
identityOrganizationId,
});
} catch (error) {
// Convert domain errors to NestJS HTTP exceptions
if (error instanceof OrganizationNotFoundError) {
throw new NotFoundException(error.message);
}
throw error;
}
}
}Key principles:
- Interface-first: Define what the module exposes as an interface
- Selective exposure: Only expose methods that other modules actually need
- Error translation: Convert domain errors to framework exceptions at the boundary
- Use case orchestration: Public services delegate to use cases, not domain services directly
Private Implementation
Everything except the public API remains private to the module:
typescript
// This stays private to the orders module
class OrderDomainService {
calculateTotal(items: OrderItem[]): Money {
// Domain logic that other modules shouldn't access directly
}
}
// Repository implementation is private
@Injectable()
class PrismaOrderRepository implements OrderRepositoryInterface {
// Other modules can't access this directly
}Inter-Module Communication
The Adapter Pattern for Module Boundaries
To maintain strict module boundaries, inter-module communication follows a specific pattern:
- Public services live in
/publicdirectory - Each module exposes its public API through services in a dedicated/publicfolder - Only adapters can import from other modules - Files in a module's
/adaptersdirectory are the only ones allowed to import from other modules'/publicdirectories - Modules export public services - Source modules export their public services through the NestJS module's
exportsarray - Adapters implement local interfaces - The consuming module defines its own interface for what it needs
- Dependency injection wires it together - NestJS DI provides the concrete implementation
This structure is enforced by dependency-cruiser rules that prevent any module code (except adapters) from importing from other modules.
Example: Task Module Using Organization Module
When the task module needs organization functionality:
typescript
// Step 1: Organization module exposes public API
// modules/organization/public/organization.public-service.interface.ts
export interface OrganizationPublicService {
getOrganizationById(id: string): Promise<Organization | null>;
}
// modules/organization/public/organization.public-service.ts
@Injectable()
export class OrganizationPublicServiceImpl implements OrganizationPublicService {
constructor(private readonly getOrganizationUseCase: GetOrganizationUseCase) {}
async getOrganizationById(id: string): Promise<Organization | null> {
return this.getOrganizationUseCase.execute(id);
}
}
// modules/organization/organization.module.ts
@Module({
providers: [
GetOrganizationUseCase,
// ... other providers
{
provide: 'ORGANIZATION_PUBLIC_SERVICE',
useClass: OrganizationPublicServiceImpl,
}
],
exports: ['ORGANIZATION_PUBLIC_SERVICE'], // Export public service
})
export class OrganizationModule {}
// Step 2: Task module defines what it needs (local interface)
// modules/task/domain/interfaces/organization-provider.interface.ts
export interface OrganizationProvider {
findOrganization(organizationId: string): Promise<Organization | null>;
}
// Step 3: Task module creates adapter (ONLY place that imports from organization)
// modules/task/adapters/organization.adapter.ts
import { Injectable, Inject } from '@nestjs/common';
import { OrganizationPublicService } from '../../organization/public/organization.public-service.interface';
import { OrganizationProvider } from '../domain/interfaces/organization-provider.interface';
@Injectable()
export class OrganizationAdapter implements OrganizationProvider {
constructor(
@Inject('ORGANIZATION_PUBLIC_SERVICE')
private readonly organizationPublicService: OrganizationPublicService,
) {}
async findOrganization(organizationId: string): Promise<Organization | null> {
// Adapter translates between the public API and what task module needs
return this.organizationPublicService.getOrganizationById(organizationId);
}
}
// Step 4: Wire it up in task module
// modules/task/task.module.ts
@Module({
imports: [OrganizationModule], // Import to get access to public service
providers: [
OrganizationAdapter,
{
provide: 'ORGANIZATION_PROVIDER',
useClass: OrganizationAdapter,
},
CreateTaskUseCase,
// ... other providers
],
})
export class TaskModule {}
// Step 5: Use the interface in task use cases
// modules/task/use-cases/create-task.use-case.ts
export class CreateTaskUseCase {
constructor(
@Inject('ORGANIZATION_PROVIDER')
private readonly organizationProvider: OrganizationProvider,
) {}
async execute(data: CreateTaskDto) {
// Verify organization exists using our local interface
const org = await this.organizationProvider.findOrganization(data.organizationId);
if (!org) {
throw new NotFoundException('Organization not found');
}
// ... create task
}
}Benefits of the Adapter Pattern
- Clear boundaries - Only adapters can cross module boundaries
- Interface segregation - Modules only expose what they need, not entire services
- Testability - Easy to mock interfaces for testing
- Loose coupling - Modules depend on abstractions they control
- Evolution - Modules can change independently as long as contracts are maintained
Enforcing Module Boundaries with Dependency Cruiser
To ensure these architectural boundaries are maintained, we use dependency-cruiser to automatically enforce architectural rules. The configuration includes six key rules:
- no-nestjs-in-domain - Domain layer cannot import NestJS
- no-nestjs-in-use-cases - Use cases cannot import NestJS
- domain-isolation - Domain code can only import from its own domain folder
- no-cross-module-imports - Only adapters and module files can import from other modules
- no-shared-imports-outside-adapters - Only repositories and adapters can access shared infrastructure
- adapters-only-use-public-services - Adapters can only import from other modules'
/publicdirectories
Here's the core rule that enforces the adapter pattern:
javascript
{
name: 'no-cross-module-imports',
comment: 'Only adapters and main module files can import from other modules',
severity: 'error',
from: {
path: '^src/modules/([^/]+)/',
pathNot: [
'^src/modules/[^/]+/adapters/',
'^src/modules/[^/]+/[^/]+\\.module\\.(ts|js)$'
],
},
to: {
path: '^src/modules/(?!$1)[^/]+/',
pathNot: [
'^src/modules/auth/infrastructure/guards/',
],
},
}And the rule that enforces the /public directory pattern:
javascript
{
name: 'adapters-only-use-public-services',
comment: 'Adapters can only depend on public services from other modules',
severity: 'error',
from: {
path: '^src/modules/([^/]+)/adapters/',
},
to: {
path: '^src/modules/(?!$1)[^/]+/',
pathNot: [
'^src/modules/[^/]+/public/', // Only public/ directory allowed
'^src/modules/[^/]+/[^/]+\\.module\\.(ts|js)$', // Module files allowed
],
},
}Usage:
bash
# Validate module boundaries
npm run lint:dependencies
# Generate visual dependency graph
npm run lint:dependencies:graphRun npm run lint:dependencies in CI to prevent architectural violations from being merged.
See the complete Dependency Cruiser Configuration with all rules and detailed explanations.
Event-Driven Communication
For loose coupling, use NestJS's event system:
typescript
// modules/orders/events/order-placed.event.ts
export class OrderPlacedEvent {
constructor(
public readonly orderId: string,
public readonly customerId: string,
public readonly total: number,
) {}
}
// Publishing events
@Injectable()
export class CreateOrderUseCase {
constructor(private eventEmitter: EventEmitter2) {}
async execute(request: CreateOrderRequest): Promise<Order> {
const order = await this.createOrder(request);
// Emit event for other modules
this.eventEmitter.emit(
'order.placed',
new OrderPlacedEvent(order.id, order.customerId, order.total),
);
return order;
}
}
// Subscribing to events in another module
@Injectable()
export class InventoryEventHandler {
@OnEvent('order.placed')
async handleOrderPlaced(event: OrderPlacedEvent) {
await this.reserveInventory(event.orderId);
}
}Shared Kernel
Common types and utilities can be shared across modules:
typescript
// modules/shared/domain/money.value-object.ts
export class Money {
constructor(
private readonly amount: number,
private readonly currency: string,
) {}
add(other: Money): Money {
// Shared value object logic
}
}
// modules/shared/shared.module.ts
@Global()
@Module({
providers: [DateService, IdGenerator],
exports: [DateService, IdGenerator],
})
export class SharedModule {}Module Registration
Dependency Injection Tokens
Use injection tokens for loose coupling:
typescript
// modules/orders/orders.module.ts
@Module({
providers: [
{
provide: 'ORDER_REPOSITORY',
useClass: PrismaOrderRepository,
},
{
provide: 'PAYMENT_GATEWAY',
useClass: StripePaymentGateway,
},
],
})
export class OrdersModule {}
// Using tokens in use cases
@Injectable()
export class ProcessPaymentUseCase {
constructor(
@Inject('ORDER_REPOSITORY')
private orderRepository: OrderRepositoryInterface,
@Inject('PAYMENT_GATEWAY')
private paymentGateway: PaymentGatewayInterface,
) {}
}Module Imports
Keep module dependencies explicit and minimal:
typescript
@Module({
imports: [
SharedModule, // Shared utilities
AuthModule, // For authentication
// Don't import modules you don't directly need
],
// ...
})
export class OrdersModule {}Data Isolation
Logical Database Separation
Even in a shared database, maintain logical separation:
typescript
// Each module uses its own schema/tables
// modules/orders/infrastructure/entities/order.entity.ts
@Entity('orders_order') // Prefixed table name
export class OrderEntity {
@PrimaryColumn()
id: string;
@Column()
customer_id: string; // No foreign key to users table
// ...
}
// modules/users/infrastructure/entities/user.entity.ts
@Entity('users_user') // Different prefix
export class UserEntity {
@PrimaryColumn()
id: string;
// ...
}No Cross-Module Queries
Repositories should never join across module boundaries:
typescript
// ❌ Bad: Cross-module database query
const ordersWithUsers = await this.db.query(`
SELECT o.*, u.name
FROM orders_order o
JOIN users_user u ON o.customer_id = u.id
`);
// ✅ Good: Use service APIs
const order = await this.orderRepository.findById(orderId);
const user = await this.usersService.getUser(order.customerId);Evolution Path
Starting Simple
Begin with coarse-grained modules:
typescript
// Initial structure
@Module({
imports: [
CoreModule, // Auth, users, basic settings
CommerceModule, // Orders, products, inventory
FulfillmentModule // Shipping, tracking
],
})
export class AppModule {}Splitting Modules
As complexity grows, split modules along business lines:
typescript
// After growth
@Module({
imports: [
AuthModule,
UsersModule,
OrdersModule,
InventoryModule,
ProductsModule,
ShippingModule,
TrackingModule,
],
})
export class AppModule {}Extracting to Microservices
When needed, extract modules with minimal changes:
- Create service wrapper:
typescript
// From direct module use
@Injectable()
export class OrdersService {
async createOrder(data: CreateOrderDto): Promise<Order> {
// Local implementation
}
}
// To HTTP client
@Injectable()
export class OrdersServiceClient implements OrdersService {
async createOrder(data: CreateOrderDto): Promise<Order> {
return this.http.post('http://orders-service/orders', data);
}
}- Update module provider:
typescript
{
provide: OrdersService,
useClass: process.env.ORDERS_SERVICE_URL
? OrdersServiceClient
: OrdersService,
}Best Practices
DO ✅
- Define clear module boundaries based on business capabilities
- Export only public interfaces from modules
- Use dependency injection tokens for flexibility
- Maintain separate test suites per module
- Document module APIs clearly
DON'T ❌
- Share database tables between modules
- Create circular dependencies between modules
- Expose internal implementations through module exports
- Bypass module APIs to access data
- Couple modules to each other's DTOs or internal types
Example: Complete Module
typescript
// modules/payments/payments.module.ts
@Module({
imports: [SharedModule],
controllers: [PaymentsController],
providers: [
// Use Cases
ProcessPaymentUseCase,
RefundPaymentUseCase,
GetPaymentStatusUseCase,
// Domain Services
PaymentDomainService,
FeeCalculator,
// Infrastructure
{
provide: 'PAYMENT_REPOSITORY',
useClass: PrismaPaymentRepository,
},
{
provide: 'PAYMENT_GATEWAY',
useFactory: (config: ConfigService) => {
return config.get('payment.provider') === 'stripe'
? new StripeGateway(config)
: new PayPalGateway(config);
},
inject: [ConfigService],
},
// Public API
PaymentsService,
// Event Handlers
PaymentEventHandler,
],
exports: [
PaymentsService, // Only the public API
],
})
export class PaymentsModule {}Next Steps
- Learn about Clean Architecture in NestJS Modules
- Understand Repository Pattern in NestJS
- Explore Integration Testing for module boundaries
Related Concepts
- Modular Monolith - Theoretical foundation
- Coupling and Cohesion - Design principles
- Dependency Inversion - Decoupling strategies