nestjs-project-structure
Opinionated NestJS + TypeScript project structure covering module organization, layer-first architecture, monorepo patterns, and domain-driven folder conventions. Use when organizing a NestJS codebase, deciding where files should live, splitting large modules, or structuring services, controllers, repositories, DTOs, and guards.
Nestjs Project Structure — Compiled Guide
Version: 1.0.0
This file is auto-generated from the individual guide files in
guides/. Do not edit directly.
Overview
Opinionated NestJS + TypeScript project structure covering module organization, layer-first architecture, monorepo patterns, and domain-driven folder conventions. Use when organizing a NestJS codebase, deciding where files should live, splitting large modules, or structuring services, controllers, repositories, DTOs, and guards.
Table of Contents
- >Architecture Principles: Module Dependency Rules
- >Architecture Principles: Module-First Architecture
- >Module Organization: Domain Module Structure
- >Module Organization: Dynamic Modules
- >Module Organization: Feature Modules vs Domain Modules
- >Layer Separation: Controller Layer Boundaries
- >Layer Separation: Repository Pattern for Data Access
- >Layer Separation: Service Layer Patterns
- >Shared & Common: Common Module for Cross-Cutting Concerns
- >Shared & Common: Shared Infrastructure Modules
- >DTOs & Validation: DTO Organization and Validation
- >DTOs & Validation: DTO Transformation and Serialization
- >Configuration: Environment-Specific Configuration
- >Configuration: Typed Configuration Module
- >Naming Conventions: Class and Decorator Naming
- >Naming Conventions: File Naming Conventions
- >Monorepo Patterns: Nx Monorepo Structure
- >Monorepo Patterns: Turborepo Monorepo Structure
- >Testing Structure: E2E Test Organization
- >Testing Structure: Unit Test File Organization
- >Splitting Guidelines: When to Split Modules
- >Splitting Guidelines: When to Split Services
1. Module Dependency Rules
Module dependencies should flow in one direction: feature modules depend on shared modules, never the reverse. Circular dependencies are the #1 architecture smell in NestJS.
Dependency Direction
AppModule
/ | \
v v v
UsersModule OrdersModule AuthModule
\ | /
v v v
SharedModule (Database, Mail, Logger)
|
v
ConfigModuleRules:
- >Feature modules import shared modules, never other feature modules directly
- >Shared modules never import feature modules
- >If two feature modules need to communicate, use events or a shared service
Preventing Circular Dependencies
Incorrect (circular import):
// users.module.ts
@Module({
imports: [OrdersModule], // Users depends on Orders
})
export class UsersModule {}
// orders.module.ts
@Module({
imports: [UsersModule], // Orders depends on Users — CIRCULAR
})
export class OrdersModule {}Correct (use forwardRef or extract shared logic):
// Option 1: Extract shared logic into a new module
// shared/user-orders/user-orders.service.ts
@Injectable()
export class UserOrdersService {
constructor(
@Inject(forwardRef(() => UsersService)) private users: UsersService,
@Inject(forwardRef(() => OrdersService)) private orders: OrdersService,
) {}
}
// Option 2: Use events for loose coupling
// orders.service.ts
@Injectable()
export class OrdersService {
constructor(private eventEmitter: EventEmitter2) {}
async createOrder(dto: CreateOrderDto) {
const order = await this.repository.create(dto);
this.eventEmitter.emit('order.created', { orderId: order.id, userId: dto.userId });
return order;
}
}
// users.service.ts — listens without importing OrdersModule
@Injectable()
export class UsersService {
@OnEvent('order.created')
async handleOrderCreated(payload: { orderId: string; userId: string }) {
await this.repository.incrementOrderCount(payload.userId);
}
}Module Export Rules
// Only export what other modules need
@Module({
providers: [UsersService, UsersRepository, UsersCacheService],
exports: [UsersService], // Only the service — not internal implementation
})
export class UsersModule {}Rules:
- >Export the minimum public API from each module
- >Keep repositories, strategies, and internal services private
- >Use
@nestjs/event-emitterfor cross-module communication - >Use
forwardRef()only as a last resort — prefer extracting shared logic
2. Module-First Architecture
NestJS modules are the natural boundary for domain ownership. Each module encapsulates its controllers, services, repositories, DTOs, and entities. Inside each module, separate code by layer (controller → service → repository).
Recommended Structure
src/
app.module.ts # Root module — imports all feature modules
main.ts # Bootstrap
common/ # Cross-cutting (guards, filters, pipes, interceptors)
decorators/
current-user.decorator.ts
roles.decorator.ts
filters/
all-exceptions.filter.ts
guards/
jwt-auth.guard.ts
roles.guard.ts
interceptors/
logging.interceptor.ts
transform.interceptor.ts
pipes/
parse-uuid.pipe.ts
config/ # Typed configuration
config.module.ts
app.config.ts
database.config.ts
auth.config.ts
modules/ # Domain modules
users/
users.module.ts
users.controller.ts
users.service.ts
users.repository.ts
dto/
create-user.dto.ts
update-user.dto.ts
user-response.dto.ts
entities/
user.entity.ts
users.controller.spec.ts
users.service.spec.ts
orders/
orders.module.ts
orders.controller.ts
orders.service.ts
orders.repository.ts
dto/
entities/
auth/
auth.module.ts
auth.controller.ts
auth.service.ts
dto/
strategies/
jwt.strategy.ts
local.strategy.ts
shared/ # Infrastructure modules
database/
database.module.ts
drizzle.provider.ts
schema/ # Drizzle schema files
users.schema.ts
orders.schema.ts
index.ts
mail/
mail.module.ts
mail.service.ts
logger/
logger.module.ts
logger.service.ts
lib/ # Pure utilities (no NestJS dependencies)
pagination/
paginate.ts
crypto/
hash.ts
dates/
format.ts
test/
e2e/
app.e2e-spec.ts
auth.e2e-spec.tsWhy Module-First
# BAD: Layer-first at top level — modules hidden inside layers
src/
controllers/
users.controller.ts
orders.controller.ts
services/
users.service.ts
orders.service.ts
repositories/
users.repository.tsProblems:
- >Related files scattered across folders
- >No clear ownership boundaries
- >Hard to extract a module into a separate package
- >NestJS module system ignored
Module-first keeps all related code together while maintaining layer separation inside each module.
3. Domain Module Structure
Every domain module follows the same internal structure: module definition, controller, service, repository, DTOs, and entities.
Standard Module Layout
modules/users/
users.module.ts # Module definition with imports/providers/exports
users.controller.ts # HTTP layer — routes, params, response codes
users.service.ts # Business logic — validation, orchestration
users.repository.ts # Data access — Drizzle queries
dto/
create-user.dto.ts # Request validation
update-user.dto.ts
user-response.dto.ts # Response serialization
user-query.dto.ts # Query params validation
entities/
user.entity.ts # Domain entity (if different from schema)
users.controller.spec.ts # Controller unit tests
users.service.spec.ts # Service unit testsModule Definition
// users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { UsersRepository } from './users.repository';
import { DatabaseModule } from '@/shared/database/database.module';
@Module({
imports: [DatabaseModule],
controllers: [UsersController],
providers: [UsersService, UsersRepository],
exports: [UsersService], // Only export what other modules need
})
export class UsersModule {}Layer Flow
Request → Controller → Service → Repository → Database
↓
Validation
Authorization
Business rules- >Controller: HTTP concerns only — parsing params, calling service, returning response
- >Service: Business logic — validation, authorization checks, orchestrating multiple repositories
- >Repository: Data access only — Drizzle queries, no business logic
// Controller — thin, delegates to service
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get(':id')
async findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.usersService.findOneOrFail(id);
}
}
// Service — business logic
@Injectable()
export class UsersService {
constructor(private readonly usersRepository: UsersRepository) {}
async findOneOrFail(id: string): Promise<UserResponseDto> {
const user = await this.usersRepository.findById(id);
if (!user) throw new NotFoundException(`User ${id} not found`);
return UserResponseDto.fromEntity(user);
}
}
// Repository — data access
@Injectable()
export class UsersRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
async findById(id: string) {
return this.db.query.users.findFirst({ where: eq(users.id, id) });
}
}4. Dynamic Modules
Dynamic modules accept configuration at import time, enabling reusable modules with different settings per consumer.
Implementation
// shared/mail/mail.module.ts
import { Module, type DynamicModule } from '@nestjs/common';
import { MailService } from './mail.service';
interface MailModuleOptions {
apiKey: string;
from: string;
templateDir?: string;
}
@Module({})
export class MailModule {
static forRoot(options: MailModuleOptions): DynamicModule {
return {
module: MailModule,
global: true, // Available everywhere without importing
providers: [
{ provide: 'MAIL_OPTIONS', useValue: options },
MailService,
],
exports: [MailService],
};
}
static forRootAsync(options: {
imports?: any[];
useFactory: (...args: any[]) => MailModuleOptions | Promise<MailModuleOptions>;
inject?: any[];
}): DynamicModule {
return {
module: MailModule,
global: true,
imports: options.imports ?? [],
providers: [
{
provide: 'MAIL_OPTIONS',
useFactory: options.useFactory,
inject: options.inject ?? [],
},
MailService,
],
exports: [MailService],
};
}
}
// Usage in AppModule
@Module({
imports: [
ConfigModule.forRoot(),
MailModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
apiKey: config.get('MAIL_API_KEY'),
from: config.get('MAIL_FROM'),
}),
}),
],
})
export class AppModule {}Rules:
- >Use
forRoot()for synchronous configuration - >Use
forRootAsync()when configuration depends on other modules (ConfigModule) - >Set
global: truefor infrastructure modules used everywhere (database, mail, logger) - >Keep
forRootandforRootAsyncas the only two static methods
5. Feature Modules vs Domain Modules
NestJS documentation uses "feature module" for any non-root module. In practice, distinguish between domain modules (own a business entity) and feature modules (orchestrate a cross-cutting flow).
Domain Modules
Own a single business entity and its CRUD operations:
modules/
users/ # Owns User entity
products/ # Owns Product entity
orders/ # Owns Order entity
categories/ # Owns Category entityRules for domain modules:
- >One primary entity per module
- >Repository lives inside the module
- >Service contains business rules for that entity
- >Controller handles REST endpoints for that entity
Feature Modules
Orchestrate a flow that spans multiple domain modules:
modules/
checkout/ # Orchestrates: orders + products + payments + notifications
onboarding/ # Orchestrates: users + verification + welcome email
reporting/ # Reads from: orders + products + users (read-only)Rules for feature modules:
- >Import services from domain modules (don't re-implement data access)
- >Own the orchestration logic, not the entities
- >May have their own controller for flow-specific endpoints
- >Should not have their own repository (use domain module repositories via services)
Example: Checkout Feature Module
// modules/checkout/checkout.module.ts
@Module({
imports: [OrdersModule, ProductsModule, PaymentsModule, NotificationsModule],
controllers: [CheckoutController],
providers: [CheckoutService],
})
export class CheckoutModule {}
// modules/checkout/checkout.service.ts
@Injectable()
export class CheckoutService {
constructor(
private readonly ordersService: OrdersService,
private readonly productsService: ProductsService,
private readonly paymentsService: PaymentsService,
private readonly notificationsService: NotificationsService,
) {}
async processCheckout(dto: CheckoutDto): Promise<Order> {
// Validate stock
await this.productsService.validateStock(dto.items);
// Create order
const order = await this.ordersService.create(dto);
// Process payment
await this.paymentsService.charge(order.id, order.total);
// Send confirmation
await this.notificationsService.sendOrderConfirmation(order);
return order;
}
}Decision Tree
Does this module own a database entity?
Yes → Domain module (users/, products/, orders/)
No → Does it orchestrate multiple domains?
Yes → Feature module (checkout/, onboarding/)
No → Is it infrastructure?
Yes → Shared module (database/, mail/, logger/)
No → It probably belongs inside an existing module
6. Controller Layer Boundaries
Controllers handle HTTP concerns only: routing, request parsing, response formatting, and status codes. No business logic.
What belongs in controllers
- >Route definitions (
@Get,@Post,@Put,@Delete) - >Parameter extraction (
@Param,@Query,@Body) - >Response status codes (
@HttpCode) - >OpenAPI decorators (
@ApiTags,@ApiResponse) - >Calling the service and returning the result
What does NOT belong in controllers
- >Database queries
- >Business validation rules
- >Authorization logic (use guards)
- >Error message formatting (use exception filters)
- >Data transformation (use interceptors or DTOs)
Example
@ApiTags('users')
@Controller('users')
@UseGuards(JwtAuthGuard)
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
@ApiOperation({ summary: 'List users with pagination' })
async findAll(@Query() query: UserQueryDto): Promise<PaginatedResponse<UserResponseDto>> {
return this.usersService.findAll(query);
}
@Get(':id')
async findOne(@Param('id', ParseUUIDPipe) id: string): Promise<UserResponseDto> {
return this.usersService.findOneOrFail(id);
}
@Post()
@HttpCode(HttpStatus.CREATED)
async create(@Body() dto: CreateUserDto, @CurrentUser() actor: User): Promise<UserResponseDto> {
return this.usersService.create(dto, actor);
}
@Patch(':id')
async update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateUserDto,
): Promise<UserResponseDto> {
return this.usersService.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
return this.usersService.remove(id);
}
}Each method is 1-3 lines: extract params, call service, return. If a controller method is more than 5 lines, logic probably belongs in the service.
7. Repository Pattern for Data Access
Repositories abstract database queries behind a typed interface. Services never write raw queries — they call repository methods.
Base Repository Pattern
// shared/database/base.repository.ts
import { Inject, Injectable } from '@nestjs/common';
import { DRIZZLE, type DrizzleDB } from './drizzle.provider';
import { eq, type SQL } from 'drizzle-orm';
import { type PgTable } from 'drizzle-orm/pg-core';
@Injectable()
export abstract class BaseRepository<TTable extends PgTable, TInsert, TSelect> {
constructor(@Inject(DRIZZLE) protected readonly db: DrizzleDB) {}
protected abstract table: TTable;
async findById(id: string): Promise<TSelect | undefined> {
const [result] = await this.db
.select()
.from(this.table)
.where(eq((this.table as any).id, id))
.limit(1);
return result as TSelect | undefined;
}
async create(data: TInsert): Promise<TSelect> {
const [result] = await this.db.insert(this.table).values(data as any).returning();
return result as TSelect;
}
async update(id: string, data: Partial<TInsert>): Promise<TSelect | undefined> {
const [result] = await this.db
.update(this.table)
.set(data as any)
.where(eq((this.table as any).id, id))
.returning();
return result as TSelect | undefined;
}
async delete(id: string): Promise<void> {
await this.db.delete(this.table).where(eq((this.table as any).id, id));
}
}Domain Repository
// modules/users/users.repository.ts
@Injectable()
export class UsersRepository extends BaseRepository<
typeof users,
typeof users.$inferInsert,
typeof users.$inferSelect
> {
protected table = users;
async findByEmail(email: string) {
return this.db.query.users.findFirst({
where: eq(users.email, email),
});
}
async findWithOrders(id: string) {
return this.db.query.users.findFirst({
where: eq(users.id, id),
with: { orders: { limit: 10, orderBy: desc(orders.createdAt) } },
});
}
async search(query: UserQueryDto) {
const conditions: SQL[] = [];
if (query.name) conditions.push(ilike(users.name, `%${query.name}%`));
if (query.role) conditions.push(eq(users.role, query.role));
return this.db
.select()
.from(users)
.where(and(...conditions))
.orderBy(desc(users.createdAt))
.limit(query.limit)
.offset(query.offset);
}
}Rules:
- >One repository per domain entity
- >Repositories return raw data (entities), not DTOs
- >Complex joins and aggregations live in the repository
- >Business logic (authorization, validation) stays in the service
- >Use the base repository for common CRUD, extend for domain-specific queries
8. Service Layer Patterns
Services contain business logic: validation rules, authorization checks, orchestration of multiple repositories, and error handling.
What belongs in services
- >Business validation (beyond DTO validation)
- >Authorization checks (does this user own this resource?)
- >Orchestrating multiple repositories
- >Event emission
- >Error throwing (NotFoundException, ForbiddenException)
- >DTO transformation (entity → response DTO)
Example
@Injectable()
export class OrdersService {
constructor(
private readonly ordersRepository: OrdersRepository,
private readonly productsRepository: ProductsRepository,
private readonly eventEmitter: EventEmitter2,
) {}
async create(dto: CreateOrderDto, actor: User): Promise<OrderResponseDto> {
// Business validation
const products = await this.productsRepository.findByIds(dto.productIds);
const unavailable = products.filter((p) => p.stock < 1);
if (unavailable.length > 0) {
throw new BadRequestException(
`Products out of stock: ${unavailable.map((p) => p.name).join(', ')}`,
);
}
// Create order
const order = await this.ordersRepository.create({
userId: actor.id,
items: dto.items,
total: this.calculateTotal(products, dto.items),
});
// Side effects
this.eventEmitter.emit('order.created', { orderId: order.id });
return OrderResponseDto.fromEntity(order);
}
async findOneOrFail(id: string, actor: User): Promise<OrderResponseDto> {
const order = await this.ordersRepository.findById(id);
if (!order) throw new NotFoundException(`Order ${id} not found`);
// Authorization: only owner or admin can view
if (order.userId !== actor.id && actor.role !== 'admin') {
throw new ForbiddenException();
}
return OrderResponseDto.fromEntity(order);
}
private calculateTotal(products: Product[], items: OrderItem[]): number {
return items.reduce((sum, item) => {
const product = products.find((p) => p.id === item.productId)!;
return sum + product.price * item.quantity;
}, 0);
}
}Rules:
- >One service per domain module
- >Services call repositories, never the database directly
- >Services throw HTTP exceptions (NestJS catches and formats them)
- >Keep private helpers for calculation logic
- >Use events for cross-module side effects
9. Common Module for Cross-Cutting Concerns
The common/ directory holds cross-cutting decorators, guards, interceptors, pipes, and filters that are used globally or across multiple modules.
Structure
common/
decorators/
current-user.decorator.ts # Extract user from request
roles.decorator.ts # @Roles('admin', 'user')
public.decorator.ts # @Public() to skip auth
api-paginated.decorator.ts # Swagger pagination docs
filters/
all-exceptions.filter.ts # Global exception handler
validation.filter.ts # Transform validation errors
guards/
jwt-auth.guard.ts # JWT authentication
roles.guard.ts # Role-based authorization
interceptors/
logging.interceptor.ts # Request/response logging
transform.interceptor.ts # Response envelope wrapping
timeout.interceptor.ts # Request timeout
pipes/
parse-uuid.pipe.ts # UUID validation
trim-strings.pipe.ts # Trim whitespace from strings
constants/
injection-tokens.ts # DI token constantsGlobal Registration
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Global pipes
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
);
// Global filters
app.useGlobalFilters(new AllExceptionsFilter());
// Global interceptors
app.useGlobalInterceptors(
new LoggingInterceptor(),
new TransformInterceptor(),
);
await app.listen(3000);
}Custom Decorator Example
// common/decorators/current-user.decorator.ts
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: keyof User | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user?.[data] : user;
},
);
// Usage: @CurrentUser() user: User
// Usage: @CurrentUser('id') userId: stringRules:
- >
common/is NOT a NestJS module — just a directory for shared providers - >Register global providers in
main.tsor viaAPP_GUARD/APP_INTERCEPTORtokens - >Keep each file focused on one concern
- >Decorators, pipes, and filters here should be truly generic — domain-specific ones stay in their module
10. Shared Infrastructure Modules
Shared modules provide infrastructure services (database, mail, logger) that multiple domain modules depend on.
Structure
shared/
database/
database.module.ts # Drizzle + connection pool setup
drizzle.provider.ts # DRIZZLE injection token
schema/
users.schema.ts
orders.schema.ts
products.schema.ts
relations.ts # All relation definitions
index.ts # Re-exports all schemas
mail/
mail.module.ts
mail.service.ts
templates/
welcome.hbs
reset-password.hbs
logger/
logger.module.ts
logger.service.ts
cache/
cache.module.ts
cache.service.tsDatabase Module Example
// shared/database/drizzle.provider.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
export const DRIZZLE = Symbol('DRIZZLE');
export type DrizzleDB = ReturnType<typeof drizzle<typeof schema>>;
export const drizzleProvider = {
provide: DRIZZLE,
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const pool = new Pool({
connectionString: config.get('DATABASE_URL'),
max: 20,
});
return drizzle(pool, { schema });
},
};
// shared/database/database.module.ts
@Global()
@Module({
providers: [drizzleProvider],
exports: [DRIZZLE],
})
export class DatabaseModule {}Rules:
- >Mark infrastructure modules as
@Global()when used by most modules - >Keep Drizzle schema files in
shared/database/schema/— one per table - >Export a barrel file (
schema/index.ts) for clean imports - >Shared modules own their configuration but get values from ConfigModule
11. DTO Organization and Validation
DTOs validate input and shape output. Separate request DTOs (validation) from response DTOs (serialization) to prevent leaking internal fields.
File Placement
modules/users/dto/
create-user.dto.ts # POST body validation
update-user.dto.ts # PATCH body (partial of create)
user-query.dto.ts # GET query params
user-response.dto.ts # Response serializationRequest DTO
// dto/create-user.dto.ts
import { IsEmail, IsString, MinLength, IsOptional, IsEnum } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateUserDto {
@ApiProperty({ example: 'jane@example.com' })
@IsEmail()
email: string;
@ApiProperty({ example: 'Jane Doe' })
@IsString()
@MinLength(2)
name: string;
@ApiProperty({ minLength: 8 })
@IsString()
@MinLength(8)
password: string;
@ApiPropertyOptional({ enum: ['user', 'admin'] })
@IsOptional()
@IsEnum(['user', 'admin'])
role?: 'user' | 'admin';
}Update DTO (Partial)
// dto/update-user.dto.ts
import { PartialType, OmitType } from '@nestjs/swagger';
import { CreateUserDto } from './create-user.dto';
// All fields optional, email excluded from updates
export class UpdateUserDto extends PartialType(OmitType(CreateUserDto, ['email'])) {}Response DTO
// dto/user-response.dto.ts
import { Exclude, Expose } from 'class-transformer';
export class UserResponseDto {
@Expose() id: string;
@Expose() email: string;
@Expose() name: string;
@Expose() role: string;
@Expose() createdAt: Date;
@Exclude() password: never; // Never exposed
@Exclude() deletedAt: never;
static fromEntity(entity: UserEntity): UserResponseDto {
const dto = new UserResponseDto();
dto.id = entity.id;
dto.email = entity.email;
dto.name = entity.name;
dto.role = entity.role;
dto.createdAt = entity.createdAt;
return dto;
}
}Rules:
- >One DTO per operation (create, update, query, response)
- >Use
PartialTypeandOmitTypeto derive update DTOs - >Never reuse request DTOs as response DTOs
- >Response DTOs must explicitly exclude sensitive fields
- >Place DTOs in
dto/within the module, not in a globaldtos/folder
12. DTO Transformation and Serialization
class-transformer converts plain objects to class instances (request) and class instances to plain objects (response). Use it with interceptors for consistent serialization.
Serialization Interceptor
// common/interceptors/transform.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { map, type Observable } from 'rxjs';
interface ApiResponse<T> {
data: T;
meta?: Record<string, unknown>;
}
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
return next.handle().pipe(
map((data) => {
// If data already has envelope structure, pass through
if (data && typeof data === 'object' && 'data' in data) {
return data;
}
return { data };
}),
);
}
}Query DTO with Transformation
// dto/user-query.dto.ts
import { IsOptional, IsString, IsInt, Min, Max, IsEnum } from 'class-validator';
import { Type } from 'class-transformer';
export class UserQueryDto {
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsEnum(['user', 'admin'])
role?: string;
@IsOptional()
@Type(() => Number) // Transform string query param to number
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
get offset(): number {
return ((this.page ?? 1) - 1) * (this.limit ?? 20);
}
}Date Transformation
import { Type } from 'class-transformer';
import { IsDate, IsOptional } from 'class-validator';
export class DateRangeDto {
@IsOptional()
@Type(() => Date)
@IsDate()
startDate?: Date;
@IsOptional()
@Type(() => Date)
@IsDate()
endDate?: Date;
}Rules:
- >Enable
transform: trueandenableImplicitConversion: truein global ValidationPipe - >Use
@Type(() => Number)for query params that arrive as strings - >Use
@Type(() => Date)for date strings - >Keep transformation logic in DTOs, not in controllers or services
13. Environment-Specific Configuration
Separate environment files prevent accidental production deployments with development settings.
Environment Files
.env # Default values (committed, no secrets)
.env.local # Local overrides (gitignored)
.env.test # Test environment
.env.production # Production values reference (no actual secrets)Validation at Startup
// config/app.config.ts
import { registerAs } from '@nestjs/config';
import { z } from 'zod';
const schema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().default(3000),
CORS_ORIGINS: z
.string()
.transform((s) => s.split(',').map((o) => o.trim()))
.default('http://localhost:3000'),
API_PREFIX: z.string().default('api'),
});
export const appConfig = registerAs('app', () => {
const env = schema.parse(process.env);
return {
nodeEnv: env.NODE_ENV,
port: env.PORT,
corsOrigins: env.CORS_ORIGINS,
apiPrefix: env.API_PREFIX,
isDev: env.NODE_ENV === 'development',
isProd: env.NODE_ENV === 'production',
isTest: env.NODE_ENV === 'test',
};
});Environment-Specific Behavior
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const config = app.get(ConfigService);
const appConf = config.get('app');
// Swagger only in development
if (appConf.isDev) {
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('docs', app, document);
}
// CORS
app.enableCors({ origin: appConf.corsOrigins });
app.setGlobalPrefix(appConf.apiPrefix);
await app.listen(appConf.port);
}Rules:
- >Validate ALL environment variables at startup with Zod
- >Crash immediately if required variables are missing — don't discover at runtime
- >Use
.default()for optional values with sensible defaults - >Never commit secrets — use
.env.local(gitignored) or environment injection
14. Typed Configuration Module
Use @nestjs/config with typed factory functions for type-safe, validated configuration.
Structure
config/
config.module.ts # ConfigModule.forRoot setup
app.config.ts # App-wide config (port, cors, name)
database.config.ts # Database connection config
auth.config.ts # JWT secrets, token expiry
mail.config.ts # SMTP/API configTyped Config Factory
// config/database.config.ts
import { registerAs } from '@nestjs/config';
import { z } from 'zod';
const schema = z.object({
DATABASE_URL: z.string().url(),
DATABASE_POOL_MAX: z.coerce.number().default(20),
DATABASE_SSL: z.coerce.boolean().default(false),
});
export const databaseConfig = registerAs('database', () => {
const env = schema.parse(process.env);
return {
url: env.DATABASE_URL,
poolMax: env.DATABASE_POOL_MAX,
ssl: env.DATABASE_SSL,
};
});
export type DatabaseConfig = ReturnType<typeof databaseConfig>;Config Module Setup
// config/config.module.ts
import { ConfigModule as NestConfigModule } from '@nestjs/config';
import { appConfig } from './app.config';
import { databaseConfig } from './database.config';
import { authConfig } from './auth.config';
export const ConfigModule = NestConfigModule.forRoot({
isGlobal: true,
load: [appConfig, databaseConfig, authConfig],
envFilePath: ['.env.local', '.env'],
});Usage with Type Safety
@Injectable()
export class AuthService {
constructor(
@Inject(authConfig.KEY) private readonly config: AuthConfig,
) {}
generateToken(userId: string): string {
return this.jwtService.sign(
{ sub: userId },
{
secret: this.config.jwtSecret,
expiresIn: this.config.jwtExpiresIn,
},
);
}
}Rules:
- >Validate environment variables with Zod at startup — fail fast on missing config
- >Use
registerAsfor namespaced, typed configuration - >Set
isGlobal: trueto avoid importing ConfigModule in every feature module - >Never access
process.envdirectly in services — always go through ConfigService or typed config
15. Class and Decorator Naming
Class names follow PascalCase with the same suffix convention as file names.
Standard Patterns
// Module
export class UsersModule {}
// Controller
@Controller('users')
export class UsersController {}
// Service
@Injectable()
export class UsersService {}
// Repository
@Injectable()
export class UsersRepository {}
// Guard
@Injectable()
export class JwtAuthGuard implements CanActivate {}
// Interceptor
@Injectable()
export class LoggingInterceptor implements NestInterceptor {}
// Pipe
@Injectable()
export class ParseUUIDPipe implements PipeTransform {}
// Filter
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {}
// DTO
export class CreateUserDto {}
export class UpdateUserDto {}
export class UserResponseDto {}
export class UserQueryDto {}
// Entity
export class UserEntity {}
// Strategy
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {}
// Provider token
export const DRIZZLE = Symbol('DRIZZLE');
// Custom decorator (function, not class)
export const CurrentUser = createParamDecorator(...);
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
export const Public = () => SetMetadata('isPublic', true);Rules
- >Class name = PascalCase version of file name
- >
UsersServiceinusers.service.ts - >
JwtAuthGuardinjwt-auth.guard.ts - >DTOs include the operation:
Create,Update,Query,Response - >Custom decorators are functions, not classes — use camelCase
- >Injection tokens use UPPER_SNAKE_CASE Symbols
16. File Naming Conventions
NestJS enforces a suffix convention for generated files. Follow it consistently for all files.
Standard Suffixes
| Suffix | Purpose | Example |
|---|---|---|
.module.ts | Module definition | users.module.ts |
.controller.ts | HTTP controller | users.controller.ts |
.service.ts | Business logic | users.service.ts |
.repository.ts | Data access | users.repository.ts |
.guard.ts | Route guard | jwt-auth.guard.ts |
.interceptor.ts | Request/response interceptor | logging.interceptor.ts |
.pipe.ts | Validation/transformation pipe | parse-uuid.pipe.ts |
.filter.ts | Exception filter | all-exceptions.filter.ts |
.decorator.ts | Custom decorator | current-user.decorator.ts |
.strategy.ts | Passport strategy | jwt.strategy.ts |
.dto.ts | Data transfer object | create-user.dto.ts |
.entity.ts | Domain entity | user.entity.ts |
.schema.ts | Drizzle schema | users.schema.ts |
.spec.ts | Unit test | users.service.spec.ts |
.e2e-spec.ts | E2E test | auth.e2e-spec.ts |
.config.ts | Configuration | database.config.ts |
.provider.ts | Custom provider | drizzle.provider.ts |
.interface.ts | TypeScript interface | pagination.interface.ts |
Naming Rules
- >kebab-case for all file names:
jwt-auth.guard.ts, notJwtAuthGuard.ts - >Singular noun for entity-related files:
user.entity.ts, notusers.entity.ts - >Plural noun for module-level files:
users.module.ts,users.controller.ts - >Action prefix for DTOs:
create-user.dto.ts,update-user.dto.ts - >Descriptive name for guards/interceptors:
jwt-auth.guard.ts,logging.interceptor.ts
Anti-Patterns
# BAD: PascalCase file names
UsersController.ts
JwtAuthGuard.ts
# BAD: Missing suffix
users.ts # What is this? Service? Controller?
auth.ts
# BAD: Generic names
helpers.ts
utils.ts
common.ts17. Nx Monorepo Structure
Nx provides integrated monorepo tooling with dependency graph analysis, affected commands, and code generators.
Workspace Layout
my-workspace/
apps/
api/ # NestJS application
src/
app/
app.module.ts
main.ts
project.json
web/ # Frontend application
src/
project.json
libs/
shared/
interfaces/ # Shared TypeScript interfaces
src/
lib/
user.interface.ts
pagination.interface.ts
index.ts
project.json
utils/ # Shared utility functions
src/
project.json
api/
feature-users/ # Domain feature library
src/
lib/
users.module.ts
users.controller.ts
users.service.ts
users.repository.ts
dto/
entities/
index.ts
project.json
data-access-db/ # Database access library
src/
lib/
database.module.ts
drizzle.provider.ts
index.ts
project.json
nx.json
tsconfig.base.jsonLibrary Types
# Nx library classification for NestJS
feature-* → Domain modules with controllers and services
data-access-* → Database, HTTP clients, external service integrations
util-* → Pure functions, helpers, no NestJS dependencies
shared-* → Cross-app interfaces, DTOs, constantsGenerating Libraries
# Feature library
pnpm nx g @nx/nest:library feature-users --directory=libs/api/feature-users
# Shared interfaces
pnpm nx g @nx/js:library interfaces --directory=libs/shared/interfaces
# Data access library
pnpm nx g @nx/nest:library data-access-db --directory=libs/api/data-access-dbImporting Across Libraries
// tsconfig.base.json paths
{
"compilerOptions": {
"paths": {
"@my-workspace/shared/interfaces": ["libs/shared/interfaces/src/index.ts"],
"@my-workspace/api/feature-users": ["libs/api/feature-users/src/index.ts"],
"@my-workspace/api/data-access-db": ["libs/api/data-access-db/src/index.ts"]
}
}
}
// Usage in app
import { UsersModule } from '@my-workspace/api/feature-users';
import { DatabaseModule } from '@my-workspace/api/data-access-db';
import { User } from '@my-workspace/shared/interfaces';Enforcing Boundaries
// nx.json — project tags
// In each project.json, add tags:
// apps/api: ["scope:api", "type:app"]
// libs/api/feature-users: ["scope:api", "type:feature"]
// libs/shared/interfaces: ["scope:shared", "type:interfaces"]// .eslintrc.json — boundary rules
{
"rules": {
"@nx/enforce-module-boundaries": [
"error",
{
"depConstraints": [
{ "sourceTag": "type:app", "onlyDependOnLibsWithTags": ["type:feature", "type:data-access", "type:util", "type:interfaces"] },
{ "sourceTag": "type:feature", "onlyDependOnLibsWithTags": ["type:data-access", "type:util", "type:interfaces"] },
{ "sourceTag": "type:data-access", "onlyDependOnLibsWithTags": ["type:util", "type:interfaces"] },
{ "sourceTag": "type:util", "onlyDependOnLibsWithTags": ["type:interfaces"] }
]
}
]
}
}Rules
- >Use
feature-*libraries for domain modules — keepapps/apithin (just AppModule imports) - >Enforce dependency direction: app → feature → data-access → util → interfaces
- >Use
pnpm nx affectedfor CI — only build/test what changed - >Barrel exports (
index.ts) define the public API of each library — never import from internal paths
18. Turborepo Monorepo Structure
Turborepo uses pnpm workspaces with a simpler, convention-based approach compared to Nx.
Workspace Layout
my-monorepo/
apps/
api/ # NestJS application
src/
modules/
common/
main.ts
package.json
tsconfig.json
web/ # Frontend application
package.json
packages/
config-typescript/ # Shared tsconfig presets
nestjs.json
react.json
package.json
config-eslint/ # Shared ESLint configs
nestjs.js
package.json
shared-types/ # Shared TypeScript types
src/
user.ts
pagination.ts
index.ts
package.json
shared-validators/ # Shared validation schemas
src/
user.schema.ts
index.ts
package.json
database/ # Shared database package
src/
schema/
drizzle.config.ts
index.ts
package.json
turbo.json
pnpm-workspace.yaml
package.jsonWorkspace Configuration
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"dependsOn": ["^build"],
"persistent": true,
"cache": false
},
"test": {
"dependsOn": ["^build"]
},
"lint": {}
}
}Package References
// apps/api/package.json
{
"name": "@my-monorepo/api",
"dependencies": {
"@my-monorepo/shared-types": "workspace:*",
"@my-monorepo/shared-validators": "workspace:*",
"@my-monorepo/database": "workspace:*"
}
}// apps/api/src/modules/users/users.service.ts
import { User } from '@my-monorepo/shared-types';
import { createUserSchema } from '@my-monorepo/shared-validators';
import { db, users } from '@my-monorepo/database';Shared Package Pattern
// packages/shared-types/package.json
{
"name": "@my-monorepo/shared-types",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"build": "tsc --build",
"lint": "eslint src/"
}
}// packages/shared-types/src/index.ts
export type { User, CreateUserInput, UpdateUserInput } from './user';
export type { PaginatedResponse, PaginationParams } from './pagination';Rules
- >Keep
packages/for shared code — each package has its ownpackage.jsonand build step - >Use
workspace:*for internal dependencies so pnpm links them automatically - >Shared types packages should export types only — no runtime dependencies
- >Use
turbo.jsontask dependencies (^build) to ensure packages build before apps - >Keep NestJS-specific code in
apps/api— packages should be framework-agnostic where possible
19. E2E Test Organization
E2E tests live in a top-level test/ directory and test full HTTP request/response cycles against a running application.
File Structure
test/
jest-e2e.json # E2E-specific Jest config
setup.ts # Global setup (database, app bootstrap)
teardown.ts # Global teardown (cleanup)
helpers/
test-app.helper.ts # Shared app creation
auth.helper.ts # Token generation for authenticated requests
database.helper.ts # Seed/reset database
users/
users.e2e-spec.ts # Users endpoint tests
users.fixtures.ts # Test data factories
auth/
auth.e2e-spec.ts
auth.fixtures.tsTest App Helper
// test/helpers/test-app.helper.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { AppModule } from '../../src/app.module';
export async function createTestApp(): Promise<INestApplication> {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
const app = moduleFixture.createNestApplication();
// Apply the same pipes/interceptors as production
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
return app;
}E2E Test Structure
// test/users/users.e2e-spec.ts
import * as request from 'supertest';
import { INestApplication } from '@nestjs/common';
import { createTestApp } from '../helpers/test-app.helper';
import { resetDatabase, seedUsers } from '../helpers/database.helper';
import { getAuthToken } from '../helpers/auth.helper';
describe('Users (e2e)', () => {
let app: INestApplication;
let authToken: string;
beforeAll(async () => {
app = await createTestApp();
await resetDatabase();
await seedUsers();
authToken = await getAuthToken(app, 'admin@example.com');
});
afterAll(async () => {
await app.close();
});
describe('GET /users', () => {
it('should return paginated users', () => {
return request(app.getHttpServer())
.get('/users?page=1&limit=10')
.set('Authorization', `Bearer ${authToken}`)
.expect(200)
.expect((res) => {
expect(res.body.data).toHaveLength(10);
expect(res.body.meta.total).toBeDefined();
});
});
it('should return 401 without auth token', () => {
return request(app.getHttpServer())
.get('/users')
.expect(401);
});
});
describe('POST /users', () => {
it('should create a user and return 201', () => {
return request(app.getHttpServer())
.post('/users')
.set('Authorization', `Bearer ${authToken}`)
.send({ email: 'new@example.com', name: 'New User', password: 'Str0ng!Pass' })
.expect(201)
.expect((res) => {
expect(res.body.id).toBeDefined();
expect(res.body.email).toBe('new@example.com');
expect(res.body).not.toHaveProperty('password');
});
});
it('should return 400 for invalid email', () => {
return request(app.getHttpServer())
.post('/users')
.set('Authorization', `Bearer ${authToken}`)
.send({ email: 'not-an-email', name: 'Bad' })
.expect(400);
});
});
});Rules
- >E2E tests go in
test/at the project root — separate from unit tests - >Mirror the module structure inside
test/(e.g.,test/users/,test/auth/) - >Use a real database (test instance) — don't mock at the E2E level
- >Apply the same global pipes, interceptors, and guards as production
- >Reset database state in
beforeAllorbeforeEach— tests must not depend on order - >Keep fixture/helper files in
test/helpers/for reuse across test suites
20. Unit Test File Organization
Unit tests live next to the source file they test. Every service, controller, and repository should have a .spec.ts file.
File Placement
modules/
users/
users.controller.ts
users.controller.spec.ts # ← next to source
users.service.ts
users.service.spec.ts
users.repository.ts
users.repository.spec.ts
dto/
create-user.dto.ts
create-user.dto.spec.ts # ← validate DTO decoratorsService Test Structure
// users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
import { UsersRepository } from './users.repository';
describe('UsersService', () => {
let service: UsersService;
let repository: jest.Mocked<UsersRepository>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{
provide: UsersRepository,
useValue: {
findById: jest.fn(),
findAll: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
},
],
}).compile();
service = module.get(UsersService);
repository = module.get(UsersRepository);
});
describe('findById', () => {
it('should return a user when found', async () => {
const user = { id: '1', email: 'test@example.com', name: 'Test' };
repository.findById.mockResolvedValue(user);
const result = await service.findById('1');
expect(result).toEqual(user);
expect(repository.findById).toHaveBeenCalledWith('1');
});
it('should throw NotFoundException when user not found', async () => {
repository.findById.mockResolvedValue(null);
await expect(service.findById('999')).rejects.toThrow(NotFoundException);
});
});
});Controller Test Structure
// users.controller.spec.ts
describe('UsersController', () => {
let controller: UsersController;
let service: jest.Mocked<UsersService>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [UsersController],
providers: [
{
provide: UsersService,
useValue: {
findById: jest.fn(),
create: jest.fn(),
},
},
],
}).compile();
controller = module.get(UsersController);
service = module.get(UsersService);
});
it('should delegate to service and return result', async () => {
const dto = { email: 'test@example.com', name: 'Test' };
const created = { id: '1', ...dto };
service.create.mockResolvedValue(created);
const result = await controller.create(dto);
expect(result).toEqual(created);
expect(service.create).toHaveBeenCalledWith(dto);
});
});Rules
- >Place
.spec.tsfiles next to the source file — not in a separatetest/directory - >Mock only direct dependencies — use
jest.Mocked<T>for type-safe mocks - >Test behavior, not implementation — assert on return values and thrown exceptions
- >One
describeblock per method,itblocks for each scenario - >Controller tests should be thin — verify delegation to service, not business logic
- >Use
Test.createTestingModuleto leverage NestJS DI in tests
21. When to Split Modules
Modules grow over time. Recognize the signals and split before complexity becomes unmanageable.
Split Signals
Split a module when:
✓ More than 5 services in one module
✓ Circular dependencies between services in the same module
✓ Two distinct domain concepts share a module (e.g., Orders + Inventory)
✓ A controller has routes for unrelated resources
✓ Tests require mocking half the module to test one service
✓ Multiple teams need to modify the same module frequentlyBefore: Monolith Module
// BAD: commerce.module.ts — too many responsibilities
@Module({
controllers: [
OrdersController,
PaymentsController,
RefundsController,
InvoicesController,
ShippingController,
],
providers: [
OrdersService,
PaymentsService,
RefundsService,
InvoicesService,
ShippingService,
OrdersRepository,
PaymentsRepository,
],
})
export class CommerceModule {}After: Focused Modules
// GOOD: orders.module.ts
@Module({
imports: [PaymentsModule, ShippingModule],
controllers: [OrdersController],
providers: [OrdersService, OrdersRepository],
exports: [OrdersService],
})
export class OrdersModule {}
// GOOD: payments.module.ts
@Module({
controllers: [PaymentsController, RefundsController],
providers: [PaymentsService, RefundsService, PaymentsRepository],
exports: [PaymentsService],
})
export class PaymentsModule {}
// GOOD: shipping.module.ts
@Module({
controllers: [ShippingController],
providers: [ShippingService],
exports: [ShippingService],
})
export class ShippingModule {}
// GOOD: invoices.module.ts
@Module({
imports: [OrdersModule, PaymentsModule],
controllers: [InvoicesController],
providers: [InvoicesService],
})
export class InvoicesModule {}Communication After Split
// Use events to decouple modules that were previously tightly coupled
// orders.service.ts
@Injectable()
export class OrdersService {
constructor(
private readonly repository: OrdersRepository,
private readonly eventEmitter: EventEmitter2,
) {}
async complete(orderId: string): Promise<Order> {
const order = await this.repository.update(orderId, { status: 'completed' });
// Other modules react to events instead of being called directly
this.eventEmitter.emit('order.completed', { orderId: order.id, total: order.total });
return order;
}
}
// invoices.service.ts — listens instead of being called
@Injectable()
export class InvoicesService {
@OnEvent('order.completed')
async generateInvoice(payload: { orderId: string; total: number }) {
await this.create({ orderId: payload.orderId, amount: payload.total });
}
}Rules
- >One domain concept per module — if you can name two concepts, you need two modules
- >Split by noun (Orders, Payments), not by verb (Creating, Processing)
- >After splitting, communicate between modules via exported services or events
- >Use events for one-to-many or fire-and-forget communication
- >Use direct service imports for synchronous, required operations
- >Keep the
exportsarray minimal — only expose what other modules actually need
22. When to Split Services
A service that does too much becomes hard to test, hard to understand, and a merge conflict magnet.
Split Signals
Split a service when:
✓ More than ~300 lines
✓ Constructor has more than 5 dependencies
✓ Methods group into distinct clusters with separate concerns
✓ Some methods are reused by other modules, others are internal
✓ Test setup requires mocking 6+ dependencies
✓ The class name needs "And" to describe what it doesBefore: God Service
// BAD: users.service.ts — authentication + profile + notifications
@Injectable()
export class UsersService {
constructor(
private readonly repo: UsersRepository,
private readonly jwtService: JwtService,
private readonly argon: ArgonService,
private readonly mailer: MailService,
private readonly s3: S3Service,
private readonly cache: CacheService,
private readonly eventEmitter: EventEmitter2,
) {}
async register(dto: RegisterDto) { /* hash password, create user, send welcome email */ }
async login(dto: LoginDto) { /* verify password, generate tokens */ }
async refreshToken(token: string) { /* validate, rotate */ }
async updateProfile(id: string, dto: UpdateProfileDto) { /* update fields */ }
async uploadAvatar(id: string, file: Buffer) { /* upload to S3, update URL */ }
async changePassword(id: string, dto: ChangePasswordDto) { /* verify old, hash new */ }
async sendPasswordReset(email: string) { /* generate token, send email */ }
async getNotificationPreferences(id: string) { /* read from cache or DB */ }
async updateNotificationPreferences(id: string, dto: NotifPrefsDto) { /* update, bust cache */ }
}After: Focused Services
// GOOD: auth.service.ts — authentication only
@Injectable()
export class AuthService {
constructor(
private readonly usersRepo: UsersRepository,
private readonly jwtService: JwtService,
private readonly argon: ArgonService,
) {}
async register(dto: RegisterDto) { /* ... */ }
async login(dto: LoginDto) { /* ... */ }
async refreshToken(token: string) { /* ... */ }
async changePassword(userId: string, dto: ChangePasswordDto) { /* ... */ }
async sendPasswordReset(email: string) { /* ... */ }
}
// GOOD: users-profile.service.ts — profile management
@Injectable()
export class UsersProfileService {
constructor(
private readonly repo: UsersRepository,
private readonly s3: S3Service,
) {}
async updateProfile(id: string, dto: UpdateProfileDto) { /* ... */ }
async uploadAvatar(id: string, file: Buffer) { /* ... */ }
}
// GOOD: notification-preferences.service.ts
@Injectable()
export class NotificationPreferencesService {
constructor(
private readonly repo: UsersRepository,
private readonly cache: CacheService,
) {}
async get(userId: string) { /* ... */ }
async update(userId: string, dto: NotifPrefsDto) { /* ... */ }
}Extraction Strategy
1. Identify clusters — group methods by which dependencies they use
2. Name the new service — if you can't find a clear name, the split may be wrong
3. Move methods — extract to new service class
4. Update the module — register new providers, update exports
5. Update dependents — other services/controllers now inject the specific service
6. Verify tests — each new service should be independently testable with fewer mocksRules
- >Split by responsibility, not by size alone — a 400-line service with one clear responsibility is fine
- >Each service should be describable in one sentence without "and"
- >After splitting, the original module registers all resulting services
- >If a split creates a service useful to multiple modules, consider moving it to a shared module
- >Constructor injection count is a smell indicator: 3-4 is healthy, 6+ warrants review
- >Prefer composition over inheritance — services call each other, don't extend each other