Node / Express Guide

Controllers

The factory-function controller pattern for Express route handlers.

Controllers handle HTTP request/response. They validate inputs (Joi), delegate work to a service, and return JSON. They never contain business logic.

TL;DR — Factory function per domain, validate with Joi { convert: true }, delegate to service, catch errors → next(error). Never query DB or build responses for failures.

Pattern

All controllers are factory functions — no classes:

// src/controllers/auth.controller.ts
import { NextFunction, Request, Response } from "express";
import {
  AuthenticatedRequest,
  NotFoundError,
  UnauthorizedError,
  UnprocessableEntityError,
} from "@codisolutions23/node-utils";
import { log } from "../utils/log.util";
import { useAuthService } from "../services/auth.service";
import { loginSchema } from "../validations/auth.validation";
import { commonSchemas } from "../validations/local.validation";
import { TenantRequest } from "../middleware/tenant.middleware";

export function useAuthController() {
  const {
    login: _login,
    refreshToken: _refreshToken,
    getCurrentUser: _getCurrentUser,
    logout: _logout,
  } = useAuthService();

  const resource = "auth.controller";

  async function login(req: TenantRequest, res: Response, next: NextFunction) {
    const method = "login";
    try {
      const validationData = {
        ...req.body,
        organizationId: req.organizationId,
      };
      const { error, value } = loginSchema.validate(validationData, {
        convert: true,
      });
      if (error) {
        log("error", resource, method, "Validation error", {
          error: error.message,
        });
        return next(new UnprocessableEntityError(error.message));
      }

      const session = await _login(value);
      res.json(session);
    } catch (error: any) {
      log("error", resource, method, "Login failed.", {
        error: error.message,
        requestBody: req.body,
        requestOrganizationId: req.organizationId,
      });
      return next(error);
    }
  }

  async function getCurrentUser(
    req: AuthenticatedRequest,
    res: Response,
    next: NextFunction,
  ) {
    const method = "getCurrentUser";
    const user = req.user as { id?: string };
    try {
      const userId = user?.id;
      if (!userId) {
        return next(
          new UnauthorizedError(
            "Invalid user session. Please log in to continue.",
          ),
        );
      }

      const result = await _getCurrentUser(userId);
      res.json(result);
    } catch (error: any) {
      log("error", resource, method, "Failed to get user.", {
        error: error.message,
      });
      return next(error);
    }
  }

  return { login, getCurrentUser };
}

Rules

  1. Validate first using Joi with { convert: true }. Return next(new UnprocessableEntityError(...)) on failure — never throw directly.
  2. Delegate everything to a service. Controllers must not query the DB or contain conditional business logic.
  3. return next(error) on catch — not res.status(500).json(...). The global errorHandler middleware handles the response.
  4. Log every caught error with log('error', resource, method, message, meta).
  5. resource constant at the top of every controller — 'auth.controller', 'user.controller', etc.

Request Types

TypeSourceWhen to use
RequestExpressUnauthenticated routes
AuthenticatedRequest@codisolutions23/node-utilsRoutes behind authenticate() — adds .user (with .id, .role, .jti) and .token
TenantRequestsrc/middleware/tenant.middleware.tsRoutes that resolve a tenant — adds .organizationId and .slug
AuthenticatedRequest uses .user.id (not ._id) and .user.role (not .type). These match the JWT payload shape set by the auth middleware in codi-node-utils.
src/types/common.types.ts contains MongoQuery<T> and PaginatedResult<T>not request types. Request types come from the sources listed above.

Resource Controller Example

// src/controllers/resource.controller.ts
import { NextFunction, Request, Response } from "express";
import {
  createResourceSchema,
  updateResourceSchema,
} from "../validations/resource.validation";
import { useResourceService } from "../services/resource.service";
import {
  AuthenticatedRequest,
  UnprocessableEntityError,
} from "@codisolutions23/node-utils";
import { log } from "../utils/log.util";

export function useResourceController() {
  const service = useResourceService();
  const resource = "resource.controller";

  async function getAll(
    req: AuthenticatedRequest,
    res: Response,
    next: NextFunction,
  ) {
    const method = "getAll";
    try {
      const user = req.user as { id: string; organizationId?: string };
      const { page = 1, limit = 10 } = req.query;
      const result = await service.getAll({
        organizationId: user.organizationId!,
        page: Number(page),
        limit: Number(limit),
      });
      res.json(result);
    } catch (error: any) {
      log("error", resource, method, "Failed to fetch resources.", {
        error: error.message,
      });
      return next(error);
    }
  }

  async function create(
    req: AuthenticatedRequest,
    res: Response,
    next: NextFunction,
  ) {
    const method = "create";
    try {
      const { error, value } = createResourceSchema.validate(req.body, {
        convert: true,
      });
      if (error) return next(new UnprocessableEntityError(error.message));

      const user = req.user as { organizationId?: string };
      const result = await service.create({
        ...value,
        organizationId: user.organizationId,
      });
      res.status(201).json(result);
    } catch (error: any) {
      log("error", resource, method, "Failed to create resource.", {
        error: error.message,
      });
      return next(error);
    }
  }

  async function update(
    req: AuthenticatedRequest,
    res: Response,
    next: NextFunction,
  ) {
    const method = "update";
    try {
      const { error, value } = updateResourceSchema.validate(req.body, {
        convert: true,
      });
      if (error) return next(new UnprocessableEntityError(error.message));

      const result = await service.update(req.params.id, value);
      res.json(result);
    } catch (error: any) {
      log("error", resource, method, "Failed to update resource.", {
        error: error.message,
      });
      return next(error);
    }
  }

  async function remove(req: Request, res: Response, next: NextFunction) {
    const method = "remove";
    try {
      await service.remove(req.params.id);
      res.json({ message: "Deleted successfully." });
    } catch (error: any) {
      log("error", resource, method, "Failed to delete resource.", {
        error: error.message,
      });
      return next(error);
    }
  }

  return { getAll, create, update, remove };
}