| name | nestjs-architecture |
| description | NestJS module architecture methodology — DI wiring, config validation, repository pattern, request lifecycle, exception handling, health checks, and structured logging. Load when building or reviewing NestJS applications. |
| user-invocable | false |
NestJS Module Architecture
Opinionated methodology for production-grade NestJS backends. Distilled from
official docs, community concordances (10+ sources, 2024-2026), and SkillsMP
analysis. Covers module design through operational readiness.
When to Use
- Creating or reviewing NestJS modules, services, controllers
- Wiring DI providers, custom injection tokens, dynamic modules
- Setting up config validation, logging, health checks
- Implementing the repository pattern with Prisma
- Debugging request lifecycle ordering (middleware/guards/pipes/filters)
- Reviewing exception handling strategy
- Evaluating module boundaries and decomposition
Module Taxonomy
Three module types. Every provider belongs to exactly one.
| Type | Purpose | Import Rule | @Global() |
|---|
| Feature | Domain-scoped: own controller, service, DTOs, entities | Imported by modules that need its exports | Never |
| Core | One-time setup: DB connection, logger config, global interceptors | AppModule only | No |
| Shared | Cross-cutting utilities: filters, guards, decorators, logger | Available everywhere | Yes (sparingly) |
Feature Module Shape
bikes/
├── dto/
│ ├── create-bike.dto.ts
│ └── bike-response.dto.ts
├── entities/
│ └── bike.entity.ts
├── bikes.controller.ts
├── bikes.service.ts
├── bikes.module.ts
└── bikes.service.spec.ts
Feature-based layout (all feature files in one folder) is the universal
recommendation. Layered-by-type (controllers/, services/, entities/ at top
level) is explicitly anti-recommended -- it breaks at scale.
Export Discipline
Export only what other modules need to inject. Never export everything by
default. If nothing is exported, the module is fully encapsulated.
@Module({
imports: [PrismaModule],
controllers: [BikesController],
providers: [BikesService, BikeRepository],
exports: [BikesService],
})
export class BikesModule {}
Request Lifecycle
Official pipeline order (docs.nestjs.com/faq/request-lifecycle):
1. Incoming request
2. Middleware (global → module-bound, in bind order)
3. Guards (global → controller → route)
4. Interceptors pre (global → controller → route)
5. Pipes (global → controller → route → param, last-param-first)
6. Controller handler
7. Service
8. Interceptors post (route → controller → global) ← FILO
9. Exception Filters (route → controller → global) ← lowest-first
10. Server response
Critical Nuances
- Interceptors use FILO on the response path (first bound = last to
process response)
- Exception Filters are the only component that resolves lowest-first
(route → controller → global), opposite of guards/interceptors
- Caught exceptions (
try/catch) bypass filters entirely
- Exceptions cannot pass between filters -- use inheritance for chaining
- Pipes run per parameter in reverse order (last param processed first)
Placement Decision
| Component | Use When |
|---|
| Middleware | Request/response transformation, logging, CORS, body parsing |
| Guard | Authorization, role checks, feature flags (returns boolean) |
| Interceptor | Response mapping, caching, timing, error transformation |
| Pipe | Validation, transformation of input parameters |
| Filter | Exception handling, error response formatting |
Config Module
Zod validation with @nestjs/config (community-leading pattern, 2025+):
const envSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
});
export type EnvConfig = z.infer<typeof envSchema>;
export function validate(config: Record<string, unknown>) {
const result = envSchema.safeParse(config);
if (!result.success) throw new Error(`Config validation:\n${result.error.format()}`);
return result.data;
}
ConfigModule.forRoot({ isGlobal: true, validate, cache: true })
Config Rules
- Never access
process.env directly -- always ConfigService.get()
isGlobal: true eliminates re-imports in feature modules
- Fail fast on startup if required vars are missing
- Use
registerAs() for namespaced config groups
- Wrap
ConfigService in a typed AppConfigService for domain-specific getters
Namespaced Config
export default registerAs('database', () => ({
host: process.env.DATABASE_HOST,
port: parseInt(process.env.DATABASE_PORT, 10) || 5432,
}));
TypeOrmModule.forRootAsync(databaseConfig.asProvider())
forRootAsync Pattern
Standard for any module needing async configuration:
LoggerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
pinoHttp: { level: config.get('LOG_LEVEL') },
}),
})
Repository Pattern
Abstract classes as DI tokens -- TypeScript interfaces erase at runtime and
cannot be used as injection tokens. Abstract classes survive compilation.
Contract (abstract class)
export abstract class BikeRepository {
abstract findById(id: string): Promise<Bike | null>;
abstract findByStatus(status: BikeStatus): Promise<Bike[]>;
abstract save(bike: Bike): Promise<void>;
}
Prisma Implementation
@Injectable()
export class PrismaBikeRepository extends BikeRepository {
constructor(private prisma: PrismaService) { super(); }
async findById(id: string): Promise<Bike | null> {
const row = await this.prisma.bike.findUnique({ where: { id } });
return row ? BikeMapper.toDomain(row) : null;
}
async save(bike: Bike): Promise<void> {
await this.prisma.bike.upsert({
where: { id: bike.id },
update: BikeMapper.toPersistence(bike),
create: BikeMapper.toPersistence(bike),
});
}
}
Module Wiring
@Module({
providers: [
BikesService,
{ provide: BikeRepository, useClass: PrismaBikeRepository },
],
exports: [BikesService],
})
export class BikesModule {}
PrismaService
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
}
Mapper Discipline
Always map between persistence rows and domain objects. Never expose Prisma
types from services. Mappers are a few dozen lines per entity and pay for
themselves on the first schema change.
DAO vs Repository
A repository exposes findById, save, and domain-meaningful queries
(findActiveByCustomer). A DAO exposes findByEmailContaining,
updateStatusToCancelled, incrementViewCount -- it leaks persistence
concerns and bypasses domain invariants.
Service Design
Size Limit
200 lines per service. When a service exceeds this, decompose by
extracting use-case classes.
Use-Case Classes (complex workflows)
@Injectable()
export class PlaceOrderUseCase {
constructor(
private readonly orders: OrderRepository,
private readonly eventBus: EventEmitter2,
) {}
async execute(cmd: PlaceOrderCommand): Promise<string> {
const order = Order.place(cmd.customerId, cmd.lines);
await this.orders.save(order);
order.pullEvents().forEach(e => this.eventBus.emit(e.name, e));
return order.id;
}
}
Single execute() method. One use-case per file. Inject via DI like any
service. Preferred over service methods when the workflow spans multiple
aggregates or has complex orchestration.
Standard Service (CRUD-shaped modules)
Service methods are fine for simpler operations. The 200-line limit still
applies -- split into multiple focused services when needed.
Controller Discipline
Controllers are thin: validate input (via Pipes/DTOs), call service/use-case,
map response. Zero business logic in controllers.
@Controller('orders')
export class OrdersController {
constructor(private readonly placeOrder: PlaceOrderUseCase) {}
@Post()
async create(@Body() dto: PlaceOrderDto) {
const id = await this.placeOrder.execute(dto.toCommand());
return { orderId: id };
}
}
Structured Logging
nestjs-pino + pino-http + pino (dev: pino-pretty).
Setup
@Module({
imports: [
LoggerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const isDev = config.get('NODE_ENV') === 'development';
return {
pinoHttp: {
level: isDev ? 'debug' : 'info',
transport: isDev
? { target: 'pino-pretty', options: { colorize: true, singleLine: true } }
: undefined,
redact: ['req.headers.authorization', 'req.headers.cookie'],
},
};
},
}),
],
})
export class AppLoggerModule {}
Key Mechanism
pino-http middleware creates a child logger per request stored in
AsyncLocalStorage. Every logger.log() call within that request
automatically carries the same req.id -- no manual correlation needed.
Why Pino over Winston
- Deferred serialization (worker thread, non-blocking event loop)
- Structured JSON output by default
- Zero-cost suppressed log levels (checks level before serialization)
nestjs-pino library quality and DI integration
Health Checks
@nestjs/terminus with Prisma:
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private db: PrismaHealthIndicator,
private memory: MemoryHealthIndicator,
) {}
@Get()
@HealthCheck()
check() {
return this.health.check([
() => this.db.pingCheck('database'),
() => this.memory.checkHeap('memory_heap', 150 * 1024 * 1024),
]);
}
}
Operational Requirements
app.enableShutdownHooks() in main.ts for graceful shutdown
- Use
gracefulShutdownTimeoutMs for Kubernetes zero-downtime deployments
- Available indicators:
PrismaHealthIndicator, MemoryHealthIndicator,
DiskHealthIndicator, HttpHealthIndicator, MicroserviceHealthIndicator
Exception Handling
Global Catch-All Filter
@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 request = ctx.getRequest<Request>();
const status = exception instanceof HttpException
? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
const message = exception instanceof HttpException
? exception.getResponse() : 'Internal server error';
if (status >= 500) this.logger.error(exception);
response.status(status).json({
statusCode: status, message,
timestamp: new Date().toISOString(), path: request.url,
});
}
}
Domain-to-HTTP Mapping
Map domain exceptions to HTTP exceptions in the filter or controller layer,
never in the domain layer. Domain code throws domain-specific errors; the
presentation layer translates.
Built-in NestJS Exceptions
Use these in services -- never throw raw Error:
| Exception | Status |
|---|
BadRequestException | 400 |
UnauthorizedException | 401 |
ForbiddenException | 403 |
NotFoundException | 404 |
ConflictException | 409 |
InternalServerErrorException | 500 |
Swagger/OpenAPI
DTO Decorator Discipline
Every DTO property gets @ApiProperty(). Response DTOs documented via
@ApiResponse({ type: ResponseDto }) at controller level.
export class CreateBikeDto {
@ApiProperty({ example: 'Canyon' })
@IsString()
brand: string;
@ApiProperty({ enum: BikeCategory })
@IsEnum(BikeCategory)
category: BikeCategory;
@ApiPropertyOptional({ example: 85, description: 'Battery health %' })
@IsOptional()
@IsNumber()
batteryHealthPct?: number;
}
Setup
Wire in main.ts: DocumentBuilder chain (.setTitle, .setVersion,
.addBearerAuth) -> SwaggerModule.createDocument -> SwaggerModule.setup.
Enable the CLI plugin in nest-cli.json to auto-generate @ApiProperty
from TypeScript types (reduces decorator boilerplate).
Anti-Patterns
| Pattern | Why It Fails |
|---|
process.env.X in service code | Untestable, unvalidated, scattered config |
| Layered folder structure | Feature changes touch N folders; dependencies invisible |
| Returning ORM entities from controllers | Leaks internal fields, couples API to DB schema |
| Business logic in controllers | Untestable, violates separation of concerns |
new Service() bypassing DI | Loses testability, lifecycle hooks, scope |
console.log for logging | Unstructured, synchronous, no context |
| Circular module dependencies | Fix structure; forwardRef() is a bandage |
@Global() overuse | Recreates "everything available everywhere" |
| DAOs posing as repositories | Leaks persistence, bypasses domain invariants |
| 40+ imports in AppModule | Restructure into domain/feature modules |
| Mocks for everything in integration tests | Use real containers (Testcontainers) |
Cross-References
| Concern | Resource |
|---|
| Config externalization | yaml-config-preference rule, random-ports rule |
| Service size limits | file-size-limit rule (600/800 line limits) |
| Logging methodology | logging-hygiene rule, logging skill |
| Testing strategy | @nestjs/testing + Jest + Supertest + Testcontainers |
| DDD (full treatment) | appscale.blog DDD+NestJS guide (Apr 2026) |
| Event-driven patterns | @nestjs/event-emitter for in-process; RabbitMQ for cross-service |
| Workflow orchestration | Temporal.io SDK (separate worker process) |