Building a Modular Monolith with NestJS: Best Practices for Maintainability

NestJS modular monoliths offer scalability and maintainability. Loosely coupled modules, dependency injection, and clear APIs enable independent development. Shared kernel and database per module approach enhance modularity and future flexibility.

Building a Modular Monolith with NestJS: Best Practices for Maintainability

Alright, let’s dive into the world of building modular monoliths with NestJS! If you’re like me, you’ve probably heard the term “microservices” thrown around a lot lately. But sometimes, a full-blown microservices architecture can be overkill for smaller projects. That’s where the modular monolith comes in handy.

NestJS is a fantastic framework for building scalable and maintainable server-side applications. It’s built on top of Express (or Fastify if you prefer) and provides a solid foundation for creating modular applications. So, why not combine the best of both worlds?

A modular monolith is essentially a single application that’s divided into loosely coupled modules. Each module has its own set of responsibilities and can be developed, tested, and deployed independently. This approach gives you the benefits of modularity without the complexity of a distributed system.

Let’s start by setting up a basic NestJS project. If you haven’t already, install the Nest CLI:

npm i -g @nestjs/cli

Now, create a new project:

nest new modular-monolith

Once your project is set up, let’s create a few modules to demonstrate the modular approach. We’ll create a user module and an order module:

nest generate module user
nest generate module order

Each module should have its own set of controllers, services, and entities. For example, in the user module:

// user.module.ts
import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
import { UserService } from './user.service';

@Module({
  controllers: [UserController],
  providers: [UserService],
  exports: [UserService],
})
export class UserModule {}

The key to building a maintainable modular monolith is to keep your modules loosely coupled. This means that each module should have a clear, well-defined API and shouldn’t depend directly on the internals of other modules.

One way to achieve this is by using dependency injection. NestJS has a powerful dependency injection system built-in. Let’s say our order module needs to access user information. Instead of directly importing the UserService, we can define an interface:

// user.interface.ts
export interface IUserService {
  findById(id: string): Promise<User>;
}

Now, in our OrderService, we can inject this interface:

// order.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { IUserService } from '../user/user.interface';

@Injectable()
export class OrderService {
  constructor(
    @Inject('USER_SERVICE') private readonly userService: IUserService,
  ) {}

  async createOrder(userId: string, items: string[]) {
    const user = await this.userService.findById(userId);
    // Create order logic here
  }
}

This approach allows us to easily swap out implementations or mock services for testing.

Another best practice for maintainability is to use a shared kernel. This is a small core of code that’s shared between all modules. It typically includes things like common interfaces, DTOs, and utility functions. Create a shared folder in your src directory for this purpose.

As your application grows, you might find that some modules are becoming too large. That’s when you can start thinking about splitting them into submodules. NestJS makes this easy with its module system. For example, you could split the user module into authentication and profile submodules:

// user.module.ts
import { Module } from '@nestjs/common';
import { AuthModule } from './auth/auth.module';
import { ProfileModule } from './profile/profile.module';

@Module({
  imports: [AuthModule, ProfileModule],
  exports: [AuthModule, ProfileModule],
})
export class UserModule {}

One of the challenges with a modular monolith is managing database access. While it’s tempting to have a single, shared database, this can lead to tight coupling between modules. Instead, consider using a database per module approach. This can be achieved using NestJS’s multi-database support:

// app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      name: 'userConnection',
      type: 'postgres',
      host: 'localhost',
      port: 5432,
      username: 'user',
      password: 'password',
      database: 'user_db',
      entities: [__dirname + '/user/**/*.entity{.ts,.js}'],
      synchronize: true,
    }),
    TypeOrmModule.forRoot({
      name: 'orderConnection',
      type: 'postgres',
      host: 'localhost',
      port: 5432,
      username: 'user',
      password: 'password',
      database: 'order_db',
      entities: [__dirname + '/order/**/*.entity{.ts,.js}'],
      synchronize: true,
    }),
  ],
})
export class AppModule {}

This setup allows each module to have its own database, reducing coupling and making it easier to potentially split into microservices in the future if needed.

As your modular monolith grows, you’ll want to ensure that it remains performant. NestJS provides several tools to help with this. One of my favorites is the use of interceptors for caching:

import { CacheInterceptor, UseInterceptors } from '@nestjs/common';

@UseInterceptors(CacheInterceptor)
@Get()
findAll() {
  return this.userService.findAll();
}

This simple decorator can significantly improve performance for frequently accessed, relatively static data.

Testing is crucial for maintaining a large application. NestJS provides excellent support for unit, integration, and e2e testing out of the box. Make sure to write tests for each module independently. This not only ensures that each module works correctly but also helps maintain the modularity of your application.

Here’s a quick example of a unit test for our UserService:

import { Test, TestingModule } from '@nestjs/testing';
import { UserService } from './user.service';

describe('UserService', () => {
  let service: UserService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [UserService],
    }).compile();

    service = module.get<UserService>(UserService);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  // Add more tests here
});

As your application grows, you might find that certain operations span multiple modules. In these cases, consider implementing the Saga pattern. This allows you to manage complex, multi-step processes while maintaining loose coupling between modules.

One last thing to keep in mind is documentation. With a modular monolith, it’s important to clearly document the API of each module. NestJS integrates well with Swagger, making it easy to generate API documentation:

import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';

const config = new DocumentBuilder()
  .setTitle('Modular Monolith API')
  .setDescription('API description')
  .setVersion('1.0')
  .build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, document);

Building a modular monolith with NestJS is a great way to create maintainable, scalable applications. By following these best practices, you can enjoy the benefits of modularity without the complexity of a full microservices architecture. Remember, the key is to keep your modules loosely coupled, use dependency injection wisely, and always think about the boundaries between your modules. Happy coding!