Back to Blog
Node.js7 min read

NestJS Enterprise Patterns I Wish I Knew Earlier

Hard-won lessons building production NestJS apps: module architecture, dependency injection gotchas, error handling, and patterns that scale.

Jay Salot

Jay Salot

Senior Full Stack AI Engineer

June 3, 2026 · 7 min read

Share
Developer working on laptop

Last year, we rebuilt a monolithic Express API serving 2M+ requests daily into a modular NestJS application. The migration took three months, and honestly, we made every mistake in the book. NestJS calls itself a "progressive Node.js framework," but that enterprise-grade power comes with complexity. Here's what I learned about patterns and practices that actually matter in production.

Why NestJS for Enterprise Node.js Apps

I resisted NestJS for years. Too much magic, too opinionated. But after maintaining a sprawling Express codebase where every developer had their own folder structure and error handling strategy, I get it now.

NestJS forces architectural decisions upfront. Modules, providers, controllers—it's all prescribed. This drives some developers crazy, but in teams of 5+ engineers, having one way to do things is invaluable. The TypeScript-first approach caught real bugs at compile time that would've been production incidents in plain Node.js.

The gotcha here is NestJS isn't just "Express with decorators." It's a complete paradigm shift. You need to think in dependency injection, module boundaries, and lifecycle hooks. If your team is small (1-2 devs) or building simple CRUD APIs, Express or Fastify might be faster. But for enterprise applications with complex business logic, microservices, or event-driven architectures, NestJS shines.

Module Architecture That Scales

The biggest mistake we made initially was creating one massive AppModule that imported everything. As the codebase grew, circular dependencies became a nightmare.

Domain-Driven Module Organization

We restructured around business domains, not technical layers. Instead of a ServicesModule and ControllersModule, we built UsersModule, OrdersModule, PaymentsModule. Each module owns its domain completely:

// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
import { UsersRepository } from './users.repository';

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UsersController],
  providers: [UsersService, UsersRepository],
  exports: [UsersService], // Only export what other modules need
})
export class UsersModule {}

The key is the exports array. Only expose the service layer, never repositories or internal implementation details. This creates clear boundaries and prevents tight coupling.

Shared Modules for Cross-Cutting Concerns

For utilities used everywhere (database, caching, logging), we created a SharedModule marked as global:

import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { RedisModule } from './redis/redis.module';
import { LoggerModule } from './logger/logger.module';

@Global()
@Module({
  imports: [ConfigModule, RedisModule, LoggerModule],
  exports: [ConfigModule, RedisModule, LoggerModule],
})
export class SharedModule {}

The @Global() decorator means you don't need to import this module everywhere. Use this sparingly—only for truly universal dependencies. We initially marked too many modules as global and lost the benefit of explicit imports.

Dependency Injection Done Right

NestJS's DI system is powerful but easy to misuse. I've seen teams inject 10+ dependencies into a single service, creating a god class that's impossible to test.

Constructor Injection Pattern

Always use constructor injection, never property injection. It makes dependencies explicit and testable:

@Injectable()
export class OrdersService {
  constructor(
    private readonly ordersRepository: OrdersRepository,
    private readonly paymentsService: PaymentsService,
    private readonly logger: LoggerService,
  ) {}

  async createOrder(dto: CreateOrderDto): Promise<Order> {
    this.logger.log(`Creating order for user ${dto.userId}`);
    // Implementation
  }
}

If you're injecting more than 5 dependencies, that's a code smell. The service is doing too much. Split it.

Custom Providers for Complex Scenarios

We had a scenario where our notification service needed different implementations based on environment (email in prod, console logs in dev). Custom providers solved this elegantly:

const notificationProvider = {
  provide: 'NOTIFICATION_SERVICE',
  useFactory: (configService: ConfigService) => {
    const env = configService.get('NODE_ENV');
    return env === 'production'
      ? new EmailNotificationService()
      : new ConsoleNotificationService();
  },
  inject: [ConfigService],
};

@Module({
  providers: [notificationProvider],
  exports: ['NOTIFICATION_SERVICE'],
})
export class NotificationsModule {}

Then inject with @Inject('NOTIFICATION_SERVICE'). This pattern also works great for feature flags or A/B testing different implementations.

Enterprise Error Handling

NestJS's exception filters are powerful, but the default error responses aren't production-ready. They leak implementation details and don't provide consistent error structures.

Global Exception Filter

We built a global exception filter that standardizes all error responses:

import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import { Response } from 'express';

@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest();

    let status = HttpStatus.INTERNAL_SERVER_ERROR;
    let message = 'Internal server error';
    let errors = [];

    if (exception instanceof HttpException) {
      status = exception.getStatus();
      const exceptionResponse = exception.getResponse();
      message = typeof exceptionResponse === 'string' 
        ? exceptionResponse 
        : (exceptionResponse as any).message;
      errors = (exceptionResponse as any).errors || [];
    }

    // Log the full error for debugging
    console.error('Exception:', exception);

    response.status(status).json({
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
      message,
      errors,
    });
  }
}

Register it globally in main.ts:

app.useGlobalFilters(new GlobalExceptionFilter());

This caught so many edge cases we didn't handle properly. Database connection errors, third-party API timeouts, validation failures—all return consistent JSON structures now.

Validation and Transformation Pipelines

The class-validator and class-transformer integration is one of NestJS's killer features. But there's a gotcha: by default, it doesn't strip unknown properties.

DTO Validation Setup

Enable whitelist and forbidNonWhitelisted to prevent mass assignment vulnerabilities:

// main.ts
app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true, // Strip properties without decorators
    forbidNonWhitelisted: true, // Throw error if extra properties sent
    transform: true, // Auto-transform payloads to DTO instances
    transformOptions: {
      enableImplicitConversion: true, // Convert primitive types
    },
  }),
);

Then create strict DTOs:

import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';

export class CreateUserDto {
  @IsEmail()
  email: string;

  @IsString()
  @MinLength(8)
  password: string;

  @IsString()
  @IsOptional()
  name?: string;
}

This bit me in production once. A client sent an isAdmin: true field we didn't expect. Without whitelist, it passed through and created an admin user. Not good.

Database Patterns and Repository Layer

NestJS works with TypeORM, Prisma, Mongoose—we use TypeORM with PostgreSQL. The pattern that saved us was abstracting repositories.

Custom Repository Pattern

Don't use TypeORM repositories directly in services. Wrap them:

@Injectable()
export class UsersRepository {
  constructor(
    @InjectRepository(User)
    private readonly repository: Repository<User>,
  ) {}

  async findByEmail(email: string): Promise<User | null> {
    return this.repository.findOne({ where: { email } });
  }

  async create(data: Partial<User>): Promise<User> {
    const user = this.repository.create(data);
    return this.repository.save(user);
  }

  // Add query optimization methods
  async findActiveUsers(): Promise<User[]> {
    return this.repository.find({
      where: { isActive: true },
      select: ['id', 'email', 'name'], // Don't load password hashes
    });
  }
}

This indirection lets you optimize queries, add caching, or swap ORMs without changing service code. We migrated from TypeORM to Prisma in one module by only rewriting the repository layer.

Testing Strategies That Work

NestJS's testing utilities are solid, but setting up test modules is verbose. We created factory functions to reduce boilerplate.

Unit Testing Services

describe('UsersService', () => {
  let service: UsersService;
  let repository: jest.Mocked<UsersRepository>;

  beforeEach(async () => {
    const mockRepository = {
      findByEmail: jest.fn(),
      create: jest.fn(),
    };

    const module = await Test.createTestingModule({
      providers: [
        UsersService,
        { provide: UsersRepository, useValue: mockRepository },
      ],
    }).compile();

    service = module.get<UsersService>(UsersService);
    repository = module.get(UsersRepository);
  });

  it('should create a user', async () => {
    const dto = { email: 'test@example.com', password: 'password123' };
    repository.create.mockResolvedValue({ id: 1, ...dto } as User);

    const result = await service.createUser(dto);
    expect(result.email).toBe(dto.email);
    expect(repository.create).toHaveBeenCalledWith(dto);
  });
});

Mock all dependencies. Never hit real databases in unit tests. We use Docker for integration tests with real Postgres instances.

Configuration Management

The @nestjs/config module is great, but validating configuration at startup saved us from runtime errors in production.

import * as Joi from 'joi';

ConfigModule.forRoot({
  validationSchema: Joi.object({
    NODE_ENV: Joi.string().valid('development', 'production', 'test').required(),
    PORT: Joi.number().default(3000),
    DATABASE_URL: Joi.string().required(),
    REDIS_URL: Joi.string().required(),
    JWT_SECRET: Joi.string().min(32).required(),
  }),
});

If required environment variables are missing, the app won't start. Much better than discovering missing config in production.

Performance Considerations

NestJS's abstraction layers add overhead. In practice, we saw about 10-15% slower response times compared to raw Express for simple endpoints. But the maintainability gains are worth it.

For high-throughput endpoints, we bypass some NestJS features. Direct Fastify handlers where needed. The framework is flexible enough to drop down to lower levels when performance matters.

Caching with Redis interceptors helped a lot. We cache expensive database queries and external API calls at the controller level with decorators.

Key Takeaways

NestJS is the best enterprise Node.js framework I've used, but it requires discipline. Organize by domain, not technical layers. Keep services small and focused. Validate everything at the boundary. Abstract database access. Test with mocks, not real dependencies.

The learning curve is real. Budget 2-3 weeks for your team to get comfortable with dependency injection and decorators. But once it clicks, building complex features becomes faster and less error-prone.

Would I use NestJS again? Absolutely. For new projects with more than one developer, it's my default choice. The patterns and best practices force you to write maintainable code, and that's exactly what enterprise applications need.

#NestJS#Node.js#TypeScript#Enterprise#Backend
Share

Related Articles