NestJS API Example
This example demonstrates NestJS layered architecture with Stricture enforcement. It’s a simple users API that shows proper separation between DTOs (API contracts), Entities (database models), Services (business logic), and Controllers (HTTP handling).
What You’ll Learn
- The critical difference between DTOs and Entities
- Why controllers should never expose entities directly
- How to structure a NestJS module properly
- How Stricture prevents common NestJS security issues
- The proper data flow: HTTP → Controller → Service → Repository
Architecture Overview
%%{init: {'theme':'base', 'themeVariables': { 'primaryColor':'#b3d9ff','primaryBorderColor':'#1e90ff','secondaryColor':'#ffd9b3','secondaryBorderColor':'#ff8c00','tertiaryColor':'#b3ffb3','tertiaryBorderColor':'#32cd32'}}}%%
graph TB
Client["🌐 HTTP Client"]
subgraph Module["📦 Users Module"]
Controller["Controller
HTTP Layer"]
Service["Service
Business Logic"]
subgraph Contracts["📋 Contracts"]
DTO["DTOs
API Input/Output"]
end
subgraph Data["💾 Data Layer"]
Entity["Entities
Database Models"]
end
end
Client --> Controller
Controller --> DTO
Controller --> Service
Service --> DTO
Service --> Entity
style Module fill:#f0f0f0,stroke:#666,stroke-width:2px
style Contracts fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
style Data fill:#ccffcc,stroke:#32cd32,stroke-width:2px
style Controller fill:#ffe6cc,stroke:#ff8c00,stroke-width:2px
style Service fill:#ffd9b3,stroke:#ff8c00,stroke-width:2px
Key Principle: Controllers use DTOs for API contracts. Services convert between Entities (internal) and DTOs (external).
File Structure
examples/nestjs-basic/├── .stricture/│ └── config.json # { "preset": "@stricture/nestjs" }├── src/│ ├── users/│ │ ├── dto/ # API contracts│ │ │ ├── create-user.dto.ts # Input│ │ │ └── user.dto.ts # Output│ │ ├── entities/ # Database models│ │ │ └── user.entity.ts # Internal representation│ │ ├── users.controller.ts # HTTP handling│ │ ├── users.service.ts # Business logic│ │ └── users.module.ts # Module definition│ ├── app.module.ts│ └── main.ts└── package.jsonThe Critical DTO/Entity Separation
This is the most important concept in NestJS architecture:
Why DTOs and Entities Must Be Separate
Problem: Exposing entities directly in your API
// ❌ DANGEROUS - Exposing entity directly@Controller('users')export class UsersController { @Get() findAll(): Promise<User[]> { // ← Entity exposed! return this.usersService.findAll() }}
// Entity with sensitive fieldexport class User { id: number email: string passwordHash: string // ⚠️ LEAKED TO API!}API Response:
[ { "id": 1, "passwordHash": "hashed_secret123" // 🚨 SECURITY ISSUE! }]Solution: Separate DTOs from Entities
// ✅ SAFE - Using DTO@Controller('users')export class UsersController { @Get() findAll(): Promise<UserDto[]> { // ← DTO for API return this.usersService.findAll() }}
// Entity (internal database model)export class User { id: number email: string passwordHash: string // Internal detail createdAt: Date}
// DTO (API contract)export class UserDto { id: number email: string name: string createdAt: Date // passwordHash is NOT included!}API Response (safe):
[ { "id": 1, "name": "John Doe", "createdAt": "2024-01-01T00:00:00.000Z" }]Benefits:
- 🔒 Security: Sensitive fields never exposed
- 📜 API Evolution: Change database schema without breaking API
- ✅ Type Safety: DTOs provide compile-time validation
- 🎯 Clarity: Clear API contracts
The Four Layers
1. DTOs (API Contracts)
Location: src/users/dto/
Purpose: Define API input and output shapes
Output DTO: src/users/dto/user.dto.ts
/** * UserDto - Output for API responses * Does NOT include sensitive fields like passwordHash */export class UserDto { id: number email: string name: string createdAt: Date // Note: passwordHash is NOT included!}Input DTO: src/users/dto/create-user.dto.ts
import { IsEmail, IsString, MinLength } from 'class-validator'
/** * CreateUserDto - Input for user creation * Uses validation decorators */export class CreateUserDto { @IsEmail() email: string
@IsString() @MinLength(2) name: string
@IsString() @MinLength(8) password: string // ← Plain password (will be hashed)}Key Points:
- ✅ Use
class-validatordecorators for validation - ✅ Separate input DTOs from output DTOs
- ✅ Never include sensitive computed fields (like
passwordHash)
2. Entities (Database Models)
Location: src/users/entities/
Purpose: Internal database representation
File: src/users/entities/user.entity.ts
/** * User entity - Database model * Contains internal database fields like passwordHash */export class User { id: number email: string name: string passwordHash: string // ← Internal detail, NOT exposed in API createdAt: Date}Key Points:
- ✅ Internal representation only
- ✅ Can include sensitive fields
- ✅ Used by services and repositories
- ❌ Should NEVER be exposed in controller methods
3. Services (Business Logic)
Location: src/users/users.service.ts
Purpose: Orchestrate business logic, convert entities to DTOs
File: src/users/users.service.ts
import { Injectable } from '@nestjs/common'import { User } from './entities/user.entity'import { CreateUserDto } from './dto/create-user.dto'import { UserDto } from './dto/user.dto'
@Injectable()export class UsersService { private users: User[] = [] private nextId = 1
async create(dto: CreateUserDto): Promise<UserDto> { // Create entity from DTO const user: User = { id: this.nextId++, email: dto.email, name: dto.name, passwordHash: `hashed_${dto.password}`, // Hash the password createdAt: new Date() }
this.users.push(user)
// ✅ Convert entity to DTO before returning return this.toDto(user) }
async findAll(): Promise<UserDto[]> { // ✅ Map entities to DTOs return this.users.map(u => this.toDto(u)) }
async findOne(id: number): Promise<UserDto | null> { const user = this.users.find(u => u.id === id) return user ? this.toDto(user) : null }
/** * Convert entity to DTO * ✅ Excludes sensitive fields like passwordHash */ private toDto(user: User): UserDto { return { id: user.id, email: user.email, name: user.name, createdAt: user.createdAt // passwordHash is NOT included in DTO } }}Key Points:
- ✅ Works with entities internally
- ✅ Returns DTOs to controllers
- ✅ Private
toDto()method for conversion - ✅ Business logic and validation
- ❌ Never returns entities directly
The toDto() Pattern:
This pattern is crucial:
- Service methods work with entities internally
- Before returning, convert to DTO
- This ensures sensitive fields are never exposed
4. Controllers (HTTP Layer)
Location: src/users/users.controller.ts
Purpose: Handle HTTP requests, use DTOs exclusively
File: src/users/users.controller.ts
import { Controller, Get, Post, Body, Param } from '@nestjs/common'import { UsersService } from './users.service'import { CreateUserDto } from './dto/create-user.dto'import { UserDto } from './dto/user.dto'
/** * UsersController - HTTP layer * Uses DTOs for all API input/output (never entities!) */@Controller('users')export class UsersController { constructor(private readonly usersService: UsersService) {}
@Post() create(@Body() createUserDto: CreateUserDto): Promise<UserDto> { return this.usersService.create(createUserDto) }
@Get() findAll(): Promise<UserDto[]> { return this.usersService.findAll() }
@Get(':id') findOne(@Param('id') id: string): Promise<UserDto | null> { return this.usersService.findOne(+id) }}Key Points:
- ✅ All method parameters and return types use DTOs
- ✅ Never imports entities
- ✅ Delegates to service layer
- ✅ Focused on HTTP concerns only
Type Safety:
@Post()create(@Body() dto: CreateUserDto): Promise<UserDto> { // ↑ Input DTO ↑ Output DTO // Validated by class-validator}Stricture Configuration
The example uses the @stricture/nestjs preset.
File: .stricture/config.json
{ "preset": "@stricture/nestjs"}What Stricture Enforces
Controllers:
- ✅ Can import services
- ✅ Can import DTOs
- ❌ Cannot import entities (prevents accidental exposure)
- ❌ Cannot import repositories
Services:
- ✅ Can import DTOs
- ✅ Can import entities
- ✅ Can import repositories
- ✅ Can import other services
DTOs:
- ✅ Can import validators (
class-validator) - ❌ Cannot import entities (keeps API contracts independent)
- ❌ Cannot import services
Entities:
- ✅ Can import ORM decorators
- ❌ Cannot import DTOs (entities are internal)
- ❌ Cannot import services
Running the Example
Installation
cd examples/nestjs-basicnpm installDevelopment
npm run start:devServer starts on http://localhost:3000
Production Build
npm run buildnpm run start:prodLinting
npm run lintAPI Endpoints
Create a User
curl -X POST http://localhost:3000/users \ -H "Content-Type: application/json" \ -d '{ "email": "[email protected]", "name": "John Doe", "password": "secret123" }'Response:
{ "id": 1, "name": "John Doe", "createdAt": "2024-01-01T00:00:00.000Z"}Note: passwordHash is NOT in the response!
Get All Users
curl http://localhost:3000/usersResponse:
[ { "id": 1, "name": "John Doe", "createdAt": "2024-01-01T00:00:00.000Z" }, { "id": 2, "name": "Jane Smith", "createdAt": "2024-01-02T00:00:00.000Z" }]Get One User
curl http://localhost:3000/users/1Response:
{ "id": 1, "name": "John Doe", "createdAt": "2024-01-01T00:00:00.000Z"}Try Breaking the Architecture
Want to see Stricture in action?
Violation 1: Controller Exposing Entity
Try this in users.controller.ts:
// ❌ BAD - Controller exposing entityimport { User } from './entities/user.entity'
@Controller('users')export class UsersController { @Get() findAll(): Promise<User[]> { // ❌ VIOLATION! return this.usersService.findAll() }}Run npm run lint:
src/users/users.controller.ts 2:1 error Controllers cannot import entities
Why: Exposing entities in your API is a security risk. Entities often contain sensitive fields (passwordHash, internalStatus, etc.) that should never be exposed to API consumers.
Fix: Use DTOs instead: 1. Create a DTO in dto/user.dto.ts 2. Service should return UserDto, not User 3. Controller method returns Promise<UserDto[]>
Example: // users.service.ts private toDto(user: User): UserDto { return { id: user.id, email: user.email, name: user.name // passwordHash NOT included } }
@stricture/enforce-boundaries
❌ 1 errorViolation 2: DTO Importing Entity
Try this in dto/user.dto.ts:
// ❌ BAD - DTO importing entityimport { User } from '../entities/user.entity'
export class UserDto extends User { // ❌ VIOLATION! // This would include all entity fields, including passwordHash!}Run npm run lint:
src/users/dto/user.dto.ts 2:1 error DTOs cannot import entities
Why: DTOs are API contracts and should be independent of internal database models. This separation allows you to: - Change database schema without breaking API - Control exactly what's exposed in your API - Add/remove entity fields without affecting API consumers
Fix: Define DTO independently: export class UserDto { id: number email: string name: string // Only fields you want to expose }
@stricture/enforce-boundaries
❌ 1 errorViolation 3: Controller Importing Repository
Try this in users.controller.ts:
// ❌ BAD - Controller directly accessing repositoryimport { UserRepository } from './users.repository'
@Controller('users')export class UsersController { constructor( private readonly repository: UserRepository // ❌ VIOLATION! ) {}
@Get() findAll() { return this.repository.findAll() // ❌ Bypassing service layer! }}Run npm run lint:
src/users/users.controller.ts 2:1 error Controllers cannot import repositories
Why: Controllers should delegate to services, not access repositories directly. This maintains proper layering and separation of concerns.
Layers: Controller → Service → Repository
Fix: Import and use the service: import { UsersService } from './users.service'
constructor(private readonly usersService: UsersService) {}
@stricture/enforce-boundaries
❌ 1 errorCommon Patterns
Pattern 1: Pagination DTO
export class PaginatedUsersDto { data: UserDto[] total: number page: number pageSize: number}
// users.service.tsasync findPaginated(page: number, pageSize: number): Promise<PaginatedUsersDto> { const start = (page - 1) * pageSize const end = start + pageSize const paginatedUsers = this.users.slice(start, end)
return { data: paginatedUsers.map(u => this.toDto(u)), total: this.users.length, page, pageSize }}
// users.controller.ts@Get()findAll( @Query('page') page = '1', @Query('pageSize') pageSize = '10'): Promise<PaginatedUsersDto> { return this.usersService.findPaginated(+page, +pageSize)}Pattern 2: Partial Update DTO
import { PartialType } from '@nestjs/mapped-types'import { CreateUserDto } from './create-user.dto'
export class UpdateUserDto extends PartialType(CreateUserDto) { // All fields from CreateUserDto, but optional}
// users.service.tsasync update(id: number, dto: UpdateUserDto): Promise<UserDto> { const user = this.users.find(u => u.id === id) if (!user) throw new Error('User not found')
Object.assign(user, dto) return this.toDto(user)}Pattern 3: Custom Validation
import { IsEmail, IsString, MinLength, Matches } from 'class-validator'
export class CreateUserDto { @IsEmail() email: string
@IsString() @MinLength(2) name: string
@IsString() @MinLength(8) @Matches(/^(?=.*[A-Z])(?=.*[0-9])/, { message: 'Password must contain at least one uppercase letter and one number' }) password: string}Benefits of This Architecture
1. Security
// ✅ Service ensures sensitive fields never leakprivate toDto(user: User): UserDto { return { id: user.id, email: user.email, name: user.name // passwordHash, internalNotes, etc. NOT included }}2. API Evolution
Change database schema without breaking API:
// Add new entity field (internal only)export class User { id: number email: string name: string passwordHash: string internalStatus: string // ← New field lastLoginAt: Date // ← New field createdAt: Date}
// DTO stays the same (API unchanged)export class UserDto { id: number email: string name: string createdAt: Date}3. Type Safety
// TypeScript catches type errors at compile time@Get()findAll(): Promise<UserDto[]> { // ← Compiler enforces this // Returning User[] would be a type error! return this.usersService.findAll()}4. Clear Contracts
// API consumers know exactly what to expectinterface UserApiResponse { id: number email: string name: string createdAt: string}
// Entity can change without breaking this contractCommon Questions
Q: Why not use TypeORM’s exclude feature?
A: It’s error-prone and easy to forget:
// ❌ Risky - One mistake exposes password@Entity()export class User { @Column() email: string
@Column({ select: false }) // ← Easy to forget passwordHash: string}Better: Explicit DTOs that only include what you want:
// ✅ Safe - Only includes what you defineexport class UserDto { id: number email: string // passwordHash can't possibly be here}Q: Can I use the same DTO for input and output?
A: Generally no. Input and output often differ:
// Input - includes passwordexport class CreateUserDto { email: string password: string // ← Plain password}
// Output - includes computed fieldsexport class UserDto { id: number // ← Generated by server email: string createdAt: Date // ← Generated by server // password is NOT included}Q: How do I handle nested relationships?
A: Create DTOs for each entity:
export class Post { id: number title: string author: User // ← Entity relation}
// dto/post.dto.tsexport class PostDto { id: number title: string author: UserDto // ← DTO relation}
// posts.service.tsprivate toDto(post: Post): PostDto { return { id: post.id, title: post.title, author: this.usersService.toDto(post.author) // ← Nested conversion }}Next Steps
- Add authentication: Use Passport.js with JWT
- Real database: Add TypeORM or Prisma
- Validation: Add more class-validator decorators
- Error handling: Create custom exception filters
- Testing: Write unit tests for services