CtrlK
BlogDocsLog inGet started
Tessl Logo

jbvc/backend-dev-guidelines

Opinionated backend development standards for Node.js + Express + TypeScript microservices. Covers layered architecture, BaseController pattern, dependency injection, Prisma repositories, Zod validation, unifiedConfig, Sentry error tracking, async safety, and testing discipline.

71

Quality

71%

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

routing-and-controllers.mdresources/

Routing and Controllers - Best Practices

Complete guide to clean route definitions and controller patterns.

Table of Contents

  • Routes: Routing Only
  • BaseController Pattern
  • Good Examples
  • Anti-Patterns
  • Refactoring Guide
  • Error Handling
  • HTTP Status Codes

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
import { Router } from 'express';
import { UserController } from '../controllers/UserController';
import { SSOMiddlewareClient } from '../middleware/SSOMiddleware';
import { auditMiddleware } from '../middleware/auditMiddleware';

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)
);

export default router;

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

import * as Sentry from '@sentry/node';
import { Response } from 'express';

export abstract class BaseController {
    /**
     * 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<T>(
        res: Response,
        data: T,
        message?: string,
        statusCode = 200
    ): void {
        res.status(statusCode).json({
            success: true,
            message,
            data,
        });
    }

    /**
     * Performance tracking wrapper
     */
    protected async withTransaction<T>(
        name: string,
        operation: string,
        callback: () => Promise<T>
    ): Promise<T> {
        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
import { Request, Response } from 'express';
import { BaseController } from './BaseController';
import { UserService } from '../services/userService';
import { createUserSchema } from '../validators/userSchemas';

export class UserController extends BaseController {
    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

import { Router } from 'express';
import { NotificationController } from '../controllers/NotificationController';
import { SSOMiddlewareClient } from '../middleware/SSOMiddleware';

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)
);

export default router;

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

import { z } from 'zod';

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
export class PostController extends BaseController {
    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
export class PostService {
    async createPost(
        data: CreatePostDTO,
        userId: string
    ): Promise<PostResult> {
        // 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
import { PostController } from '../controllers/PostController';

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.handleError(error, res, 'createUser');
    }
}

Custom Error Status Codes

async getUser(req: Request, res: Response): Promise<void> {
    try {
        const user = await this.userService.findById(req.params.id);

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

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

Validation Errors

async createUser(req: Request, res: Response): Promise<void> {
    try {
        const validated = createUserSchema.parse(req.body);
        const user = await this.userService.create(validated);
        this.handleSuccess(res, user, 'User created', 201);
    } catch (error) {
        // Zod errors get 400 status
        if (error instanceof z.ZodError) {
            return this.handleError(error, res, 'createUser', 400);
        }
        this.handleError(error, res, 'createUser');
    }
}

HTTP Status Codes

Standard Codes

CodeUse CaseExample
200Success (GET, PUT)User retrieved, Updated
201Created (POST)User created
204No Content (DELETE)User deleted
400Bad RequestInvalid input data
401UnauthorizedNot authenticated
403ForbiddenNo permission
404Not FoundResource doesn't exist
409ConflictDuplicate resource
422Unprocessable EntityValidation failed
500Internal Server ErrorUnexpected error

Usage Examples

// 200 - Success (default)
this.handleSuccess(res, user);

// 201 - Created
this.handleSuccess(res, user, 'Created', 201);

// 400 - Bad Request
this.handleError(error, res, 'operation', 400);

// 404 - Not Found
this.handleError(new Error('Not found'), res, 'operation', 404);

// 403 - Forbidden
this.handleError(new ForbiddenError('No permission'), res, 'operation', 403);

Refactoring Guide

Identify Routes Needing Refactoring

Red Flags:

  • Route file > 100 lines
  • Multiple try-catch blocks in one route
  • Direct database access (Prisma calls)
  • Complex business logic (if statements, loops)
  • Permission checks in routes

Check your routes:

# Find large route files
wc -l form/src/routes/*.ts | sort -n

# Find routes with Prisma usage
grep -r "PrismaService" form/src/routes/

Refactoring Process

1. Extract to Controller:

// Before: Route with logic
router.post('/action', async (req, res) => {
    try {
        // 50 lines of logic
    } catch (e) {
        handler.handleException(res, e);
    }
});

// After: Clean route
router.post('/action', (req, res) => controller.performAction(req, res));

// New controller method
async performAction(req: Request, res: Response): Promise<void> {
    try {
        const result = await this.service.performAction(req.body);
        this.handleSuccess(res, result);
    } catch (error) {
        this.handleError(error, res, 'performAction');
    }
}

2. Extract to Service:

// Controller stays thin
async performAction(req: Request, res: Response): Promise<void> {
    try {
        const validated = actionSchema.parse(req.body);
        const result = await this.actionService.execute(validated);
        this.handleSuccess(res, result);
    } catch (error) {
        this.handleError(error, res, 'performAction');
    }
}

// Service contains business logic
export class ActionService {
    async execute(data: ActionDTO): Promise<Result> {
        // All business logic here
        // Permission checks
        // Database operations
        // Complex transformations
        return result;
    }
}

3. Add Repository (if needed):

// Service calls repository
export class ActionService {
    constructor(private actionRepository: ActionRepository) {}

    async execute(data: ActionDTO): Promise<Result> {
        // Business logic
        const entity = await this.actionRepository.findById(data.id);
        // More logic
        return await this.actionRepository.update(data.id, changes);
    }
}

// Repository handles data access
export class ActionRepository {
    async findById(id: number): Promise<Entity | null> {
        return PrismaService.main.entity.findUnique({ where: { id } });
    }

    async update(id: number, data: Partial<Entity>): Promise<Entity> {
        return PrismaService.main.entity.update({ where: { id }, data });
    }
}

Related Files:

  • SKILL.md - Main guide
  • services-and-repositories.md - Service layer details
  • complete-examples.md - Full refactoring examples

resources

architecture-overview.md

async-and-errors.md

complete-examples.md

configuration.md

database-patterns.md

middleware-guide.md

routing-and-controllers.md

sentry-and-monitoring.md

services-and-repositories.md

testing-guide.md

validation-patterns.md

SKILL.md

tile.json