Skip to main content

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/
├── auth.controller.ts # Authentication
├── user.controller.ts # User management
├── book.controller.ts # Book catalog
├── wishlist.controller.ts # Wishlist management
├── store.controller.ts # Store management
├── inventory.controller.ts # Store inventory
├── order.controller.ts # Order management
├── checkout.controller.ts # Checkout flow
├── cart.controller.ts # Shopping cart
├── pos.controller.ts # Point of sale
├── line.controller.ts # Social lines
├── review.controller.ts # Book reviews
├── follow.controller.ts # Social following
├── feed.controller.ts # Activity feed
├── club.controller.ts # Book clubs
├── challenge.controller.ts # Reading challenges
└── ...

Request Context

Controllers have access to request context via Express Request object:

Authenticated User

req.user: {
id: string;
isGuest: boolean;
tier: UserTier;
} | undefined

Set by authenticate or optionalAuth middleware.

Store Access

req.store: {
id: string;
role: string;
permissions?: Record<string, any>;
} | undefined

Set by requireStoreAccess or requireStoreOwnership middleware.

Request Parameters

req.params.id      // Route params (/resources/:id)
req.query.cursor // Query string (?cursor=abc)
req.body.name // Request body (JSON)
req.headers // HTTP headers
req.file // Uploaded file (multer)

Best Practices

  1. Keep controllers thin - Delegate business logic to services
  2. Validate all input - Use Zod schemas for request validation
  3. Handle errors consistently - Return standard error responses
  4. Log errors - Use the logger for debugging
  5. Return early - Use guard clauses and early returns
  6. Use TypeScript - Leverage type safety
  7. Don't catch and ignore - Always handle or propagate errors
  8. Use status codes correctly - Follow HTTP standards
  9. Document complex logic - Add comments for non-obvious code
  10. Test controllers - Write unit tests for controller methods

Testing Controllers

import { Request, Response } from 'express';
import { someController } from './some.controller';
import { someService } from '../services/some.service';

jest.mock('../services/some.service');

describe('SomeController', () => {
let mockReq: Partial<Request>;
let mockRes: Partial<Response>;

beforeEach(() => {
mockReq = {
params: {},
query: {},
body: {},
user: { id: 'user-123', isGuest: false, tier: 'free' },
};
mockRes = {
json: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
send: jest.fn(),
};
});

it('should return resource by id', async () => {
mockReq.params = { id: '123' };
const mockData = { id: '123', name: 'Test' };

(someService.getById as jest.Mock).mockResolvedValue(mockData);

await someController.getById(mockReq as Request, mockRes as Response);

expect(mockRes.json).toHaveBeenCalledWith({ result: mockData });
});

it('should return 404 when resource not found', async () => {
mockReq.params = { id: '123' };

(someService.getById as jest.Mock).mockResolvedValue(null);

await someController.getById(mockReq as Request, mockRes as Response);

expect(mockRes.status).toHaveBeenCalledWith(404);
expect(mockRes.json).toHaveBeenCalledWith({
error: 'NotFound',
message: 'Resource not found',
});
});
});