Controllers

This document outlines the controller patterns and architecture used across the API.

Overview

The API follows a consistent controller pattern across all features, with 22 controllers organized by feature domains:

Core Pattern

All controllers follow this consistent structure:

controller-pattern.ts

import { Request, Response } from "express"; import { asyncHandler } from "@/lib/express/express.async-handler"; import { sendSuccessResponse, sendErrorResponse } from "@/lib/express/express.response"; import { HttpStatus } from "@/lib/http/http.status"; import { SerializedEntity } from "../feature.config"; import { operationFunction } from "../operations/feature.operation";

export const controllerName = asyncHandler( async (req: Request, res: Response) => { // 1. Extract validated request data (validated by middleware) const validatedData = req.validated.body; // Pre-validated, type-safe const id = req.params.id as string;

// 2. Execute business logic (no validation needed) const result = await operationFunction(validatedData, id);

// 3. Send response sendSuccessResponse( req, res, { data: result, serializerConfig: SerializedEntity, type: "single", // or "collection" }, { status: HttpStatus.SUCCESS, // or other status }, );

// 4. Optional: Emit events/queue operations await featureQueue.enqueue({ event: FeatureEventTypes.EventName, payload: result, metadata: { source: "controller-name" }, });

}, );

Route Configuration with Validation:

// Validation happens before controller execution
router.post("/", validateHttpRequest(createSchema, "body"), controllerName);

Key Components

1. Async Handler Wrapper

export const controllerName = asyncHandler(
  async (req: Request, res: Response) => {
    // Controller logic
  },
);

The asyncHandler automatically catches errors and forwards them to Express error handling middleware.

2. Request Data Extraction

// Body data
const { name, email } = req.body ?? {};

// Route parameters
const id = req.params.id as string;
const { processId, giftId } = req.params;

// Query parameters (if needed)
const page = req.query.page as string;

3. Input Validation (Handled by Middleware)

Controllers do not handle input validation directly. Validation is performed by middleware before the request reaches the controller. This separation of concerns ensures clean, focused controllers.

Validation Middleware Pattern:

routes.ts

// routes file occasionsRouter.post("/", validateHttpRequest(occasionInsertSchema, "body"), createOccasionController );

Validation Process:

http-request-validator.ts

// middleware/http-request-validator.ts export function validateHttpRequest<T extends z.ZodTypeAny>( schema: T, location: RequestLocation, // "body" | "params" | "query" | "headers" ) { return (req: Request, res: Response, next: NextFunction) => { const result = schema.safeParse(req[location]); if (!result.success) { // Automatically sends JSON:API error response const errors = result.error.issues.map((issue) => ({ status: "400", code: issue.code, title: "Validation Error", detail: issue.message, source: { pointer: </span><span class="token string">/</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>location<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">/</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>issue<span class="token punctuation">.</span>path<span class="token punctuation">.</span><span class="token function">join</span><span class="token punctuation">(</span><span class="token string">"/"</span><span class="token punctuation">)</span><span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">, }, })); return sendErrorResponse(req, res, errors); }

// Store validated data for controller to use req.validated[location] = result.data; next();

}; }

Controller Access to Validated Data:

create-occasion.controller.ts

// Controllers receive pre-validated data export const createOccasionController = asyncHandler( async (req: Request, res: Response) => { // Access validated data from middleware const validatedData = req.validated.body; // Type-safe validated data

// No validation needed - data is guaranteed to be valid const newOccasion = await createOccasion(validatedData);

sendSuccessResponse( req, res, { data: newOccasion, serializerConfig: SerializedOccasion, type: "single", }, { status: HttpStatus.CREATED, }, );

}, );

Validation Error Response:

validation-error-response.json

// Automatic error response from middleware { "errors": [ { "status": "400", "code": "invalid_string", "title": "Validation Error", "detail": "Invalid email", "source": { "pointer": "/body/email" }, "meta": { "requestId": "req-123", "timestamp": "2026-02-23T21:00:00.000Z" } } ] }

Schema Definition:

// occasions.types.ts
export const occasionInsertSchema = createInsertSchema(occasionsSchema).openapi({
  title: "CreateOccasion",
  description: "Request schema for creating a new occasion",
});

// Generated Zod schema automatically validates:
{
  name: z.string().min(1),
  description: z.string().optional(),
  date: z.date(),
  organizerId: z.string().uuid(),
  status: z.enum(["created", "upcoming", "active", "passed"]).default("created")
}

Benefits of Middleware Validation:

4. Business Logic Execution

// Create operations
const user = await createUser({ id: uuid(), name, email });

// Find operations
const gift = await findById(id);
if (!gift) {
  return sendErrorResponse(req, res, {
    status: HttpStatus.NOT_FOUND,
    description: "Gift not found",
  });
}

// Update/delete operations
await selectGift(processId, giftId);

5. Response Handling

The API uses two main response utility functions that handle JSON:API serialization and consistent response formatting:

sendSuccessResponse<T>()

Purpose: Sends successful responses with automatic JSON:API serialization.

Signature:

export const sendSuccessResponse = <T>(
  req: Request,
  res: Response,
  responseConfig?: JsonApiResponseConfig<T>,
  opts?: SuccessResponseOptions,
): Response => {
  // Implementation handles serialization and response formatting
};

Parameters:

ResponseConfig Interface:

export type JsonApiResponseConfig<T> = {
  type: ResourceType; // "single" | "collection"
  data: T | T[]; // Single entity or array
  serializerConfig: JsonApiResourceConfig<T>; // Serialization rules
};

SuccessResponseOptions Interface:

export interface SuccessResponseOptions {
  status?: StatusConfig; // HTTP status (defaults to 200)
  additionalMeta?: Record<string, unknown>; // Additional metadata
  links?: ResponseLinks; // Additional response links
}

Usage Examples:

// Single entity response
sendSuccessResponse(
  req,
  res,
  {
    data: user,
    serializerConfig: SerializedUser,
    type: "single",
  },
  {
    status: HttpStatus.CREATED,
    additionalMeta: {
      version: "1.0",
    },
  },
);

// Collection response
sendSuccessResponse(
  req,
  res,
  {
    data: users,
    serializerConfig: SerializedUser,
    type: "collection",
  },
  {
    status: HttpStatus.SUCCESS,
    additionalMeta: {
      total: users.length,
      page: 1,
      pageSize: 10,
    },
  },
);

// No content response (204)
sendSuccessResponse(
  req,
  res,
  undefined, // No response config needed for 204
  {
    status: HttpStatus.NO_CONTENT,
  },
);

Automatic Features:

Generated Response Structure:

// Single entity
{
  "data": {
    "id": "uuid",
    "type": "user",
    "attributes": {
      "name": "John Doe",
      "email": "john@example.com"
    }
  },
  "links": {
    "self": "https://api.example.com/users/uuid"
  },
  "meta": {
    "timestamp": "2026-02-23T21:00:00.000Z",
    "requestId": "req-123",
    "version": "1.0"  // From additionalMeta
  }
}

// Collection
{
  "data": [
    { "id": "1", "type": "user", "attributes": {...} },
    { "id": "2", "type": "user", "attributes": {...} }
  ],
  "links": {
    "self": "https://api.example.com/users"
  },
  "meta": {
    "timestamp": "2026-02-23T21:00:00.000Z",
    "requestId": "req-123",
    "total": 2,      // From additionalMeta
    "page": 1,       // From additionalMeta
    "pageSize": 10    // From additionalMeta
  }
}
sendErrorResponse()

Purpose: Sends error responses following JSON:API error specification.

Signature:

export const sendErrorResponse = (
  req: Request,
  res: Response,
  errorInput: ErrorResponseConfig | JsonApiErrorObject[],
  opts?: ErrorResponseOptions,
): Response<JsonApiErrorResponse> => {
  // Implementation handles error serialization and formatting
};

Parameters:

ErrorResponseConfig Interface:

export type ErrorResponseConfig = Partial<
  Pick<StatusConfig, "description" | "title">
> & {
  status: StatusConfig; // HTTP status with code
  source?: { pointer?: string; method?: string }; // Error source location
  details?: Record<string, any>; // Additional error details
};

ErrorResponseOptions Interface:

export interface ErrorResponseOptions {
  additionalMeta?: Record<string, unknown>; // Additional metadata
  errorId?: string; // Unique error identifier
  suppressLogging?: boolean; // Suppress error logging
}

Usage Examples:

// Simple error response
sendErrorResponse(req, res, {
  status: HttpStatus.NOT_FOUND,
  description: "User not found",
});

// Error with source pointer (for validation errors)
sendErrorResponse(req, res, {
  status: HttpStatus.BAD_REQUEST,
  title: "Validation Error",
  description: "Invalid email format",
  source: {
    pointer: "/data/attributes/email",
    method: "POST",
  },
  details: {
    field: "email",
    value: "invalid-email",
    expected: "valid email format",
  },
});

// Multiple errors (array format)
sendErrorResponse(req, res, [
  {
    status: "400",
    code: "INVALID_EMAIL",
    title: "Validation Error",
    detail: "Email format is invalid",
    source: { pointer: "/data/attributes/email" },
  },
  {
    status: "400",
    code: "MISSING_NAME",
    title: "Validation Error",
    detail: "Name is required",
    source: { pointer: "/data/attributes/name" },
  },
]);

Generated Response Structure:

// Single error
{
  "errors": [
    {
      "status": "404",
      "title": "Not Found",
      "detail": "User not found",
      "source": {
        "pointer": "/users/123"
      },
      "meta": {
        "timestamp": "2026-02-23T21:00:00.000Z",
        "requestId": "req-123"
      }
    }
  ]
}

// Multiple errors
{
  "errors": [
    {
      "status": "400",
      "code": "INVALID_EMAIL",
      "title": "Validation Error",
      "detail": "Email format is invalid",
      "source": { "pointer": "/data/attributes/email" }
    },
    {
      "status": "400",
      "code": "MISSING_NAME",
      "title": "Validation Error",
      "detail": "Name is required",
      "source": { "pointer": "/data/attributes/name" }
    }
  ]
}

Automatic Features:

Key Benefits
  1. Consistency: All responses follow JSON:API specification
  2. Type Safety: Full TypeScript support with generics
  3. Automatic Serialization: No manual JSON:API formatting needed
  4. Error Handling: Standardized error response format
  5. Metadata: Automatic request tracking and timestamps
  6. Links: Self-linking and custom link support
  7. Validation: Built-in validation for response configurations

6. Event/Queue Integration

// Optional: Emit events after successful operations
await occasionsQueue.enqueue({
  event: OccasionJobs.OccasionCreated,
  payload: newOccasion,
  metadata: { source: "occasion-controller" },
});

JSON:API Serialization

The API automatically serializes all responses to adhere to the JSON:API specification (v1.0). This ensures consistent response formats across all endpoints and provides clients with a predictable, standardized API experience.

Serialization Process

Automatic Serialization Pipeline

// Controller calls sendSuccessResponse()
sendSuccessResponse(req, res, {
  data: user,                    // Raw entity/entities
  type: "single",              // Single or collection
  serializerConfig: SerializedUser,  // Serialization rules
});

// ↓ sendSuccessResponse calls serializeJsonApi()
serializeJsonApi(req, {
  responseConfig: { data, type, serializerConfig },
  metadata: additionalMeta
});

// ↓ serializeJsonApi calls serializeResource() for each entity
serializeResource(resource, serializerConfig, req);

// ↓ Returns JSON:API compliant response
{
  "data": { /* JSON:API resource object */ },
  "links": { /* Self links */ },
  "meta": { /* Response metadata */ }
}

Resource Serialization

Input Entity:

// Raw database entity
const user = {
  id: "uuid-123",
  name: "John Doe",
  email: "john@example.com",
  password: "hashed-password", // Sensitive data
  createdAt: "2026-02-23T21:00:00.000Z",
  updatedAt: "2026-02-23T21:00:00.000Z",
};

Serializer Configuration:

// users.config.ts
export const SerializedUser: JsonApiResourceConfig<User> = {
  type: "user",
  attributes: (user: User) => ({
    name: user.name,
    email: user.email,
    createdAt: user.createdAt,
    // password excluded for security
  }),
  resourceMeta: (user: User) => ({
    isActive: user.email.includes("@example.com"),
    memberSince: user.createdAt,
  }),
  links: (user: User, req: Request) => ({
    self: `${req.protocol}://${req.get("host")}/users/${user.id}`,
    profile: `${req.protocol}://${req.get("host")}/users/${user.id}/profile`,
  }),
};

JSON:API Output:

{
  "data": {
    "type": "user",
    "id": "uuid-123",
    "attributes": {
      "name": "John Doe",
      "email": "john@example.com",
      "createdAt": "2026-02-23T21:00:00.000Z"
    },
    "meta": {
      "isActive": true,
      "memberSince": "2026-02-23T21:00:00.000Z"
    },
    "links": {
      "self": "https://api.example.com/users/uuid-123",
      "profile": "https://api.example.com/users/uuid-123/profile"
    }
  },
  "links": {
    "self": "https://api.example.com/users/uuid-123"
  },
  "meta": {
    "requestId": "req-456",
    "timestamp": "2026-02-23T21:00:00.000Z"
  }
}

Serialization Configuration

JsonApiResourceConfig Interface

export interface JsonApiResourceConfig<T> {
  type: string; // Resource type (e.g., "user", "gift")
  attributes: (resource: T) => Record<string, any>; // Attribute mapping function
  relationships?: Record<string, RelationshipObject<T>>; // Relationships
  resourceMeta?: (resource: T) => Record<string, any>; // Resource-specific metadata
  links?: (resource: T, req: Request) => ResourceLinkObject; // Resource links
}

Configuration Examples

Simple Entity:

export const SerializedGift: JsonApiResourceConfig<Gift> = {
  type: "gift",
  attributes: (gift: Gift) => ({
    name: gift.name,
    description: gift.description,
    price: gift.price,
    category: gift.category,
  }),
};

Entity with Relationships:

export const SerializedOccasion: JsonApiResourceConfig<Occasion> = {
  type: "occasion",
  attributes: (occasion: Occasion) => ({
    name: occasion.name,
    description: occasion.description,
    date: occasion.date,
    status: occasion.status,
  }),
  relationships: {
    organizer: {
      data: (occasion: Occasion) => ({
        type: "user",
        id: occasion.organizerId,
      }),
      links: (occasion: Occasion, req: Request) => ({
        self: `${req.protocol}://${req.get("host")}/occasions/${occasion.id}/relationships/organizer`,
        related: `${req.protocol}://${req.get("host")}/occasions/${occasion.id}/organizer`,
      }),
    },
    participants: {
      data: (occasion: Occasion) =>
        occasion.participants?.map((p) => ({
          type: "user",
          id: p.id,
        })) || null,
    },
  },
};

Entity with Custom Metadata and Links:

export const SerializedUser: JsonApiResourceConfig<User> = {
  type: "user",
  attributes: (user: User) => ({
    name: user.name,
    email: user.email,
  }),
  resourceMeta: (user: User) => ({
    isActive: user.email.includes("@example.com"),
    memberSince: user.createdAt,
    lastSeen: user.lastLoginAt,
  }),
  links: (user: User, req: Request) => ({
    self: `${req.protocol}://${req.get("host")}/users/${user.id}`,
    profile: `${req.protocol}://${req.get("host")}/users/${user.id}/profile`,
    gifts: `${req.protocol}://${req.get("host")}/users/${user.id}/gifts`,
  }),
};

Collection Serialization

Collection Response Structure

// Input: Array of entities
const users = [user1, user2, user3];

// Serialization
sendSuccessResponse(
  req,
  res,
  {
    data: users,
    type: "collection",
    serializerConfig: SerializedUser,
  },
  {
    additionalMeta: {
      total: users.length,
      page: 1,
      pageSize: 10,
      totalPages: Math.ceil(users.length / 10),
    },
  },
);

Collection Output:

{
  "data": [
    {
      "type": "user",
      "id": "uuid-1",
      "attributes": { "name": "John", "email": "john@example.com" },
      "links": { "self": "https://api.example.com/users/uuid-1" }
    },
    {
      "type": "user",
      "id": "uuid-2",
      "attributes": { "name": "Jane", "email": "jane@example.com" },
      "links": { "self": "https://api.example.com/users/uuid-2" }
    },
    {
      "type": "user",
      "id": "uuid-3",
      "attributes": { "name": "Bob", "email": "bob@example.com" },
      "links": { "self": "https://api.example.com/users/uuid-3" }
    }
  ],
  "links": {
    "self": "https://api.example.com/users?page=1",
    "first": "https://api.example.com/users?page=1",
    "last": "https://api.example.com/users?page=1",
    "prev": null,
    "next": null
  },
  "meta": {
    "requestId": "req-789",
    "timestamp": "2026-02-23T21:00:00.000Z",
    "total": 3,
    "page": 1,
    "pageSize": 10,
    "totalPages": 1
  }
}

Error Serialization

Error Response Structure

// Error input
sendErrorResponse(req, res, {
  status: HttpStatus.BAD_REQUEST,
  title: "Validation Error",
  description: "Invalid email format",
  source: {
    pointer: "/data/attributes/email",
    method: "POST",
  },
  details: {
    field: "email",
    value: "invalid-email",
    expected: "valid email format",
  },
});

Error Output:

{
  "errors": [
    {
      "id": "error-uuid-123",
      "status": "400",
      "code": "BAD_REQUEST",
      "title": "Validation Error",
      "detail": "Invalid email format",
      "source": {
        "pointer": "/data/attributes/email",
        "method": "POST"
      },
      "meta": {
        "requestId": "req-456",
        "timestamp": "2026-02-23T21:00:00.000Z",
        "field": "email",
        "value": "invalid-email",
        "expected": "valid email format"
      }
    }
  ]
}

JSON:API Compliance Features

1. Resource Objects

{
  "type": "user",           // Required: Resource type
  "id": "uuid-123",        // Required: Resource ID
  "attributes": { ... },    // Optional: Resource attributes
  "relationships": { ... }, // Optional: Resource relationships
  "links": { ... },         // Optional: Resource links
  "meta": { ... }          // Optional: Resource metadata
}

2. Top-Level Response

{
  "data": { ... },          // Required: Primary data
  "errors": [ ... ],        // Optional: Error objects (mutually exclusive with data)
  "meta": { ... },          // Optional: Response metadata
  "links": { ... },         // Optional: Response links
  "included": [ ... ]       // Optional: Included resources
}

3. Automatic Features

4. Security Considerations

Benefits of JSON:API Serialization

For API Consumers

For Developers

For the System

This automatic JSON:API serialization ensures that all API responses are consistent, secure, and compliant with industry standards while providing developers with powerful configuration options for customizing resource representation.

JSON:API Standard

All responses follow JSON:API specification with automatic serialization:

// Single entity response
{
  "data": {
    "id": "uuid",
    "type": "user",
    "attributes": {
      "name": "John Doe",
      "email": "john@example.com"
    }
  },
  "meta": {
    "timestamp": "2026-02-23T20:57:00.000Z"
  }
}

// Collection response
{
  "data": [
    { "id": "1", "type": "user", "attributes": {...} },
    { "id": "2", "type": "user", "attributes": {...} }
  ],
  "meta": {
    "total": 2,
    "timestamp": "2026-02-23T20:57:00.000Z"
  }
}

// Error response
{
  "errors": [
    {
      "status": "404",
      "title": "Not Found",
      "detail": "Entity not found"
    }
  ]
}

HTTP Status Codes

Controller Examples

1. Create Controller (POST)

// users/controllers/create-user.controller.ts
export const createUserController = asyncHandler(
  async (req: Request, res: Response) => {
    const { name, email } = req.body ?? {};

    const user = await createUser({
      id: uuid(),
      name: name as string,
      email: email as string,
    });

    sendSuccessResponse(
      req,
      res,
      {
        data: user,
        serializerConfig: SerializedUser,
        type: "single",
      },
      {
        status: HttpStatus.CREATED,
      },
    );
  },
);

2. Get Single Controller (GET)

// gifts/controllers/get-gift.controller.ts
export const getGiftController = asyncHandler(
  async (req: Request, res: Response) => {
    const id = req.params.id as string;
    const gift = await findById(id);

    if (!gift) {
      return sendErrorResponse(req, res, {
        status: HttpStatus.NOT_FOUND,
        description: "Gift not found",
      });
    }

    sendSuccessResponse(
      req,
      res,
      {
        data: gift,
        serializerConfig: SerializedGift,
        type: "single",
      },
      {
        status: HttpStatus.SUCCESS,
      },
    );
  },
);

3. Get Collection Controller (GET)

// users/controllers/get-users.controller.ts
export const getUsersController = asyncHandler(
  async (req: Request, res: Response) => {
    const users = await findUsers();
    const total = users.length;

    return sendSuccessResponse(
      req,
      res,
      {
        data: users,
        serializerConfig: SerializedUser,
        type: "collection",
      },
      {
        status: HttpStatus.SUCCESS,
        additionalMeta: {
          total,
        },
      },
    );
  },
);

4. Action Controller (POST/PUT)

// decisions/controllers/select-gift.controller.ts
export const selectGiftOptionController = asyncHandler(
  async (req: Request, res: Response) => {
    const { processId, giftId } = req.params;

    // Validation
    if (!processId || Array.isArray(processId)) {
      return sendErrorResponse(req, res, {
        status: HttpStatus.BAD_REQUEST,
        description: "Process ID is required",
      });
    }

    // Execute action
    await selectGift(processId, giftId);

    // Return 204 No Content
    return sendSuccessResponse(req, res, undefined, {
      status: HttpStatus.NO_CONTENT,
    });
  },
);

5. Controller with Event Emission

// occasions/controllers/create-occasions.controller.ts
export const createOccasionController = asyncHandler(
  async (req: Request, res: Response) => {
    const reqBody = req.body;
    const newOccasion = await createOccasion(reqBody);

    sendSuccessResponse(
      req,
      res,
      {
        data: newOccasion,
        serializerConfig: SerializedOccasion,
        type: "single",
      },
      {
        status: HttpStatus.CREATED,
      },
    );

    // Emit event after successful creation
    await occasionsQueue.enqueue({
      event: OccasionJobs.OccasionCreated,
      payload: newOccasion,
      metadata: { source: "occasion-controller" },
    });
  },
);

Best Practices

1. Consistent Error Handling

2. Input Validation

3. Response Consistency

4. Event Emission

5. Async Operations

6. Separation of Concerns

File Organization

src/feature/[feature-name]/
├── controllers/
│   ├── create-[feature].controller.ts
│   ├── get-[feature].controller.ts
│   ├── get-[feature]s.controller.ts
│   ├── update-[feature].controller.ts
│   └── delete-[feature].controller.ts
├── operations/
│   ├── [feature].create.ts
│   ├── [feature].find.ts
│   ├── [feature].update.ts
│   └── [feature].delete.ts
├── [feature].config.ts
├── [feature].types.ts
└── workers/
    ├── [feature].events.ts
    ├── [feature].queue.ts
    └── [feature].workers.ts

This structure ensures clear separation of concerns and consistent patterns across all features.