Skip to content

Instantly share code, notes, and snippets.

@adegbengaagoro
Created June 17, 2026 07:33
Show Gist options
  • Select an option

  • Save adegbengaagoro/7cbec06ceffcd46be27df4484f2f2cb3 to your computer and use it in GitHub Desktop.

Select an option

Save adegbengaagoro/7cbec06ceffcd46be27df4484f2f2cb3 to your computer and use it in GitHub Desktop.
This guide walks you through creating a new domain module in the TN Naija Membership Portal API.

Module Creation Guide

This guide walks you through creating a new domain module in the TN Naija Membership Portal API. Every module follows the Module Facade Pattern - a single index.ts file exposes a static system class as the module's public API, while all internals (actions, models, controllers, routes) remain private.

Most modules are HTTP-facing and expose route properties. Service modules (like notifications) can skip Controllers/Routes and expose only callable methods through the facade.

Use the existing members module as a living reference throughout this guide.


Architecture Overview

A module represents a bounded domain (e.g. members, resources, events). Its internal structure is always:

src/modules/<module-name>/
├── Actions/          # Business logic - the core of the module
├── Controllers/      # Express request/response handlers
├── Docs/             # OpenAPI endpoint definitions for Swagger UI
├── Models/           # Data shaping, Firestore conversion, factory functions
├── Routes/           # Express route definitions
├── TypeChecking/     # TypeScript interfaces for the module's data
├── Validators/       # Zod schemas for request validation
├── Migrations/       # Data migration scripts (if needed)
└── index.ts          # Public facade - the ONLY file other modules import

Key rule: Nothing outside the module should import from its subdirectories. All cross-module interaction goes through the facade class in index.ts.


Step 1: Create the Folder Structure

For a module called resources:

mkdir -p src/modules/resources/{Actions,Controllers,Docs,Models,Routes,TypeChecking,Validators,Migrations}

Step 2: Define Types (TypeChecking)

Start by defining the data shapes your module will work with. Create one file per interface.

TypeChecking/ResourceTypes.ts

export interface Resource {
  id: string
  title: string
  description: string
  type: string
  url: string
  isPublished: boolean
  createdAt: Date
  updatedAt: Date
}

export interface ResourcePublicView {
  id: string
  title: string
  description: string
  type: string
  url: string
}

TypeChecking/CreateResourceOptions.ts

export interface CreateResourceOptions {
  title: string
  description: string
  type: string
  url: string
}

TypeChecking/UpdateResourceOptions.ts

export interface UpdateResourceOptions {
  title?: string
  description?: string
  url?: string
}

Conventions to follow:

  • Each interface gets its own file named after what it represents
  • The main entity type (e.g. Resource) lives in <Entity>Types.ts
  • Input types are named Create<Entity>Options, Update<Entity>Options, etc.
  • Keep types focused - don't mix entity types with input/output types

Step 3: Create Validators

Validators are Zod schemas that enforce request payload shapes. They live in Validators/ and reuse common schemas from @shared/validators/commonSchemas.js.

Validators/resourceValidators.ts

import { z } from 'zod'

export const createResourceSchema = z.object({
  title: z.string().min(1, 'Title is required').max(200).trim(),
  description: z.string().min(1).max(2000).trim(),
  type: z.string().min(1, 'Type is required').max(50).trim(),
  url: z.string().url('Must be a valid URL'),
})

export const updateResourceSchema = z.object({
  title: z.string().min(1).max(200).trim().optional(),
  description: z.string().min(1).max(2000).trim().optional(),
  url: z.string().url().optional(),
})

export type CreateResourceInput = z.infer<typeof createResourceSchema>
export type UpdateResourceInput = z.infer<typeof updateResourceSchema>

Conventions to follow:

  • Import common schemas (emailSchema, nameSchema, phoneSchema, passwordSchema, paginationSchema, idParamSchema) from @shared/validators/commonSchemas.js whenever applicable
  • Schema names follow the pattern create<Entity>Schema, update<Entity>Schema
  • Export inferred types alongside the schemas

Step 4: Create the Model

The model contains factory functions for creating entities and converting to/from Firestore documents.

Models/Resource.ts

import type { Resource, ResourcePublicView } from '../TypeChecking/ResourceTypes.js'
import type { CreateResourceOptions } from '../TypeChecking/CreateResourceOptions.js'
import { now } from '@shared/utils/dateUtils.js'

export function createResourceModel(
  id: string,
  options: CreateResourceOptions
): Resource {
  const timestamp = now()

  return {
    id,
    title: options.title.trim(),
    description: options.description.trim(),
    type: options.type.trim(),
    url: options.url,
    isPublished: false,
    createdAt: timestamp,
    updatedAt: timestamp,
  }
}

export function resourceToFirestore(resource: Resource): Record<string, unknown> {
  return {
    id: resource.id,
    title: resource.title,
    description: resource.description,
    type: resource.type,
    url: resource.url,
    isPublished: resource.isPublished,
    createdAt: resource.createdAt,
    updatedAt: resource.updatedAt,
  }
}

export function resourceFromFirestore(data: Record<string, unknown>): Resource {
  return {
    id: data.id as string,
    title: data.title as string,
    description: data.description as string,
    type: data.type as string,
    url: data.url as string,
    isPublished: data.isPublished as boolean,
    createdAt: data.createdAt instanceof Date
      ? data.createdAt
      : new Date(data.createdAt as string),
    updatedAt: data.updatedAt instanceof Date
      ? data.updatedAt
      : new Date(data.updatedAt as string),
  }
}

export function toPublicView(resource: Resource): ResourcePublicView {
  return {
    id: resource.id,
    title: resource.title,
    description: resource.description,
    type: resource.type,
    url: resource.url,
  }
}

Conventions to follow:

  • create<Entity>Model() - factory that builds a fresh entity from input options
  • <entity>ToFirestore() - serialises the entity for Firestore storage
  • <entity>FromFirestore() - deserialises a Firestore document back into the entity type, handling date conversion
  • toPublicView() / toPublicProfile() - strips internal fields for external consumption
  • Use now() from @shared/utils/dateUtils.js for timestamps
  • Use generateId() from @shared/utils/idGenerator.js for ID generation

Step 5: Write Actions (Business Logic)

Actions contain the core business logic. They interact with Firestore, call Firebase Auth, enforce business rules, and throw AppError instances on failure. Split into focused files by responsibility.

Actions/ResourceActions.ts

import { db } from '@config/firebase.js'
import { COLLECTIONS } from '@config/constants.js'
import { AppError } from '@shared/errors/AppError.js'
import { ERROR_CODES } from '@shared/errors/errorCodes.js'
import { MESSAGES } from '@shared/constants/messages.js'
import { generateId } from '@shared/utils/idGenerator.js'
import { now } from '@shared/utils/dateUtils.js'
import {
  createResourceModel,
  resourceToFirestore,
  resourceFromFirestore,
} from '../Models/Resource.js'
import type { CreateResourceOptions } from '../TypeChecking/CreateResourceOptions.js'
import type { Resource } from '../TypeChecking/ResourceTypes.js'

const resourcesCollection = () => db.collection(COLLECTIONS.RESOURCES)

export async function createResource(options: CreateResourceOptions): Promise<Resource> {
  const resourceId = generateId('res')
  const resource = createResourceModel(resourceId, options)

  await resourcesCollection().doc(resourceId).set(resourceToFirestore(resource))

  return resource
}

export async function getResourceById(id: string): Promise<Resource | null> {
  const doc = await resourcesCollection().doc(id).get()
  if (!doc.exists) return null
  return resourceFromFirestore(doc.data() as Record<string, unknown>)
}

Conventions to follow:

  • Always import MESSAGES from @shared/constants/messages.js and use message constants - never use raw strings in error messages
  • Throw AppError instances using the static factories: AppError.notFound(MESSAGES.X, ERROR_CODES.Y)
  • When you introduce new error codes, add them to src/shared/errors/errorCodes.ts
  • When you introduce new message strings, add them to src/shared/constants/messages.ts
  • When you introduce a new Firestore collection, add it to COLLECTIONS in src/config/constants.ts

Step 6: Create Controllers

Controllers are thin - they extract data from the request, call an action, and return a standardised response using the response builders.

Controllers/ResourceController.ts

import type { Request, Response } from 'express'
import { StatusCodes } from 'http-status-codes'
import { asyncHandler } from '@shared/utils/asyncHandler.js'
import { successResponse } from '@shared/utils/apiResponse.js'
import { MESSAGES } from '@shared/constants/messages.js'
import * as ResourceActions from '../Actions/ResourceActions.js'

export const create = asyncHandler(async (request: Request, response: Response) => {
  const resource = await ResourceActions.createResource(request.body)

  response.status(StatusCodes.CREATED).json(
    successResponse(StatusCodes.CREATED, MESSAGES.RESOURCE_CREATED, resource),
  )
})

export const getById = asyncHandler(async (request: Request, response: Response) => {
  const resource = await ResourceActions.getResourceById(request.params.id)

  response.status(StatusCodes.OK).json(
    successResponse(StatusCodes.OK, MESSAGES.RESOURCE_FETCHED, resource),
  )
})

Conventions to follow:

  • Every handler is wrapped with asyncHandler() - this catches rejected promises and forwards them to the error handler
  • Use request and response as parameter names (not req/res)
  • Prefix unused parameters with _ (e.g. _request)
  • Use successResponse() and errorResponse() from @shared/utils/apiResponse.js - never construct raw JSON responses
  • Use StatusCodes from http-status-codes - never use raw numeric status codes
  • Use MESSAGES constants - never use raw strings

Step 7: Define Routes

Routes wire together middleware and controllers.

Routes/ResourceRoutes.ts

import { Router } from 'express'
import { requireAuth } from '@middleware/auth.js'
import { validate } from '@middleware/validate.js'
import { createResourceSchema, updateResourceSchema } from '../Validators/resourceValidators.js'
import * as ResourceController from '../Controllers/ResourceController.js'

const router = Router()

// Public routes (if any)
// router.get('/', ResourceController.list)

// Protected routes
router.post('/', requireAuth, validate(createResourceSchema), ResourceController.create)
router.get('/:id', requireAuth, ResourceController.getById)

export default router

Conventions to follow:

  • Public routes go before protected routes
  • The middleware chain order is: requireAuthvalidate(schema)controller
  • Rate-limited routes use strictRateLimiter from @middleware/rateLimiter.js (e.g. signup)
  • Admin-only routes use requireRole('admin') instead of requireAuth

Step 8: Add API Documentation (Docs)

Each module has a Docs/ folder containing OpenAPI endpoint definitions for Swagger UI. These are completely separate from routes and controllers - no JSDoc, no decorators, no changes to existing files.

The project uses @asteasolutions/zod-to-openapi to derive OpenAPI schemas directly from your existing Zod validators, so the docs stay in sync with actual validation.

Docs/resourceDocs.ts

import { registry } from '@config/openapi.js'
import { jsonBody, jsonResponse, errorResponse, bearerAuth } from '@shared/openapi/helpers.js'
import { createResourceSchema } from '../Validators/resourceValidators.js'

export function registerResourceDocs() {
  registry.registerPath({
    method: 'post',
    path: '/api/v1/resources',
    tags: ['Resources'],
    summary: 'Create a new resource',
    security: [bearerAuth()],
    request: { body: jsonBody(createResourceSchema) },
    responses: {
      201: jsonResponse('Resource created', /* your response schema */),
      400: errorResponse(400, 'Validation failed'),
      401: errorResponse(401, 'Unauthorized'),
    },
  })

  registry.registerPath({
    method: 'get',
    path: '/api/v1/resources/{id}',
    tags: ['Resources'],
    summary: 'Get a resource by ID',
    security: [bearerAuth()],
    responses: {
      200: jsonResponse('Resource retrieved', /* your response schema */),
      401: errorResponse(401, 'Unauthorized'),
      404: errorResponse(404, 'Resource not found'),
    },
  })
}

Then register the docs in src/app.ts inside the existing Swagger UI guard:

if (env.NODE_ENV !== 'production') {
  registerMemberDocs()
  registerResourceDocs()  // add your module's docs here
  app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(generateOpenApiSpec(), { ... }))
}

Conventions to follow:

  • One register<Module>Docs() function per module, exported from Docs/<module>Docs.ts
  • Use the shared helpers from @shared/openapi/helpers.js to reduce boilerplate
  • Add .openapi({ example: '...' }) to your Zod validators so examples appear in Swagger UI
  • Each registerPath call maps to one endpoint - include all relevant HTTP status codes
  • Protected endpoints include security: [bearerAuth()]

Step 9: Create the Facade (index.ts)

This is the most important file. It is the only thing other modules and the route layer import.

index.ts

import * as ResourceActions from './Actions/ResourceActions.js'
import type { CreateResourceOptions } from './TypeChecking/CreateResourceOptions.js'
import type { UpdateResourceOptions } from './TypeChecking/UpdateResourceOptions.js'
import type { Resource, ResourcePublicView } from './TypeChecking/ResourceTypes.js'
import resourceRoutes from './Routes/ResourceRoutes.js'

export default class ResourcesSystem {
  static readonly routes = resourceRoutes

  static async createResource(options: CreateResourceOptions) {
    return ResourceActions.createResource(options)
  }

  static async getResource(id: string) {
    return ResourceActions.getResourceById(id)
  }
}

export type { Resource, ResourcePublicView, CreateResourceOptions, UpdateResourceOptions }

Conventions to follow:

  • The class name follows the pattern <Module>System (e.g. MembersSystem, ResourcesSystem)
  • Export the class as the default export
  • Export types as named exports
  • Route-facing modules expose routes as a static readonly router
  • Modules with privileged flows can also expose adminRoutes, systemRoutes, and adminSetupRoutes
  • Service-only modules may omit route properties and expose methods only (for example sendVerificationEmail())
  • Each static method is a thin proxy to an action - no business logic lives here
  • Other modules interact with yours exclusively through this class

Step 10: Register the Module

10a. Add the Firestore Collection

In src/config/constants.ts:

export const COLLECTIONS = {
  MEMBERS: 'members',
  JOBS: 'jobs',
  RESOURCES: 'resources',  // add your collection
} as const

10b. Add Message Constants

In src/shared/constants/messages.ts, add entries for every response your module returns:

export const MESSAGES = {
  // ... existing messages

  // Resources
  RESOURCE_CREATED: 'Resource created successfully',
  RESOURCE_FETCHED: 'Resource retrieved successfully',
  RESOURCE_UPDATED: 'Resource updated successfully',
  RESOURCE_NOT_FOUND: 'Resource not found',
} as const

10c. Add Error Codes (if needed)

In src/shared/errors/errorCodes.ts:

export const ERROR_CODES = {
  // ... existing codes

  // Resources
  RESOURCE_NOT_FOUND: 'RESOURCE_NOT_FOUND',
} as const

10d. Mount the Routes

In src/routes/v1/index.ts:

import ResourcesSystem from '@modules/resources/index.js'

router.use('/resources', ResourcesSystem.routes)

Step 11: Verify

Run the TypeScript compiler to catch any type errors:

./node_modules/.bin/tsc --noEmit

Run the linter:

npm run lint

Start the dev server and test your new endpoints:

npm run dev

Checklist

Before considering your module complete, verify the following:

  • All folder names follow the PascalCase convention (Actions/, Controllers/, Models/, etc.)
  • The facade in index.ts is the only entry point - no deep imports from outside the module
  • If the module is route-facing, expose the correct route surfaces (routes, adminRoutes, systemRoutes, adminSetupRoutes) from the facade
  • Every controller uses asyncHandler(), successResponse()/errorResponse(), MESSAGES, and StatusCodes
  • Every action uses MESSAGES and ERROR_CODES - no raw strings in error messages
  • Request validation uses Zod schemas passed through the validate() middleware
  • New Firestore collections are registered in src/config/constants.ts
  • New message strings are added to src/shared/constants/messages.ts
  • New error codes are added to src/shared/errors/errorCodes.ts
  • Routes are mounted in src/routes/v1/index.ts
  • OpenAPI docs are defined in Docs/<module>Docs.ts and registered in src/app.ts
  • Zod validators have .openapi({ example: '...' }) metadata for Swagger UI
  • If the module has admin operations: admin files follow the Admin<Entity> naming convention, admin routes use requireRole('admin'), and adminRoutes is mounted at /admin/<module-name>
  • tsc --noEmit passes with zero errors
  • npm run lint passes with zero warnings

Adding Admin Capabilities

When a module needs admin-specific operations (e.g. listing all entities, modifying any entity, changing roles, deactivating accounts), separate them from regular member-facing operations by creating parallel files at the Actions, Controllers, Routes, and Validators layers. The core TypeChecking and Models layers remain shared - both audiences work with the same underlying entity.

File Structure

Using the members module as an example, the folder grows like this:

src/modules/members/
├── Actions/
│   ├── AuthActions.ts              # Member-facing auth logic
│   ├── MemberActions.ts            # Member-facing CRUD
│   ├── ProfileActions.ts           # Member-facing profile queries
│   └── AdminMemberActions.ts       # Admin-only operations
├── Controllers/
│   ├── MemberController.ts         # Member-facing handlers
│   └── AdminMemberController.ts    # Admin-only handlers
├── Docs/
│   └── memberDocs.ts              # OpenAPI endpoint definitions (Swagger)
├── Routes/
│   ├── MemberRoutes.ts             # Member-facing routes (requireAuth)
│   └── AdminMemberRoutes.ts        # Admin-only routes (requireRole('admin'))
├── Validators/
│   ├── memberValidators.ts         # Member-facing schemas
│   └── adminMemberValidators.ts    # Admin-only schemas
├── Models/
│   └── Member.ts                   # Shared - same entity
├── TypeChecking/
│   ├── MemberTypes.ts              # Shared - same types
│   ├── CreateMemberOptions.ts      # Shared
│   ├── UpdateMemberOptions.ts      # Shared
│   ├── MemberIdentifierOptions.ts  # Shared
│   └── ListMembersOptions.ts       # Used by admin list operation
├── Migrations/
│   └── index.ts
└── index.ts                        # Facade exposes both routes and adminRoutes

The naming convention is consistent: prefix admin files with Admin (e.g. AdminMemberActions.ts, AdminMemberController.ts, AdminMemberRoutes.ts) and admin for validators (e.g. adminMemberValidators.ts).

Facade

The facade can expose multiple route properties - routes for member-facing routes, adminRoutes for admin-only routes, and additional setup/system routes when needed:

import systemRoutes from './Routes/SystemRoutes.js'
import memberRoutes from './Routes/MemberRoutes.js'
import adminSetupRoutes from './Routes/AdminSetupRoutes.js'
import adminMemberRoutes from './Routes/AdminMemberRoutes.js'

export default class MembersSystem {
  static readonly systemRoutes = systemRoutes
  static readonly routes = memberRoutes
  static readonly adminSetupRoutes = adminSetupRoutes
  static readonly adminRoutes = adminMemberRoutes

  // Member-facing methods
  static async createMember(options: CreateMemberOptions) { ... }
  static async getMember(identifier: MemberIdentifierOptions) { ... }

  // Admin-facing methods
  static async listMembers(options: ListMembersOptions) { ... }
  static async updateMemberRole(memberId: string, role: MemberRole) { ... }
  static async deactivateMember(memberId: string) { ... }
}

Route Mounting

In src/routes/v1/index.ts, mount the two route sets at separate paths:

import MembersSystem from '@modules/members/index.js'

router.use('/members', MembersSystem.routes)
router.use('/admin/members', MembersSystem.adminRoutes)

This produces a clear URL structure where you can tell from any endpoint whether it is member-facing or admin-only:

Member-facing - /api/v1/members/*

Method Path Description
POST /api/v1/members/signup Create a new member account
GET /api/v1/members/me Get my profile
PATCH /api/v1/members/me Update my profile
POST /api/v1/members/me/resend-verification Resend email verification link
GET /api/v1/members/:id Get a member's public profile

Admin-only - /api/v1/admin/members/*

Method Path Description
GET /api/v1/admin/members List all members (paginated)
GET /api/v1/admin/members/:id Get any member's full profile
PATCH /api/v1/admin/members/:id Update any member's details
PATCH /api/v1/admin/members/:id/role Change a member's role
PATCH /api/v1/admin/members/:id/deactivate Deactivate a member
PATCH /api/v1/admin/members/:id/activate Reactivate a member

Admin Routes Convention

Every route in AdminMemberRoutes.ts uses requireRole('admin') instead of requireAuth. This makes the access boundary explicit at the route definition level:

import { Router } from 'express'
import { requireRole } from '@middleware/requireRole.js'
import { validate } from '@middleware/validate.js'
import { updateMemberRoleSchema } from '../Validators/adminMemberValidators.js'
import * as AdminMemberController from '../Controllers/AdminMemberController.js'

const router = Router()

router.get('/', requireRole('admin'), AdminMemberController.listMembers)
router.get('/:id', requireRole('admin'), AdminMemberController.getMemberById)
router.patch('/:id', requireRole('admin'), validate(updateMemberSchema), AdminMemberController.updateMember)
router.patch('/:id/role', requireRole('admin'), validate(updateMemberRoleSchema), AdminMemberController.updateRole)
router.patch('/:id/deactivate', requireRole('admin'), AdminMemberController.deactivate)
router.patch('/:id/activate', requireRole('admin'), AdminMemberController.activate)

export default router

Scalability

This pattern scales consistently across modules. If you later add a resources module with admin operations, its admin routes mount at /admin/resources:

router.use('/resources', ResourcesSystem.routes)
router.use('/admin/resources', ResourcesSystem.adminRoutes)

The /admin prefix gives you a clean place to apply blanket middleware to all admin routes across the entire API if needed (e.g. audit logging, stricter rate limiting).

When to Skip Admin Separation

Not every module needs admin routes. If a module has no admin-specific operations, simply omit the Admin* files and the adminRoutes property from the facade. The pattern is opt-in - only add it when the module genuinely has operations that should be restricted to admin users.


Cross-Module Communication

If your module needs to interact with another module (e.g. resources needs to check if a member exists), always go through the facade:

import MembersSystem from '@modules/members/index.js'

const member = await MembersSystem.getMember({ id: memberId })

Never import directly from another module's Actions/, Models/, or any other subdirectory. The facade is the contract - internals can change freely without breaking consumers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment