NestJS Controller Patterns
This skill covers all controller patterns in erify_api, from general principles to module-specific implementations.
Canonical Examples
Study these real implementations as the source of truth:
- Admin: admin-client.controller.ts
- Studio: studio-task-template.controller.ts
- Base Controllers: base-admin.controller.ts, base-studio.controller.ts, base.controller.ts
Detailed code examples: See references/controller-examples.md
Core Responsibilities
ALL controllers share these responsibilities:
- Accept HTTP requests (Method, Body, Query, Headers)
- Validate input (Check format, required fields)
- Translate DTOs (Convert external format → internal service payloads)
- Call Service Layer (Delegate business logic)
- Serialize Response (Transform/Filter data)
- Handle Errors (Map exceptions to HTTP Status codes)
Shared Principles
1. Response Serialization
🔴 Critical: ALL endpoints must use Zod for response serialization to ensure no internal data (like database IDs) leaks.
- Use
@ZodResponse(Schema, Status)for standard responses - Use
@ZodPaginatedResponse(Schema)for list endpoints
@Get(':id')
@ZodResponse(UserDto)
async getUser(...) { ... }
2. Validation Pipes
🟡 Recommended: Always use UidValidationPipe for validating uid parameters.
@Param('id', new UidValidationPipe(UserService.UID_PREFIX, 'User'))
id: string
3. DTO Standards
- Request DTOs: Define validation rules using
zod - Response DTOs: Define output shape, excluding sensitive fields
- Pagination: Use
PaginationQueryDtofrom@/lib/pagination/pagination.schema
4. HTTP Status Codes
| Method | Success Code | Decorator Implementation |
|---|---|---|
GET |
200 OK | Default / @ZodResponse(S, HttpStatus.OK) |
POST |
201 Created | @ZodResponse(S, HttpStatus.CREATED) |
PATCH |
200 OK | @ZodResponse(S, HttpStatus.OK) |
DELETE |
204 No Content | @ZodResponse(undefined, HttpStatus.NO_CONTENT) |
5. Payload Translation & Property Filtering
🔴 Critical: Controllers MUST adapt external DTOs to internal Service Payloads and filter unnecessary properties.
Why:
- Services should be decoupled from HTTP layer and context-agnostic
- Services define clean contracts for exactly what they need
- DTOs may contain extra fields (pagination, UI state, metadata) that services don't need
- Passing entire DTOs couples services to API structure changes
Rule: ALWAYS extract only the properties the service contract requires. NEVER pass entire DTO objects.
@Post()
async create(@Param('orgId') orgId: string, @Body() dto: CreateUserDto) {
// ✅ GOOD: Extract ONLY what service needs
const { name, email } = dto;
// Filtered out: dto.pageSize, dto.sortOrder, etc.
return this.userService.create({
name,
email,
org: { connect: { uid: orgId } }
});
}
// ❌ BAD: Pass entire DTO
async create(@Body() dto: CreateUserDto) {
return this.userService.create(dto); // Service now knows about ALL DTO fields
}
// ❌ BAD: Spread operator without explicit filtering
async create(@Body() dto: CreateUserDto) {
return this.userService.create({ ...dto }); // Same problem
}
// ❌ BAD: Deleting properties
async create(@Body() dto: CreateUserDto) {
delete dto.pageSize; // Mutating DTO, not explicit about what service needs
return this.userService.create(dto);
}
Pattern for complex DTOs:
// DTO may have many fields for validation/UI purposes
interface CreateTaskDto {
name: string;
description: string;
assigneeId?: string;
// Extra fields controllers use but services don't need:
returnUrl?: string; // UI navigation
skipNotification?: boolean; // HTTP-specific flag
}
@Post()
async create(@Body() dto: CreateTaskDto) {
// ✅ Extract only service contract fields
const { name, description, assigneeId } = dto;
const task = await this.taskService.create({
name,
description,
assigneeId,
});
// Controller handles HTTP-specific logic
if (dto.skipNotification) {
// Controller decision, not service concern
}
return task;
}
6. Layer Boundaries
🟡 Recommended: Maintain strict separation of concerns.
[ HTTP Controller ] <-- Knows about Requests, Responses, Status Codes
|
v
[ Business Service ] <-- Knows about Logic, Transactions, Domain Errors
|
v
[ Data Repository ] <-- Knows about Database, SQL, ORM
Anti-Patterns:
- ❌ Controller running SQL queries (Leaky abstraction)
- ❌ Controller containing complex logic (Fat controller)
- ❌ Service returning HTTP objects (Service coupled to transport)
7. Pagination
🟡 Recommended: Always limit lists to prevent DoS and performance issues.
Standard Response Format:
{
"data": [ ... ],
"meta": {
"page": 1,
"limit": 10,
"total": 150
}
}
Admin Controllers
Use Case: System admin endpoints for managing resources across the entire system.
Core Principles
- 🔴 Critical: All admin controllers MUST extend
BaseAdminController - 🔴 Critical: Automatically protected by
@AdminProtected()via the base class - 🟡 Recommended: Use
@AdminResponse()and@AdminPaginatedResponse()instead of generic Zod decorators - 🟡 Recommended: All routes must start with
admin/
Base Controller Features
BaseAdminController provides:
@AdminProtected()decorator applicationcreatePaginatedResponse()helperensureResourceExists()andensureFieldExists()helpers
Checklist
- Controller extends
BaseAdminController - Route prefix is
admin/<resource> - Uses
@AdminResponse/@AdminPaginatedResponse - Uses
UidValidationPipefor ID parameters - Uses
ensureResourceExistsfor 404 checks
Studio Controllers
Use Case: Studio-scoped endpoints for resources that belong to a specific studio.
Core Principles
- 🔴 Critical: Extend
BaseStudioController - 🔴 Critical: Path structure must be
studios/:studioId/resource - 🔴 Critical: All queries must filter by studio context
- 🟡 Recommended: Use
@ZodResponse()and@ZodPaginatedResponse()
Authorization
BaseStudioController automatically requires studio membership via @StudioProtected().
Add role restrictions at class or method level:
import { STUDIO_ROLE } from '@eridu/api-types/memberships';
import { StudioProtected } from '@/lib/decorators/studio-protected.decorator';
// All endpoints require ADMIN
@StudioProtected([STUDIO_ROLE.ADMIN])
@Controller('studios/:studioId/task-templates')
export class StudioTaskTemplateController extends BaseStudioController { }
// Mixed: default membership, admin for delete
@Controller('studios/:studioId/resource')
export class ResourceController extends BaseStudioController {
@Get() list() { } // Any member
@StudioProtected([STUDIO_ROLE.ADMIN])
@Delete(':id') delete() { } // Admin only
}
Available roles: STUDIO_ROLE.ADMIN, STUDIO_ROLE.MEMBER
Quick Reference
| Pattern | Code |
|---|---|
| Studio scoping | studioUid: studioId (list), studio: { uid: studioId } (findOne/update/delete) |
| UID validation | @Param('studioId', new UidValidationPipe(StudioService.UID_PREFIX, 'Studio')) |
| Studio relation | studio: { connect: { uid: studioId } } (create) |
| DTO extraction | const { name, description } = dto; then pass to service |
Checklist
- Extends
BaseStudioController - Route:
studios/:studioId/resource - Authorization:
@StudioProtected([roles])if role restrictions needed - UID validation on
studioIdand resourceid - Studio scoping in all queries
- Create operations connect studio relation
User (Me) Controllers
Use Case: Authenticated users interacting with their own resources.
Core Principles
- 🟡 Recommended: Standard NestJS controller (no specific base class required)
- 🔴 Critical: ALWAYS use
@CurrentUser()to scope operations to the authenticated user - 🟡 Recommended: Routes typically start with
me/or implied user context
Checklist
- Route starts with
me/or is user-scoped - Uses
@CurrentUser()to get user ID - 🔴 Critical: NEVER trusts user ID from request body/params for self-operations
- Uses
@ZodResponseor@ZodPaginatedResponse
Backdoor Controllers
Use Case: Service-to-service communication or internal tools using API Key authentication.
Core Principles
- 🔴 Critical: All backdoor controllers MUST extend
BaseBackdoorController - 🔴 Critical: Automatically authenticated via API Key using the
@Backdoor()decorator (from base class) - 🟡 Recommended: All routes must start with
backdoor/
Checklist
- Controller extends
BaseBackdoorController - Route prefix is
backdoor/<resource> - Uses
@ZodResponsefor serialization - NO
@CurrentUserdecorator (concept doesn't exist for API keys)
Integration Controllers
Use Case: External integrations like Google Sheets extensions or webhooks.
Core Principles
- 🟡 Recommended: Integration controllers should extend their specific base class (e.g.,
BaseGoogleSheetsController) - 🟡 Recommended: Use specific decorators for the integration type (e.g.,
@GoogleSheets()) - 🟡 Recommended: Response format often requires specific serialization compatibility (e.g., snake_case for external tools)
Checklist
- Controller extends appropriate base (e.g.,
BaseGoogleSheetsController) - Uses specific auth decorator (e.g.,
@GoogleSheets) - Uses
@ZodSerializerDtofor strict output serialization
Best Practices Summary
- Choose the correct controller type (Admin/Studio/Me/Backdoor/Integration)
- Extend the appropriate base controller
- Use Zod serialization for ALL outputs with
@ZodResponse - Use
UidValidationPipefor all UIDs - 🔴 Critical: Translate DTOs into typed Service Payloads (never pass DTOs directly)
- Apply proper authorization decorators
- Scope queries appropriately (studio/user context)
- Document all endpoints via decorators
- Use correct HTTP status codes
- Implement pagination for list endpoints
Related Skills
- Service Pattern NestJS - Service layer patterns
- Data Validation - Input validation and serialization
- Shared API Types - API contracts and schemas
- Database Patterns - Soft delete, transactions