All skills
Skillintermediate

Routing and Controllers - Best Practices

Complete guide to clean route definitions and controller patterns.

Claude Code Knowledge Pack7/10/2026

Overview

Routing and Controllers - Best Practices

Complete guide to clean route definitions and controller patterns.

Table of Contents


Routes: Routing Only

The Golden Rule

Routes should ONLY:

  • ✅ Define route paths
  • ✅ Register middleware
  • ✅ Delegate to controllers

Routes should NEVER:

  • ❌ Contain business logic
  • ❌ Access database directly
  • ❌ Implement validation logic (use Zod + controller)
  • ❌ Format complex responses
  • ❌ Handle complex error scenarios

Clean Route Pattern

// routes/userRoutes.ts

const router = Router();
const controller = new UserController();

// ✅ CLEAN: Route definition only
router.get('/:id',
    SSOMiddlewareClient.verifyLoginStatus,
    auditMiddleware,
    async (req, res) => controller.getUser(req, res)
);

router.post('/',
    SSOMiddlewareClient.verifyLoginStatus,
    auditMiddleware,
    async (req, res) => controller.createUser(req, res)
);

router.put('/:id',
    SSOMiddlewareClient.verifyLoginStatus,
    auditMiddleware,
    async (req, res) => controller.updateUser(req, res)
);

Key Points:

  • Each route: method, path, middleware chain, controller delegation
  • No try-catch needed (controller handles errors)
  • Clean, readable, maintainable
  • Easy to see all endpoints at a glance

BaseController Pattern

Why BaseController?

Benefits:

  • Consistent error handling across all controllers
  • Automatic Sentry integration
  • Standardized response formats
  • Reusable helper methods
  • Performance tracking utilities
  • Logging and breadcrumb helpers

BaseController Pattern (Template)

File: /email/src/controllers/BaseController.ts


    /**
     * Handle errors with Sentry integration
     */
    protected handleError(
        error: unknown,
        res: Response,
        context: string,
        statusCode = 500
    ): void {
        Sentry.withScope((scope) => {
            scope.setTag('controller', this.constructor.name);
            scope.setTag('operation', context);
            scope.setUser({ id: res.locals?.claims?.userId });

            if (error instanceof Error) {
                scope.setContext('error_details', {
                    message: error.message,
                    stack: error.stack,
                });
            }

            Sentry.captureException(error);
        });

        res.status(statusCode).json({
            success: false,
            error: {
                message: error instanceof Error ? error.message : 'An error occurred',
                code: statusCode,
            },
        });
    }

    /**
     * Handle success responses
     */
    protected handleSuccess(
        res: Response,
        data: T,
        message?: string,
        statusCode = 200
    ): void {
        res.status(statusCode).json({
            success: true,
            message,
            data,
        });
    }

    /**
     * Performance tracking wrapper
     */
    protected async withTransaction(
        name: string,
        operation: string,
        callback: () => Promise
    ): Promise {
        return await Sentry.startSpan(
            { name, op: operation },
            callback
        );
    }

    /**
     * Validate required fields
     */
    protected validateRequest(
        required: string[],
        actual: Record<string, any>,
        res: Response
    ): boolean {
        const missing = required.filter((field) => !actual[field]);

        if (missing.length > 0) {
            Sentry.captureMessage(
                `Missing required fields: ${missing.join(', ')}`,
                'warning'
            );

            res.status(400).json({
                success: false,
                error: {
                    message: 'Missing required fields',
                    code: 'VALIDATION_ERROR',
                    details: { missing },
                },
            });
            return false;
        }
        return true;
    }

    /**
     * Logging helpers
     */
    protected logInfo(message: string, context?: Record<string, any>): void {
        Sentry.addBreadcrumb({
            category: this.constructor.name,
            message,
            level: 'info',
            data: context,
        });
    }

    protected logWarning(message: string, context?: Record<string, any>): void {
        Sentry.captureMessage(message, {
            level: 'warning',
            tags: { controller: this.constructor.name },
            extra: context,
        });
    }

    /**
     * Add Sentry breadcrumb
     */
    protected addBreadcrumb(
        message: string,
        category: string,
        data?: Record<string, any>
    ): void {
        Sentry.addBreadcrumb({ message, category, level: 'info', data });
    }

    /**
     * Capture custom metric
     */
    protected captureMetric(name: string, value: number, unit: string): void {
        Sentry.metrics.gauge(name, value, { unit });
    }
}

Using BaseController

// controllers/UserController.ts

    private userService: UserService;

    constructor() {
        super();
        this.userService = new UserService();
    }

    async getUser(req: Request, res: Response): Promise<void> {
        try {
            this.addBreadcrumb('Fetching user', 'user_controller', { userId: req.params.id });

            const user = await this.userService.findById(req.params.id);

            if (!user) {
                return this.handleError(
                    new Error('User not found'),
                    res,
                    'getUser',
                    404
                );
            }

            this.handleSuccess(res, user);
        } catch (error) {
            this.handleError(error, res, 'getUser');
        }
    }

    async createUser(req: Request, res: Response): Promise<void> {
        try {
            // Validate input
            const validated = createUserSchema.parse(req.body);

            // Track performance
            const user = await this.withTransaction(
                'user.create',
                'db.query',
                () => this.userService.create(validated)
            );

            this.handleSuccess(res, user, 'User created successfully', 201);
        } catch (error) {
            this.handleError(error, res, 'createUser');
        }
    }

    async updateUser(req: Request, res: Response): Promise<void> {
        try {
            const validated = updateUserSchema.parse(req.body);
            const user = await this.userService.update(req.params.id, validated);
            this.handleSuccess(res, user, 'User updated');
        } catch (error) {
            this.handleError(error, res, 'updateUser');
        }
    }
}

Benefits:

  • Consistent error handling
  • Automatic Sentry integration
  • Performance tracking
  • Clean, readable code
  • Easy to test

Good Examples

Example 1: Email Notification Routes (Excellent ✅)

File: /email/src/routes/notificationRoutes.ts


const router = Router();
const controller = new NotificationController();

// ✅ EXCELLENT: Clean delegation
router.get('/',
    SSOMiddlewareClient.verifyLoginStatus,
    async (req, res) => controller.getNotifications(req, res)
);

router.post('/',
    SSOMiddlewareClient.verifyLoginStatus,
    async (req, res) => controller.createNotification(req, res)
);

router.put('/:id/read',
    SSOMiddlewareClient.verifyLoginStatus,
    async (req, res) => controller.markAsRead(req, res)
);

What Makes This Excellent:

  • Zero business logic in routes
  • Clear middleware chain
  • Consistent pattern
  • Easy to understand

Example 2: Proxy Routes with Validation (Good ✅)

File: /form/src/routes/proxyRoutes.ts


const createProxySchema = z.object({
    originalUserID: z.string().min(1),
    proxyUserID: z.string().min(1),
    startsAt: z.string().datetime(),
    expiresAt: z.string().datetime(),
});

router.post('/',
    SSOMiddlewareClient.verifyLoginStatus,
    async (req, res) => {
        try {
            const validated = createProxySchema.parse(req.body);
            const proxy = await proxyService.createProxyRelationship(validated);
            res.status(201).json({ success: true, data: proxy });
        } catch (error) {
            handler.handleException(res, error);
        }
    }
);

What Makes This Good:

  • Zod validation
  • Delegates to service
  • Proper HTTP status codes
  • Error handling

Could Be Better:

  • Move validation to controller
  • Use BaseController

Anti-Patterns

Anti-Pattern 1: Business Logic in Routes (Bad ❌)

File: /form/src/routes/responseRoutes.ts (actual production code)

// ❌ ANTI-PATTERN: 200+ lines of business logic in route
router.post('/:formID/submit', async (req: Request, res: Response) => {
    try {
        const username = res.locals.claims.preferred_username;
        const responses = req.body.responses;
        const stepInstanceId = req.body.stepInstanceId;

        // ❌ Permission checking in route
        const userId = await userProfileService.getProfileByEmail(username).then(p => p.id);
        const canComplete = await permissionService.canCompleteStep(userId, stepInstanceId);
        if (!canComplete) {
            return res.status(403).json({ error: 'No permission' });
        }

        // ❌ Workflow logic in route
        const { createWorkflowEngine, CompleteStepCommand } = require('../workflow/core/WorkflowEngineV3');
        const engine = await createWorkflowEngine();
        const command = new CompleteStepCommand(
            stepInstanceId,
            userId,
            responses,
            additionalContext
        );
        const events = await engine.executeCommand(command);

        // ❌ Impersonation handling in route
        if (res.locals.isImpersonating) {
            impersonationContextStore.storeContext(stepInstanceId, {
                originalUserId: res.locals.originalUserId,
                effectiveUserId: userId,
            });
        }

        // ❌ Response processing in route
        const post = await PrismaService.main.post.findUnique({
            where: { id: postData.id },
            include: { comments: true },
        });

        // ❌ Permission check in route
        await checkPostPermissions(post, userId);

        // ... 100+ more lines of business logic

        res.json({ success: true, data: result });
    } catch (e) {
        handler.handleException(res, e);
    }
});

Why This Is Terrible:

  • 200+ lines of business logic
  • Hard to test (requires HTTP mocking)
  • Hard to reuse (tied to route)
  • Mixed responsibilities
  • Difficult to debug
  • Performance tracking difficult

How to Refactor (Step-by-Step)

Step 1: Create Controller

// controllers/PostController.ts

    private postService: PostService;

    constructor() {
        super();
        this.postService = new PostService();
    }

    async createPost(req: Request, res: Response): Promise<void> {
        try {
            const validated = createPostSchema.parse({
                ...req.body,
            });

            const result = await this.postService.createPost(
                validated,
                res.locals.userId
            );

            this.handleSuccess(res, result, 'Post created successfully');
        } catch (error) {
            this.handleError(error, res, 'createPost');
        }
    }
}

Step 2: Create Service

// services/postService.ts

    async createPost(
        data: CreatePostDTO,
        userId: string
    ): Promise {
        // Permission check
        const canCreate = await permissionService.canCreatePost(userId);
        if (!canCreate) {
            throw new ForbiddenError('No permission to create post');
        }

        // Execute workflow
        const engine = await createWorkflowEngine();
        const command = new CompleteStepCommand(/* ... */);
        const events = await engine.executeCommand(command);

        // Handle impersonation if needed
        if (context.isImpersonating) {
            await this.handleImpersonation(data.stepInstanceId, context);
        }

        // Synchronize roles
        await this.synchronizeRoles(events, userId);

        return { events, success: true };
    }

    private async handleImpersonation(stepInstanceId: number, context: any) {
        impersonationContextStore.storeContext(stepInstanceId, {
            originalUserId: context.originalUserId,
            effectiveUserId: context.effectiveUserId,
        });
    }

    private async synchronizeRoles(events: WorkflowEvent[], userId: string) {
        // Role synchronization logic
    }
}

Step 3: Update Route

// routes/postRoutes.ts

const router = Router();
const controller = new PostController();

// ✅ CLEAN: Just routing
router.post('/',
    SSOMiddlewareClient.verifyLoginStatus,
    auditMiddleware,
    async (req, res) => controller.createPost(req, res)
);

Result:

  • Route: 8 lines (was 200+)
  • Controller: 25 lines (request handling)
  • Service: 50 lines (business logic)
  • Testable, reusable, maintainable!

Error Handling

Controller Error Handling

async createUser(req: Request, res: Response): Promise<void> {
    try {
        const result = await this.userService.create(req.body);
        this.handleSuccess(res, result, 'User created', 201);
    } catch (error) {
        // BaseController.handleError automatically:
        // - Captures to Sentry with context
        // - Sets appropriate status code
        // - Returns formatted error response
        this