Controllers
Overview
Controllers are responsible for handling HTTP requests and responses. They validate input, call service layer methods, and format responses. Controllers should NOT contain business logic - that belongs in the service layer.
Controller Pattern
Standard Controller Structure
import { Request, Response } from 'express';
import { z } from 'zod';
import { someService } from '../services/some.service';
import { logger } from '../lib/logger';
// Define validation schemas at the top
const createSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().optional(),
});
const updateSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().optional(),
});
export class SomeController {
async getById(req: Request, res: Response) {
try {
const { id } = req.params;
const result = await someService.getById(id);
if (!result) {
return res.status(404).json({
error: 'NotFound',
message: 'Resource not found',
});
}
return res.json({ result });
} catch (error) {
logger.error('controller.get_failed', { error: String(error) });
return res.status(500).json({
error: 'InternalServerError',
message: 'Failed to get resource',
});
}
}
async create(req: Request, res: Response) {
try {
if (!req.user) {
return res.status(401).json({
error: 'Unauthorized',
message: 'Authentication required',
});
}
// Validate request body
const data = createSchema.parse(req.body);
// Call service layer
const result = await someService.create(req.user.id, data);
return res.status(201).json({ result });
} catch (error: any) {
// Handle validation errors
if (error instanceof z.ZodError) {
return res.status(400).json({
error: 'ValidationError',
details: error.errors,
});
}
// Handle service layer errors
if (error.status === 403) {
return res.status(403).json({
error: error.error,
message: error.message,
});
}
logger.error('controller.create_failed', { error: String(error) });
return res.status(500).json({
error: 'InternalServerError',
message: 'Failed to create resource',
});
}
}
async update(req: Request, res: Response) {
try {
if (!req.user) {
return res.status(401).json({
error: 'Unauthorized',
message: 'Authentication required',
});
}
const { id } = req.params;
const data = updateSchema.parse(req.body);
const result = await someService.update(id, req.user.id, data);
return res.json({ result });
} catch (error: any) {
if (error instanceof z.ZodError) {
return res.status(400).json({
error: 'ValidationError',
details: error.errors,
});
}
if (error.status === 404) {
return res.status(404).json({
error: error.error,
message: error.message,
});
}
logger.error('controller.update_failed', { error: String(error) });
return res.status(500).json({
error: 'InternalServerError',
message: 'Failed to update resource',
});
}
}
async delete(req: Request, res: Response) {
try {
if (!req.user) {
return res.status(401).json({
error: 'Unauthorized',
message: 'Authentication required',
});
}
const { id } = req.params;
await someService.delete(id, req.user.id);
return res.status(204).send();
} catch (error: any) {
if (error.status === 404) {
return res.status(404).json({
error: error.error,
message: error.message,
});
}
logger.error('controller.delete_failed', { error: String(error) });
return res.status(500).json({
error: 'InternalServerError',
message: 'Failed to delete resource',
});
}
}
}
// Export singleton instance
export const someController = new SomeController();
Controller Conventions
1. Authentication Checks
Controllers should check req.user when authentication is required:
async handler(req: Request, res: Response) {
if (!req.user) {
return res.status(401).json({
error: 'Unauthorized',
message: 'Authentication required',
});
}
// Proceed with authenticated user
const userId = req.user.id;
// ...
}
For optional authentication, check if req.user exists:
async handler(req: Request, res: Response) {
const userId = req.user?.id || null;
// ...
}
2. Input Validation with Zod
Use Zod schemas for request validation:
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
displayName: z.string().min(1).max(100),
});
async handler(req: Request, res: Response) {
try {
const data = schema.parse(req.body);
// Use validated data
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
error: 'ValidationError',
details: error.errors,
});
}
}
}
3. Query Parameter Parsing
Parse and validate query parameters:
async list(req: Request, res: Response) {
const cursor = req.query.cursor as string | undefined;
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 20;
// Validate limit range
if (limit < 1 || limit > 100) {
return res.status(400).json({
error: 'BadRequest',
message: 'Limit must be between 1 and 100',
});
}
const result = await someService.list({ cursor, limit });
return res.json(result);
}
4. Error Handling
Controllers should catch and handle errors appropriately:
try {
// Service call
} catch (error: any) {
// Handle known error types from service layer
if (error.status === 404) {
return res.status(404).json({
error: error.error || 'NotFound',
message: error.message,
});
}
if (error.status === 403) {
return res.status(403).json({
error: error.error || 'Forbidden',
message: error.message,
});
}
// Log unexpected errors
logger.error('controller.operation_failed', { error: String(error) });
// Return generic error
return res.status(500).json({
error: 'InternalServerError',
message: 'Operation failed',
});
}
5. Response Formatting
Success Response (200)
return res.json({ data: result });
Created (201)
return res.status(201).json({ data: result });
No Content (204)
return res.status(204).send();
Error Response
return res.status(400).json({
error: 'ErrorCode',
message: 'Human-readable message',
details: {}, // Optional additional details
});
6. Pagination Responses
Return consistent pagination format:
return res.json({
data: items,
nextCursor: 'abc123',
hasMore: true,
});
Common Controller Patterns
Ownership Verification
async update(req: Request, res: Response) {
if (!req.user) {
return res.status(401).json({
error: 'Unauthorized',
message: 'Authentication required',
});
}
const { id } = req.params;
const data = updateSchema.parse(req.body);
// Service layer checks ownership
const result = await someService.update(id, req.user.id, data);
return res.json({ result });
}
Optional Parameters
async search(req: Request, res: Response) {
const query = req.query.q as string | undefined;
const type = req.query.type as string | undefined;
const cursor = req.query.cursor as string | undefined;
if (!query) {
return res.status(400).json({
error: 'BadRequest',
message: 'Query parameter is required',
});
}
const results = await searchService.search({ query, type, cursor });
return res.json(results);
}
File Uploads
import multer from 'multer';
const upload = multer({ storage: multer.memoryStorage() });
async uploadImage(req: Request, res: Response) {
try {
if (!req.file) {
return res.status(400).json({
error: 'BadRequest',
message: 'File is required',
});
}
const imageUrl = await storageService.uploadImage(req.file);
return res.json({ imageUrl });
} catch (error) {
logger.error('image_upload_failed', { error: String(error) });
return res.status(500).json({
error: 'InternalServerError',
message: 'Failed to upload image',
});
}
}
Controller Organization
Controllers are organized by domain and correspond to route modules:
src/controllers/