Skip to content
ExponaBLOG
NESTJS · DAY 086 / 100

A modular monolith with NestJS: best practices explained

The architecture I recommend before microservices.

Expona Admin8 min read

A modular monolith with NestJS: best practices explained. The architecture I recommend before microservices.

Introduction

Most teams reach for microservices too early, trading deployment complexity for organizational clarity that they don't yet need. A modular monolith with NestJS offers a pragmatic middle ground: you get clear module boundaries, independent scaling potential, and team autonomy without the operational overhead of distributed systems. This is the architecture I recommend before microservices—a structured monolith that feels like a collection of services but runs as one process, letting you defer the microservice migration until you genuinely need it.

By day 86 of building with NestJS, you've likely encountered projects that needed better structure. This guide walks through building a modular monolith where each business domain owns its data, API, and events, with strict public contracts that make extraction to microservices straightforward when the time comes.

Modules as services — Each business module owns its data, API and events

In a modular monolith, a NestJS module isn't just a code organization unit—it's a business capability boundary. Think of your OrdersModule, PaymentsModule, and ShippingModule not as layers but as self-contained services that happen to live in the same codebase.

Each module should own three things:

  • Data layer — Its database schema, entities, and repositories.
  • Business logic — Services that implement the domain rules.
  • Public API — A controller or facade that other modules interact with.

Here's how a typical module looks:

typescript
// orders/orders.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OrderService } from './services/order.service';
import { OrderController } from './controllers/order.controller';
import { Order } from './entities/order.entity';

@Module({
  imports: [TypeOrmModule.forFeature([Order])],
  controllers: [OrderController],
  providers: [OrderService],
  exports: [OrderService], // Explicit public API
})
export class OrdersModule {}

Notice the exports array—this is your module's public interface. You're saying "other modules can import OrderService, but nothing else." The repositories, internal utility services, and database queries stay private.

When another module needs order data, it calls OrderService methods, not raw queries. This keeps the contract explicit and makes refactoring safe.

Strict public APIs — Modules expose a facade service. Internals stay private

The moment you let other modules query your repositories directly, you've lost your boundary. A common anti-pattern in growing codebases is exporting everything and pretending privacy will emerge later—it won't.

Instead, each module exports a facade service that acts as a gatekeeper. This service defines what other modules are allowed to do with your domain.

typescript
// orders/services/order.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Order } from '../entities/order.entity';

@Injectable()
export class OrderService {
  constructor(
    @InjectRepository(Order)
    private orderRepository: Repository,
  ) {}

  // Public methods — this is what other modules can call
  async getOrderById(orderId: string): Promise {
    return this.orderRepository.findOneBy({ id: orderId });
  }

  async createOrder(data: CreateOrderDto): Promise {
    const order = this.orderRepository.create(data);
    return this.orderRepository.save(order);
  }

  async updateOrderStatus(
    orderId: string,
    status: OrderStatus,
  ): Promise {
    await this.orderRepository.update(orderId, { status });
    return this.getOrderById(orderId);
  }

  // Private helper — not exported
  private async validateOrderEligibility(customerId: string): Promise {
    // Business logic internal to orders
    return true;
  }
}

// orders/orders.module.ts
@Module({
  imports: [TypeOrmModule.forFeature([Order])],
  providers: [OrderService],
  exports: [OrderService], // Only export the facade
})
export class OrdersModule {}

Now, when the ShippingModule needs order information, it injects OrderService and calls getOrderById(). It cannot reach the repository, cannot run raw queries, cannot sidestep your business logic. This is what "strict public API" means.

Over time, as you discover which methods get called most often, you can optimize and refactor the service without breaking dependents—as long as the public method signatures stay stable.

Separate schemas — Each module uses its own Postgres schema. No cross-module joins

A critical mistake in monolith-to-microservice transitions is shared database schemas. When the OrdersModule and PaymentsModule` share a schema and do cross-table joins, extracting one of them later becomes a data migration nightmare.

Instead, use separate Postgres schemas (or separate databases, depending on your scale). Each module owns its schema and all the tables within it. Foreign keys point only within your own schema.

typescript
// orders/entities/order.entity.ts
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';

@Entity('orders', { schema: 'orders_schema' })
export class Order {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column('uuid')
  customerId: string; // Reference, not a FK to customers_schema.customers

  @Column('text')
  status: 'pending' | 'confirmed' | 'shipped' | 'delivered';

  @Column('decimal', { precision: 10, scale: 2 })
  total: number;

  @CreateDateColumn()
  createdAt: Date;
}

// payments/entities/payment.entity.ts
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';

@Entity('payments', { schema: 'payments_schema' })
export class Payment {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column('uuid')
  orderId: string; // Reference, not a FK to orders_schema.orders

  @Column('text')
  status: 'pending' | 'completed' | 'failed' | 'refunded';

  @Column('decimal', { precision: 10, scale: 2 })
  amount: number;

  @CreateDateColumn()
  createdAt: Date;
}

When PaymentsModule needs to look up an order, it calls OrderService.getOrderById(orderId)—an in-process call today, an HTTP call tomorrow. The data model is identical from the consumer's perspective; the transport layer is an implementation detail.

This also prevents the "one query touches too many schemas" problem. If you need data from multiple modules, you write multiple queries and compose them in application code. Yes, this feels less efficient initially, but it forces you to think about cross-module interactions and often reveals places where you should cache or denormalize.

Extract later — When a module needs independent scaling, it already has clean boundaries

The payoff of this structure is that extraction to a microservice becomes a straightforward refactoring, not a rewrite.

Imagine your PaymentsModule` is getting hammered with traffic. Today it lives in your monolith and calls OrderService in-process. Tomorrow, you want to extract it to its own service. The work is mechanical:

  1. Copy the module code into a new NestJS project.
  2. Replace in-process OrderService calls with HTTP client calls to the orders service.
  3. Set up the new service's database (already isolated in payments_schema).
  4. Deploy and point traffic to it.

Because your module boundary was strict from day one, there's minimal data coupling to untangle. You're not rewriting queries or moving domain logic around.

typescript
// In the monolith: PaymentsService injects OrderService directly
@Injectable()
export class PaymentsService {
  constructor(
    @InjectRepository(Payment) private paymentRepository: Repository,
    private orderService: OrderService, // In-process call
  ) {}

  async processPayment(orderId: string, amount: number): Promise {
    const order = await this.orderService.getOrderById(orderId);
    if (!order) throw new NotFoundException('Order not found');
    // Process payment...
  }
}

// Later, in the microservice: PaymentsService injects an HTTP client
@Injectable()
export class PaymentsService {
  constructor(
    @InjectRepository(Payment) private paymentRepository: Repository,
    private ordersClient: OrdersClient, // HTTP call
  ) {}

  async processPayment(orderId: string, amount: number): Promise {
    const order = await this.ordersClient.getOrderById(orderId);
    if (!order) throw new NotFoundException('Order not found');
    // Process payment...
  }
}

The business logic inside processPayment doesn't change. You've swapped the transport layer, and the rest follows naturally. This is why the facade pattern matters—the contract is all that binds you.

Common mistakes

Exporting repositories or query builders: Don't let other modules use your TypeORM repositories directly. Even if it seems faster, it couples them to your schema structure and makes refactoring dangerous. Always go through a service facade.

Doing cross-schema joins in queries: Writing a single SQL query that joins orders_schema.orders and payments_schema.payments feels efficient but embeds your schema design in the query layer. Fetch from one schema, then fetch from another in application code. It's slower but clearer.

Treating modules as layers, not domains: A common error is creating an "auth" module that exports utilities, a "utils" module with helpers, and then having every domain module depend on them. Modules should represent business domains (Orders, Payments, Shipping), not technical functions. Cross-cutting concerns like logging or auth should be global middleware or guards, not modules other modules depend on.

Ignoring inter-module communication patterns: As your monolith grows, modules will need to react to events from other modules (an order was placed, a payment succeeded). Start with synchronous service calls through the facade, but plan for event-driven communication using a message bus or queue. NestJS's event emitter is a good starting point; later, switch to Kafka or RabbitMQ without changing your business logic.

Key takeaways

  • A modular monolith with NestJS positions you between a tightly coupled ball of mud and the operational complexity of microservices.
  • Each module owns its data (its own Postgres schema), business logic, and a public API (facade service).
  • Strict module boundaries—controlled exports, no cross-module repository access, no cross-schema joins—make extraction to microservices a straightforward refactoring later.
  • Separate schemas force you to think about data flow and prevent the "shared schema" trap that makes microservice migration painful.
  • Plan for event-driven communication between modules early, even if you start with in-process service calls. The abstraction will pay off when you extract modules.
  • A well-structured monolith can scale for years and transition to microservices when (and if) you actually need them, without a painful rewrite.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles