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.

Pattern

All controllers are factory functions — no classes:

// src/controllers/auth.controller.ts
import { Request, Response, NextFunction } from 'express'
import { loginSchema } from '../validations/auth.validation'
import { useAuthService } from '../services/auth.service'
import { UnprocessableEntityError } from '@codisolutions23/node-utils'
import { log } from '../utils/log.util'
import type { TenantRequest, AuthenticatedRequest } from '../types/common.types'

export function useAuthController() {
  const { login: _login, logout: _logout, getCurrentUser: _getCurrentUser } = useAuthService()
  const resource = 'auth.controller'

  async function login(req: TenantRequest, res: Response, next: NextFunction) {
    const method = 'login'
    try {
      const { error, value } = loginSchema.validate(req.body, { abortEarly: false })
      if (error) 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 })
      return next(error)
    }
  }

  async function getCurrentUser(req: AuthenticatedRequest, res: Response, next: NextFunction) {
    const method = 'getCurrentUser'
    try {
      const user = await _getCurrentUser(req.user!._id.toString())
      res.json(user)
    } catch (error: any) {
      log('error', resource, method, 'Failed to get user.', { error: error.message })
      return next(error)
    }
  }

  async function logout(req: AuthenticatedRequest, res: Response, next: NextFunction) {
    const method = 'logout'
    try {
      await _logout(req.user!.jti)
      res.json({ message: 'Logged out successfully.' })
    } catch (error: any) {
      log('error', resource, method, 'Logout failed.', { error: error.message })
      return next(error)
    }
  }

  return { login, getCurrentUser, logout }
}

Rules

  1. Validate first using Joi. 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
AuthenticatedRequestsrc/types/common.types.tsRoutes behind authenticate() — adds .user and .token
TenantRequestsrc/types/common.types.tsRoutes that resolve a tenant — adds .organizationId
// src/types/common.types.ts
import type { Request } from 'express'

export interface AuthenticatedRequest extends Request {
  user?:  { _id: string; type: string; jti: string; organizationId?: string }
  token?: string
}

export interface TenantRequest extends Request {
  organizationId?: string
}

Resource Controller Example

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

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

  async function getAll(req: AuthenticatedRequest, res: Response, next: NextFunction) {
    const method = 'getAll'
    try {
      const { page = 1, limit = 10 } = req.query
      const result = await service.getAll({
        organizationId: req.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, { abortEarly: false })
      if (error) return next(new UnprocessableEntityError(error.message))

      const result = await service.create({ ...value, organizationId: req.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, { abortEarly: false })
      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 }
}