Skip to content

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.ts

Module 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:

  1. Public services live in /public directory - Each module exposes its public API through services in a dedicated /public folder
  2. Only adapters can import from other modules - Files in a module's /adapters directory are the only ones allowed to import from other modules' /public directories
  3. Modules export public services - Source modules export their public services through the NestJS module's exports array
  4. Adapters implement local interfaces - The consuming module defines its own interface for what it needs
  5. 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:

  1. no-nestjs-in-domain - Domain layer cannot import NestJS
  2. no-nestjs-in-use-cases - Use cases cannot import NestJS
  3. domain-isolation - Domain code can only import from its own domain folder
  4. no-cross-module-imports - Only adapters and module files can import from other modules
  5. no-shared-imports-outside-adapters - Only repositories and adapters can access shared infrastructure
  6. adapters-only-use-public-services - Adapters can only import from other modules' /public directories

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:graph

Run 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:

  1. 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);
  }
}
  1. 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