Why We Standardised on NestJS
When we evaluated backend frameworks for the kind of enterprise work we do — complex data models, multiple integration points, long-running projects with evolving teams — NestJS consistently won on maintainability. The module system enforces boundaries. Dependency injection makes testing tractable. The TypeScript-first approach means type errors surface at compile time, not in production at 2am.
But NestJS is a framework, not a convention. Two teams can build completely different-looking codebases on top of it. After several years and dozens of projects, this is the structure we have converged on.
The Module Boundary Rule
The most important convention in our NestJS codebases: a module owns its data. No module directly imports a repository from another module. Cross-module communication happens through exported services, not shared repositories.
This rule prevents the coupling that makes large codebases brittle. When the Orders module needs customer data, it calls a method on the exported CustomersService, not the CustomerRepository. The Customers module can change its persistence layer, add caching, or apply business rules — and the Orders module never needs to change.
Directory Structure
src/
├── app.module.ts
├── main.ts
├── common/
│ ├── decorators/ # @CurrentUser, @Public, @Roles
│ ├── filters/ # Global exception filter
│ ├── guards/ # JwtAuthGuard, RolesGuard
│ ├── interceptors/ # ResponseInterceptor, LoggingInterceptor
│ ├── pipes/ # ParseUUIDPipe, ValidationPipe config
│ └── types/ # Shared TypeScript interfaces
├── config/
│ ├── configuration.ts # Config factory (maps env vars to typed config)
│ └── validation.schema.ts # Joi validation schema for env vars
├── database/
│ ├── entities/ # All TypeORM entities
│ ├── migrations/ # TypeORM migration files
│ └── seeds/ # Seed scripts
└── modules/
├── auth/
├── users/
├── [domain-module]/
│ ├── dto/
│ │ ├── create-x.dto.ts
│ │ └── update-x.dto.ts
│ ├── x.controller.ts
│ ├── x.module.ts
│ └── x.service.ts
└── ...
The Response Interceptor Pattern
Every API response in our codebases follows the same envelope structure:
{ "success": true, "data": { ... } }
// or on error:
{ "success": false, "error": "NOT_FOUND", "message": "Resource not found" }
This is enforced by a global ResponseInterceptor that wraps every controller response. The error format is enforced by a global HttpExceptionFilter. Frontend engineers and API consumers get a consistent contract regardless of which endpoint they call.
DTO Design: Strict Validation at the Boundary
We use class-validator and class-transformer for all DTOs, with the global validation pipe configured with whitelist: true and forbidNonWhitelisted: true. This means any property not declared in the DTO is stripped from the request — your service layer only ever sees data that has been explicitly validated and typed.
For update DTOs, we extend PartialType(CreateXDto) from @nestjs/swagger. This generates a type where all fields are optional, and keeps the Swagger documentation accurate without duplicating validators.
Config Validation
Application startup should fail loudly if required environment variables are missing or invalid. We use a Joi schema in the NestJS config module:
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
validationSchema: Joi.object({
DB_HOST: Joi.string().required(),
DB_PORT: Joi.number().default(5432),
JWT_ACCESS_SECRET: Joi.string().min(32).required(),
AWS_S3_BUCKET: Joi.string().required(),
// ...
}),
validationOptions: { abortEarly: false }
})
This surfaces configuration errors at startup, not at 3am when the first request hits a code path that needs the missing variable.
Testing Strategy
We write unit tests for service methods and integration tests for controllers. Unit tests mock the repository and any external services. Integration tests use @nestjs/testing with an in-memory SQLite database (for simple schemas) or a Docker PostgreSQL instance (for anything that uses PostgreSQL-specific features like jsonb, array types, or row-level security).
The rule we enforce: do not test the framework. NestJS handles dependency injection, module loading, and pipe execution. Testing that a ValidationPipe throws on invalid input is not a useful test. Test your business logic — what the service does with valid input, how it handles edge cases, what it throws when data is missing.
The Migrations Workflow
We run TypeORM with synchronize: false in all environments, including development after the initial schema stabilises. synchronize: true is a footgun — it can drop columns you still need if you rename an entity property. All schema changes go through explicit migration files generated by typeorm migration:generate and reviewed before being applied.
The migration workflow: generate migration → review the SQL it will execute → run against a staging database → verify → apply to production during a maintenance window. This sounds slow but it catches destructive schema changes before they reach production.
