Building Scalable Backend Architecture with NestJS: Modules, Dependency Injection and Clean Architecture
NestJS brings disciplined, modular structure to the Node.js ecosystem. After building a full production ERP backend with NestJS 11 and PostgreSQL, here is how I think about architecture — what works at scale and what creates the technical debt you'll be paying for years.
Why NestJS Architecture Decisions Matter More Than the Framework
NestJS gives you a structured foundation: modules, dependency injection, decorators, and a clear separation between controllers and services. But the framework itself cannot save you from poor architectural decisions. A badly structured NestJS application is just as unmaintainable as a poorly organized Express app — it just has more boilerplate.
After building the Ackura ERP backend — a production NestJS 11 modular monolith with 11+ domain modules, multi-tenancy, RBAC, and double-entry accounting — here is the architecture model I rely on and why.
1. The Module Boundary is the Most Important Decision
Every domain in your application should live in its own encapsulated module. A module owns its controllers, services, repositories, DTOs, entities, and guards. Nothing bleeds across module boundaries except through explicitly exported providers.
The most common mistake: SharedModule that exports everything to everyone. This creates hidden coupling between domains that makes the codebase impossible to reason about as it grows.
// Good — OrdersModule owns everything related to orders
@Module({
imports: [
TypeOrmModule.forFeature([Order, OrderLine]),
// Import only the specific service you need from another domain
UsersModule,
],
controllers: [OrdersController],
providers: [OrdersService, OrdersRepository],
exports: [OrdersService], // only export what other modules legitimately need
})
export class OrdersModule {}
// UsersModule explicitly controls what it shares
@Module({
providers: [UsersService, UsersRepository],
exports: [UsersService], // exports the service, not the repository
})
export class UsersModule {}
The rule: if Module B needs to import Module A's repository directly, that is a design smell. Module A should expose a service method that performs the operation. This keeps the data layer encapsulated within the owning module.
2. The Dependency Injection Container: How It Actually Works
NestJS's DI container resolves provider dependencies at startup based on the module's providers array. Every provider is a singleton within its module scope by default. Understanding scope is critical for multi-tenant applications.
Provider Scopes
// SINGLETON (default) — one instance per application lifetime
@Injectable()
export class ProductsService {}
// REQUEST — new instance per HTTP request (useful for per-request context like tenant ID)
@Injectable({ scope: Scope.REQUEST })
export class TenantContext {
constructor(@Inject(REQUEST) private readonly request: Request) {}
getTenantId(): string {
return this.request.headers['x-tenant-id'] as string;
}
}
// TRANSIENT — new instance every time it is injected
@Injectable({ scope: Scope.TRANSIENT })
export class AuditLogger {}
In the Ackura ERP, the TenantContext provider is REQUEST-scoped. Every service in the application can inject it to get the current tenant's ID without threading it through every function parameter. The DI container handles instantiation per request automatically.
3. Clean Layered Architecture: Controllers, Services, Repositories
The most productive NestJS architecture I have used follows three strict layers with one-directional dependencies:
- Controllers — handle HTTP concerns only: route matching, extracting parameters, calling services, returning responses. No business logic.
- Services — own all business logic. Orchestrate repository calls, enforce business rules, emit events. No HTTP concerns, no direct database queries.
- Repositories — own all database interaction. Return domain entities. No business logic.
// Controller — HTTP layer only
@Controller('products')
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Get()
findAll(@Query() query: ListProductsQueryDto) {
return this.productsService.findAll(query);
}
@Post()
@UseGuards(JwtAuthGuard, PermissionsGuard)
@RequirePermissions('products:create')
create(@Body() dto: CreateProductDto, @CurrentTenant() tenantId: string) {
return this.productsService.create(tenantId, dto);
}
}
// Service — business logic layer
@Injectable()
export class ProductsService {
constructor(private readonly productsRepository: ProductsRepository) {}
async create(tenantId: string, dto: CreateProductDto): Promise<Product> {
const exists = await this.productsRepository.findBySkuAndTenant(
dto.sku,
tenantId,
);
if (exists) {
throw new ConflictException(`Product with SKU ${dto.sku} already exists`);
}
return this.productsRepository.create({ ...dto, tenantId });
}
}
// Repository — data access layer
@Injectable()
export class ProductsRepository {
constructor(
@InjectRepository(Product)
private readonly repo: Repository<Product>,
) {}
findBySkuAndTenant(sku: string, tenantId: string): Promise<Product | null> {
return this.repo.findOne({ where: { sku, tenantId } });
}
create(data: Partial<Product>): Promise<Product> {
return this.repo.save(this.repo.create(data));
}
}
4. DTO Validation: Fail at the Network Boundary
Never let invalid data reach your business logic or database layer. Apply validation at the HTTP boundary using class-validator and class-transformer with a global ValidationPipe. This keeps services clean — they can trust their inputs are valid.
// main.ts — apply globally
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // strip properties not in the DTO
forbidNonWhitelisted: true, // throw if unknown properties are sent
transform: true, // auto-transform payloads to DTO class instances
transformOptions: { enableImplicitConversion: true },
}),
);
// create-product.dto.ts
export class CreateProductDto {
@IsString()
@MinLength(2)
@MaxLength(100)
name: string;
@IsString()
@Matches(/^[A-Z0-9-]+$/, { message: 'SKU must be uppercase alphanumeric' })
sku: string;
@IsNumber()
@Min(0)
price: number;
@IsEnum(ProductCategory)
category: ProductCategory;
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
}
5. Guards, Interceptors, and Exception Filters
NestJS's cross-cutting concerns — authentication, authorization, logging, error formatting — belong in guards, interceptors, and exception filters. Not scattered across service methods.
Guards for Authorization
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<string[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (!required) return true; // no permissions required — allow
const { user } = context.switchToHttp().getRequest();
return required.every(perm => user.permissions.includes(perm));
}
}
Interceptors for Response Transformation
@Injectable()
export class ResponseWrapperInterceptor<T>
implements NestInterceptor<T, ApiResponse<T>> {
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<ApiResponse<T>> {
return next.handle().pipe(
map(data => ({
success: true,
data,
timestamp: new Date().toISOString(),
})),
);
}
}
Exception Filters for Consistent Error Responses
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const isHttpException = exception instanceof HttpException;
const status = isHttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message = isHttpException
? exception.message
: 'An unexpected error occurred';
if (!isHttpException) {
this.logger.error('Unhandled exception', exception);
}
response.status(status).json({
success: false,
error: { status, message },
timestamp: new Date().toISOString(),
});
}
}
6. Common Mistakes
- Business logic in controllers. Controllers should be thin. If a controller method is more than 5 lines of logic, that logic belongs in the service layer.
- Circular dependencies between modules. If Module A imports Module B and Module B imports Module A, you have a design problem. Introduce a shared interface module or invert the dependency.
- Skipping the repository layer. Injecting TypeORM's
Repositorydirectly into services mixes data-access concerns with business logic and makes testing harder. - Not using scoped providers for request context. Passing tenant ID or user ID through every function parameter instead of using REQUEST-scoped providers creates noisy, fragile code.
- No global exception filter. Without it, unhandled exceptions in production expose internal stack traces in API responses.
Production Best Practices
- Use NestJS's built-in
ConfigModulewith Joi schema validation to fail fast at startup if required environment variables are missing. - Separate command and query responsibilities at the service layer (CQRS-lite). Write operations go through a different code path than reads — easier to optimize and reason about independently.
- Write unit tests for services with mocked repositories. Write integration tests for controllers with a real NestJS application context and an in-memory database. Services are the core of your business logic and need the most test coverage.
- Use NestJS's
Loggerclass, notconsole.log. It respects log levels, includes context, and can be swapped for structured logging (Pino, Winston) in production. - Apply rate limiting and request size limits at the application level with
@nestjs/throttlerand Express/Fastify body parser config. Do not rely solely on infrastructure-level protection.
Conclusion
NestJS provides the right tools for building enterprise-grade backends. But tools only take you so far. The discipline of enforcing strict module boundaries, keeping layers separated, validating at the boundary, and centralizing cross-cutting concerns is what makes a NestJS application genuinely maintainable at scale.
The patterns in this article are directly derived from building the Ackura ERP — a production system with 11 domain modules, multi-tenancy, RBAC, and a central double-entry accounting engine. They work at scale because they enforce clear contracts between layers, making the system easy to test, extend, and reason about. See the Ackura ERP project for more context on how these patterns were applied in production.
Frequently Asked Questions
Should I use a repository pattern or use TypeORM repositories directly in services?
Use a dedicated repository layer. Injecting TypeORM's Repository<T> directly in services mixes your data-access concerns with business logic. A custom repository class encapsulates query logic, makes services easier to unit test with mocks, and gives you a clean place to add caching or query optimization without touching business logic.
When should I use REQUEST scope vs SINGLETON in NestJS?
Use SINGLETON for the vast majority of providers — it is the most performant option. Use REQUEST scope only when a provider genuinely needs access to the current HTTP request context, like a multi-tenant context provider that reads the tenant ID from the request header. REQUEST scope adds overhead because NestJS creates a new instance and rebuilds the DI tree for every request.
How do I handle database transactions across multiple repositories in NestJS?
Use TypeORM's DataSource.transaction() method and pass the transaction's EntityManager to the repositories. Define an overloaded version of repository methods that accept an optional EntityManager parameter, so they can participate in an external transaction when one is provided and operate independently otherwise.
Is NestJS suitable for microservices?
Yes — NestJS has first-class support for microservices via its @nestjs/microservices package, supporting TCP, Redis, RabbitMQ, Kafka, and gRPC transports. The same module architecture that works for monoliths works for microservices. The decision between monolith and microservices should be driven by team size and deployment complexity, not by the framework.