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.
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.
For a module called resources:
mkdir -p src/modules/resources/{Actions,Controllers,Docs,Models,Routes,TypeChecking,Validators,Migrations}Start by defining the data shapes your module will work with. Create one file per interface.
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
}export interface CreateResourceOptions {
title: string
description: string
type: string
url: string
}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
Validators are Zod schemas that enforce request payload shapes. They live in Validators/ and reuse common schemas from @shared/validators/commonSchemas.js.
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.jswhenever applicable - Schema names follow the pattern
create<Entity>Schema,update<Entity>Schema - Export inferred types alongside the schemas
The model contains factory functions for creating entities and converting to/from Firestore documents.
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 conversiontoPublicView()/toPublicProfile()- strips internal fields for external consumption- Use
now()from@shared/utils/dateUtils.jsfor timestamps - Use
generateId()from@shared/utils/idGenerator.jsfor ID generation
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.
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
MESSAGESfrom@shared/constants/messages.jsand use message constants - never use raw strings in error messages - Throw
AppErrorinstances 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
COLLECTIONSinsrc/config/constants.ts
Controllers are thin - they extract data from the request, call an action, and return a standardised response using the response builders.
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
requestandresponseas parameter names (notreq/res) - Prefix unused parameters with
_(e.g._request) - Use
successResponse()anderrorResponse()from@shared/utils/apiResponse.js- never construct raw JSON responses - Use
StatusCodesfromhttp-status-codes- never use raw numeric status codes - Use
MESSAGESconstants - never use raw strings
Routes wire together middleware and controllers.
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 routerConventions to follow:
- Public routes go before protected routes
- The middleware chain order is:
requireAuth→validate(schema)→controller - Rate-limited routes use
strictRateLimiterfrom@middleware/rateLimiter.js(e.g. signup) - Admin-only routes use
requireRole('admin')instead ofrequireAuth
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.
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 fromDocs/<module>Docs.ts - Use the shared helpers from
@shared/openapi/helpers.jsto reduce boilerplate - Add
.openapi({ example: '...' })to your Zod validators so examples appear in Swagger UI - Each
registerPathcall maps to one endpoint - include all relevant HTTP status codes - Protected endpoints include
security: [bearerAuth()]
This is the most important file. It is the only thing other modules and the route layer import.
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
routesas a static readonly router - Modules with privileged flows can also expose
adminRoutes,systemRoutes, andadminSetupRoutes - 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
In src/config/constants.ts:
export const COLLECTIONS = {
MEMBERS: 'members',
JOBS: 'jobs',
RESOURCES: 'resources', // add your collection
} as constIn 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 constIn src/shared/errors/errorCodes.ts:
export const ERROR_CODES = {
// ... existing codes
// Resources
RESOURCE_NOT_FOUND: 'RESOURCE_NOT_FOUND',
} as constIn src/routes/v1/index.ts:
import ResourcesSystem from '@modules/resources/index.js'
router.use('/resources', ResourcesSystem.routes)Run the TypeScript compiler to catch any type errors:
./node_modules/.bin/tsc --noEmitRun the linter:
npm run lintStart the dev server and test your new endpoints:
npm run devBefore considering your module complete, verify the following:
- All folder names follow the PascalCase convention (
Actions/,Controllers/,Models/, etc.) - The facade in
index.tsis 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, andStatusCodes - Every action uses
MESSAGESandERROR_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.tsand registered insrc/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 userequireRole('admin'), andadminRoutesis mounted at/admin/<module-name> -
tsc --noEmitpasses with zero errors -
npm run lintpasses with zero warnings
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.
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).
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) { ... }
}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 |
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 routerThis 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).
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.
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.