- Architecture
- Directory Structure
- Controller Types
- Route Registration
- Authentication Patterns
- Transaction Management
- Validation and Error Handling
- TSOA Integration
- Implementation Guidelines
Controllers follow clean architecture principles as the interface layer, handling HTTP concerns while delegating business logic to the domain layer. Each controller represents a single API endpoint and coordinates between:
- HTTP Layer: Request/response handling, validation, authentication
- Domain Layer: Business logic through DAO interfaces
- DTO Layer: Request/response contracts with validation
Controllers remain database-agnostic by only depending on domain interfaces, never on specific implementations.
Controllers mirror business domains with action-based organization:
src/controllers/
├── routes.ts -> Main router configuration and registration
├── CustomController.ts -> Base controller classes
├── {domain}/
│ ├── routes.ts -> Route registration module
│ ├── {action}/
│ │ └── {Domain}{Action}{Method}Controller.ts
│ └── {subdomain}/
│ └── {action}/
│ └── {Domain}{Subdomain}{Action}{Method}Controller.ts
Examples:
auth/login/AuthLoginPostController.ts→POST /auth/loginproject/create/ProjectCreatePostController.ts→POST /projectauth/token/refresh/AuthTokenRefreshPostController.ts→POST /auth/token/refresh
All controllers must extend one of these abstract base classes located in CustomController.ts:
For simple operations that don't require database transactions:
export class HealthGetController extends BaseCustomController {
constructor(envVars: EnvVars, containerDAO: ContainerDAO<unknown>) {
super(envVars, containerDAO);
}
// Read-only operations, simple business logic
}Use cases:
- Health checks and status endpoints
- Read-only data retrieval
- Simple operations without database writes
- Authentication validation (token verification)
For operations requiring database transactions:
export class ProjectCreatePostController extends TransactionAbstractController {
constructor(
envVars: EnvVars,
containerDAO: ContainerDAO<unknown>,
session: DatabaseSession<unknown>
) {
super(envVars, containerDAO, session);
}
// Write operations, complex business logic requiring atomicity
}Use cases:
- Create, update, delete operations
- Multi-step database operations requiring atomicity
- Operations that must maintain data consistency
- Complex business logic involving multiple DAOs
Both base classes enforce dependency injection through constructor parameters:
envVars: Application configuration and environment variablescontainerDAO: Access to all DAO instances for data layer operationssession(TransactionAbstractController only): Database session for transaction management
This pattern enables:
- Testability: Easy mocking of dependencies in unit tests
- TSOA Compatibility: Method signatures remain clean for OpenAPI generation
- Separation of Concerns: Infrastructure dependencies isolated from business logic
All controllers use TSOA decorators for automatic OpenAPI documentation:
@Route('project') // Base route
@Tags('Project') // OpenAPI grouping
export class ProjectCreatePostController extends TransactionAbstractController {
@Post() // HTTP method + sub-route
@Security('Bearer') // Authentication requirement
public async createProject(@Body() request: CreateProjectRequest): Promise<ProjectResponse> {
// Implementation
}
}The main routes.ts file in the controllers directory serves as the central router configuration that orchestrates all domain routes.
This centralized approach provides:
- Dependency Injection: All dependencies are passed to domain route modules
- Modularity: Clean separation of domain-specific route logic
- Consistency: Uniform registration pattern across all domains
Each domain has its own routes.ts module that exports a register{Domain}Routes function:
export function registerProjectRoutes(
router: Router,
envVars: EnvVars,
containerDAO: ContainerDAO<unknown>,
databaseSessionProducer: DatabaseSessionProducer<unknown>,
timestampProducer: TimestampProducer
): void {
// Register all domain routes
}Read Operations (no transaction):
router.get('/endpoint',
authMiddleware(envVars), // Authentication
asyncHandler(async (req: Request, res: Response) => {
res.json(await new Controller().method());
})
);Write Operations (with transaction):
router.post('/endpoint',
authMiddleware(envVars), // Authentication
validateRequestBody(RequestDTO), // Validation
dbTransactionHandler(
databaseSessionProducer,
async (session, req) => {
const data = await new Controller(session).method(req.body);
return { statusCode: 201, data };
}
)
);- Health checks: No authentication required
- User registration/login: No authentication (creates authentication)
- Project management: Requires Bearer token authentication
- Token refresh: Uses refresh token in request body (not Bearer header)
// Route level
router.get('/protected', authMiddleware(envVars), /* handler */);
// Controller level (TSOA)
@Security('Bearer')
public async protectedMethod(): Promise<Response> {
const user = getAuthenticatedUser(req); // Extract authenticated user
// Business logic with user context
}Require Transactions (dbTransactionHandler):
- Create operations (user registration, project creation)
- Update operations (project updates)
- Delete operations (project deletion)
- Token operations (login, logout, refresh)
No Transactions (asyncHandler):
- Read operations (get project, list projects, health check)
- Operations that don't modify data
CRITICAL: Each API call that requires transactions creates a new database transaction to ensure proper isolation and consistency.
The transaction lifecycle is automatically managed by the dbTransactionHandler:
router.post('/endpoint',
middlewares,
dbTransactionHandler(
databaseSessionProducer, // Injected database-specific session producer
async (session, req) => {
// Fresh transaction session created for this API call
const controller = new WriteController(envVars, containerDAO, session);
return await controller.method(req.body);
}
)
);Controllers receive the session and pass it to DAO operations:
const project = await this.containerDAO.projectDAO.createProject(
this.session, // Database session for this specific transaction
name,
gitUrl,
userId,
timestamp
);The DatabaseSessionProducer provides database-agnostic session management through dependency injection. This pattern enables:
- Transaction Isolation: Each API call operates in its own transaction context
- Database Technology Independence: Session handling works across different DBMS implementations
- Testability: Sessions can be easily mocked in unit tests
For detailed information about session management, the database-agnostic design, and the dependency injection pattern, see the Domain Layer documentation.
- Route Level:
validateRequestBody(DTOClass)middleware - Path Parameters:
validateRequestParams(DTOClass)middleware - DTO Level:
class-validatordecorators in DTO classes
- Business Errors: Throw
AppError(message, statusCode) - Database Errors: Automatically handled by transaction system
- Validation Errors: Automatically handled by validation middleware
// Business logic errors
if (!project) {
throw new AppError('Project not found', 404);
}
// Access control
if (project.userId !== user.userId) {
throw new AppError('Access denied', 403);
}Controllers automatically generate OpenAPI documentation via TSOA:
- @Route('path'): Base route path
- @Tags('Group'): Swagger UI grouping
- @Security('Bearer'): Authentication requirements
- @Get/@Post/@Put/@Delete: HTTP methods
- @Body/@Path/@Query: Parameter binding
The @Security('Bearer') decorator integrates with tsoa.json configuration to show Bearer token requirements in Swagger UI.
When working with APIs that operate on the same business domain, common patterns and operations often emerge across multiple controllers. To maintain code quality and follow DRY principles, create shared utility classes within each domain:
Create utility classes like {Domain}Utils.ts within domain directories to centralize repeated logic.
Frequent operations that benefit from shared utilities:
- Entity Verification: Finding entities and validating existence
- Access Control: Ownership verification and permission checks
- DTO Conversion: Entity-to-response transformations
- Validation Logic: Complex business rule validations
- Error Handling: Domain-specific error creation patterns
- DRY Principle: Eliminates code duplication across controllers
- Consistency: Uniform error messages and response formats
- Maintainability: Single place to update common logic
- Testability: Utilities can be unit tested independently
- Readability: Controllers focus on their specific responsibilities
Controllers should use these utilities to simplify their implementation.
This approach keeps controllers lean, focused, and maintainable while ensuring consistent behavior across the API.
- HTTP Concerns: Request/response handling, status codes
- Authentication: Extract and validate user context
- Validation: Ensure input compliance via DTOs
- Orchestration: Coordinate domain operations
- Error Translation: Convert domain errors to HTTP responses
- Appropriate Base Class: Extend
BaseCustomControllerfor read operations,TransactionAbstractControllerfor write operations - Dependency Injection: Inject all dependencies through constructor (never in method parameters)
- Database Agnostic: Only use domain interfaces, never implementations
- Stateless: Controllers should not maintain state between requests
- Error Handling: Use AppError for consistent error responses
- Transaction Boundaries: Use appropriate base controller for operation type
- Authentication Context: Always validate user access for protected resources
- TSOA Documentation: Properly annotate all endpoints for API documentation
- Domain Layer: Business logic via DAO interfaces
- DTO Layer: Request/response contracts with validation
- Middleware: Authentication, validation, error handling, transactions
- Routes: Modular registration with proper middleware composition