Skip to main content

API Versioning

Overview

The API uses a header-based response versioning strategy. Clients send an X-API-Version header to request a specific response shape. The server transforms raw internal data through version-specific transformer functions before returning it.

This allows the API to evolve response shapes without breaking older clients -- a v1 client keeps receiving the v1 shape, while newer clients can opt into v2, v3, etc.

Architecture

Client Request Server
───────────────── ──────────────────────────────────────

GET /api/auth/me VersioningGuard (global)
X-API-Version: 2 ──► ┌────────────────────────────────────┐
│ 1. Validate header format │
│ 2. Reject if version > max │
└──────────────┬─────────────────────┘

Controller handler
┌────────────────────────────────────┐
│ 1. Call service to get raw data │
│ 2. versioning.resolve(req, key) │
│ 3. Returns the v2 transformer │
│ 4. Transform raw → v2 shape │
└────────────────────────────────────┘
◄── Response (v2 shape)

Key Components

ComponentLocationPurpose
VersioningServicesrc/versioning/versioning.service.tsCentral registry; resolves X-API-Version header to the correct transformer
VersioningGuardsrc/versioning/versioning.guard.tsGlobal guard that validates the version header before any handler runs
TransformerRegistrysrc/versioning/transformer-registry.tsSingleton that registers all transformers at startup via onModuleInit
VersioningModulesrc/versioning/versioning.module.tsGlobal module that exports VersioningService and bootstraps the registry
transformer.tssrc/<module>/transformer.tsPure functions that convert raw data to a versioned response shape

URL Prefix

All routes are served under a simple prefix:

/api/<resource>

The URL does not contain a version number. Versioning is handled entirely by the X-API-Version header.

Why a Centralized Registry?

Controllers in this project depend on SupabaseService, which is Scope.REQUEST. NestJS does not call onModuleInit on request-scoped providers. To avoid this limitation, all transformer registrations are centralized in TransformerRegistry -- a singleton provider whose onModuleInit is guaranteed to run during bootstrap, before any request is handled.

Controllers only call this.versioning.resolve(req, namespace) in their handlers. They never register transformers themselves.


VersioningGuard

A global APP_GUARD registered in app.module.ts. It runs before any controller handler and validates the X-API-Version header:

Header present?
├── No → Allow (no version constraint)
└── Yes → Is it a positive integer?
├── No → 400 "Must be a positive integer"
└── Yes → Is it ≤ max registered version?
├── Yes → Allow
└── No → 400 "Version X does not exist. Latest is Y."

This ensures invalid versions are rejected early with a 400 Bad Request, before the handler or any service logic executes.


VersioningService

The VersioningService is a global injectable that acts as a central registry for all version transformers:

@Injectable()
export class VersioningService {
private readonly registry = new Map<string, Map<number, TransformerFn>>();

register(namespace: string, versions: Record<number, TransformerFn>): void;
registerAll(prefix: string, map: Record<string, Record<number, TransformerFn>>): void;
resolve(req: any, namespace: string): TransformerFn;
getRegisteredNamespaces(): string[];
getVersions(namespace: string): number[];
}
  • register(namespace, versions) -- registers transformer functions under a namespaced key (e.g., 'auth.profile'). Can be called multiple times to add new versions.
  • registerAll(prefix, map) -- bulk registration. Takes a prefix (e.g., 'auth') and a map of response types to version maps. Calls register() for each, prepending the prefix.
  • resolve(req, namespace) -- reads X-API-Version from the request, looks up the namespace, and returns the matching transformer. Defaults to the highest registered version if no header is present.
  • getRegisteredNamespaces() -- lists all registered namespaces (used by the guard for version validation).
  • getVersions(namespace) -- lists all version numbers registered for a namespace.
  • Logs each registration at startup for visibility.

Version Resolution Flow

Header present?
├── Yes → Is version in the namespace map?
│ ├── Yes → Return that version's transformer
│ └── No → 400 "Available versions: [...]"
└── No → Return the latest version's transformer

TransformerRegistry

The TransformerRegistry is a singleton provider in VersioningModule that runs onModuleInit during app bootstrap. It imports all transformer.ts files and calls versioning.registerAll() for each module:

@Injectable()
export class TransformerRegistry implements OnModuleInit {
constructor(private readonly versioning: VersioningService) {}

onModuleInit() {
this.versioning.registerAll('auth', {
profile: { 1: auth.v1Profile },
session: { 1: auth.v1Session },
verifyOtp: { 1: auth.v1VerifyOtp },
message: { 1: auth.v1Message },
});

this.versioning.registerAll('student', {
list: { 1: student.v1StudentList },
detail: { 1: student.v1StudentDetail },
// ...
});

// ... all other modules
}
}

When adding a v2 transformer, you add it here alongside v1:

this.versioning.registerAll('student', {
list: { 1: student.v1StudentList, 2: student.v2StudentList },
detail: { 1: student.v1StudentDetail, 2: student.v2StudentDetail },
});

Registered Modules

All modules have v1 transformers registered:

ModuleNamespaces
authprofile, session, verifyOtp, message
schoollist, detail
studentlist, detail, created, updated, paginated
classlist, detail, created, updated, deleted, teachers, teacherAdded, teacherRemoved, subjects
academicYearlist, detail, created, updated
termlist, detail, created, updated, deleted
subjectlist, detail, created, updated, deleted
enrollmentstudents, studentSubjects, enrolled, bulkEnrolled, unenrolled, subjectsAssigned, bulkSubjectsAssigned, subjectRemoved
gradebyAssessment, byTermSubject, created, bulkGraded, updated, excluded
assessmentlist, detail, created, updated, excluded, deleted
calculationstudentTerm, studentYear, classTerm, classYear, classSummary
reportlist, detail, generated, updated, classSummary, classSummaryFiles, classSummaryUploaded, studentReport, pdfHistory, pdfLatest, pdfSaved, pdfUploaded
reportEntryupdated

Current Implementation (Auth Module)

The auth module is the reference implementation for versioning.

Files

src/auth/
├── auth.controller.ts # Injects VersioningService, calls resolve() in handlers
└── transformer.ts # v1Profile, v1Session, v1VerifyOtp, v1Message

src/auth/transformer.ts

Four transformer functions, one for each response type:

export function v1Profile(raw: any) {
return {
id: raw.id,
email: raw.email,
first_name: raw.first_name ?? null,
last_name: raw.last_name ?? null,
role: raw.role ?? null,
school: raw.school ?? null,
};
}

export function v1Session(raw: any) {
return {
access_token: raw.access_token,
refresh_token: raw.refresh_token,
expires_in: raw.expires_in,
expires_at: raw.expires_at,
};
}

export function v1VerifyOtp(session: any, user: any, profile: any) {
const hasOnboarded = !!(profile?.first_name && profile?.school_id);
return {
session: v1Session(session),
user: {
id: user.id,
email: user.email,
first_name: profile?.first_name ?? null,
last_name: profile?.last_name ?? null,
role: profile?.role ?? null,
school: profile?.school ?? null,
is_onboarded: hasOnboarded,
},
};
}

export function v1Message(message: string) {
return { message };
}

src/auth/auth.controller.ts (versioning parts)

Controllers inject VersioningService and call resolve() in handlers. They do not register transformers -- that's handled by TransformerRegistry:

import { VersioningService } from '@/versioning/versioning.service';

export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly supabaseService: SupabaseService,
private readonly versioning: VersioningService,
) {}

@Get('me')
async me(@Req() req: any) {
const raw = await this.authService.getProfile(req.user.id);
return this.versioning.resolve(req, 'auth.profile')(raw);
}
}

Auth Response Types (v1)

TypeEndpointsShape
profileGET /auth/me, PATCH /auth/onboard, PATCH /auth/profile{ id, email, first_name, last_name, role, school }
sessionPOST /auth/refresh{ access_token, refresh_token, expires_in, expires_at }
verifyOtpPOST /auth/otp/verify{ session: { ... }, user: { id, email, ..., is_onboarded } }
messagePOST /auth/otp/send, DELETE /auth/account, POST /auth/logout{ message }

Implementing Transformers: Full Guide

Scenario 1: Adding a field (non-breaking, additive)

Example: Add avatar_url to the profile response.

Step 1 - Add the v2 transformer in src/auth/transformer.ts:

export function v2Profile(raw: any) {
return {
...v1Profile(raw),
avatar_url: raw.avatar_url ?? null,
};
}

v2 extends v1 by spreading it. v1 clients still get the same shape (no avatar_url), and v2 clients get the extra field.

Step 2 - Register in src/versioning/transformer-registry.ts:

this.versioning.registerAll('auth', {
profile: { 1: auth.v1Profile, 2: auth.v2Profile },
session: { 1: auth.v1Session },
verifyOtp: { 1: auth.v1VerifyOtp },
message: { 1: auth.v1Message },
});

Step 3 - No handler changes needed. this.versioning.resolve() auto-detects the latest version from the registry.

Result:

HeaderResponse
X-API-Version: 1{ id, email, first_name, last_name, role, school }
X-API-Version: 2 or none{ id, email, first_name, last_name, role, school, avatar_url }

Scenario 2: Restructuring the shape (breaking change)

Example: Nest name fields and add timestamps in v3.

Step 1 - Add the v3 transformer:

export function v3Profile(raw: any) {
return {
id: raw.id,
email: raw.email,
name: {
first: raw.first_name ?? null,
last: raw.last_name ?? null,
},
role: raw.role ?? null,
school: raw.school ?? null,
avatar_url: raw.avatar_url ?? null,
created_at: raw.created_at ?? null,
};
}

v3 does not spread v2 because the shape is fundamentally different. It builds from scratch.

Step 2 - Register:

profile: { 1: auth.v1Profile, 2: auth.v2Profile, 3: auth.v3Profile },

Result:

HeaderResponse
X-API-Version: 1{ id, email, first_name, last_name, role, school }
X-API-Version: 2{ ..., avatar_url }
X-API-Version: 3 or none{ id, email, name: { first, last }, role, school, avatar_url, created_at }

All three versions coexist. No existing client breaks.


Scenario 3: Removing a field

Example: Remove role from the profile in v2.

Step 1 - Write v2 from scratch (don't spread v1, since spreading would include role):

export function v2Profile(raw: any) {
return {
id: raw.id,
email: raw.email,
first_name: raw.first_name ?? null,
last_name: raw.last_name ?? null,
school: raw.school ?? null,
};
}

Step 2 - Register and the latest version is auto-detected from the registry.

v1 clients still receive role. v2 clients do not.


Scenario 4: Renaming a field

Example: Rename first_namefirstName in v2.

export function v2Profile(raw: any) {
return {
id: raw.id,
email: raw.email,
firstName: raw.first_name ?? null,
lastName: raw.last_name ?? null,
role: raw.role ?? null,
school: raw.school ?? null,
};
}

v1 returns first_name / last_name. v2 returns firstName / lastName. Same data, different keys.


Scenario 5: Transformers with multiple arguments

Some responses are assembled from multiple sources. The verifyOtp transformer is an example:

export function v1VerifyOtp(session: any, user: any, profile: any) {
return {
session: v1Session(session),
user: { id: user.id, email: user.email, ... },
};
}

The handler passes all arguments through:

return this.versioning.resolve(req, 'auth.verifyOtp')(session, user, profile);

When creating v2, keep the same function signature so the handler doesn't change:

export function v2VerifyOtp(session: any, user: any, profile: any) {
return {
session: v2Session(session),
user: v2Profile(profile),
requires_2fa: user.mfa_enabled ?? false,
};
}

Scenario 6: Different versions for different response types

Response types within a module can be at different version numbers. For example, auth.profile might be on v3 while auth.session is still on v1:

this.versioning.registerAll('auth', {
profile: { 1: auth.v1Profile, 2: auth.v2Profile, 3: auth.v3Profile },
session: { 1: auth.v1Session },
message: { 1: auth.v1Message },
});

A client sending X-API-Version: 3 will get v3Profile when the handler resolves 'auth.profile'. For 'auth.session', since there's no v3 entry, resolve() returns the latest available (v1).


Adding Versioning to a New Module

Full walkthrough using a hypothetical announcement module as an example.

1. Create src/announcement/transformer.ts

Define the v1 shape for each response type the controller returns:

export function v1AnnouncementDetail(raw: any) {
return {
id: raw.id,
title: raw.title,
body: raw.body,
published_at: raw.published_at ?? null,
};
}

export function v1AnnouncementList(data: any[]) {
return data.map(v1AnnouncementDetail);
}

export function v1AnnouncementCreated(raw: any) {
return v1AnnouncementDetail(raw);
}

2. Register in src/versioning/transformer-registry.ts

Import the transformers and add a registerAll call in onModuleInit:

import * as announcement from '@/announcement/transformer';

// inside onModuleInit()
this.versioning.registerAll('announcement', {
list: { 1: announcement.v1AnnouncementList },
detail: { 1: announcement.v1AnnouncementDetail },
created: { 1: announcement.v1AnnouncementCreated },
});

3. Use resolve() in the controller

import { VersioningService } from '@/versioning/versioning.service';

export class AnnouncementController {
constructor(
private readonly announcementService: AnnouncementService,
private readonly versioning: VersioningService,
) {}

@Get()
async findAll(@Req() req: any) {
const raw = await this.announcementService.findAll();
return this.versioning.resolve(req, 'announcement.list')(raw);
}

@Get(':id')
async findOne(@Req() req: any, @Param('id') id: string) {
const raw = await this.announcementService.findOne(id);
return this.versioning.resolve(req, 'announcement.detail')(raw);
}

@Post()
async create(@Req() req: any, @Body() dto: CreateAnnouncementDto) {
const raw = await this.announcementService.create(dto);
return this.versioning.resolve(req, 'announcement.created')(raw);
}
}

4. Later, when adding v2

Add to transformer.ts:

export function v2AnnouncementDetail(raw: any) {
return {
...v1AnnouncementDetail(raw),
author: raw.author_name ?? null,
read_count: raw.read_count ?? 0,
};
}

export function v2AnnouncementList(data: any[]) {
return data.map(v2AnnouncementDetail);
}

Update the registration in transformer-registry.ts:

this.versioning.registerAll('announcement', {
list: { 1: announcement.v1AnnouncementList, 2: announcement.v2AnnouncementList },
detail: { 1: announcement.v1AnnouncementDetail, 2: announcement.v2AnnouncementDetail },
created: { 1: announcement.v1AnnouncementCreated },
});

No handler changes. No route changes. Old clients unaffected.


Testing with curl

Use these commands to test versioning against the running backend (localhost:3001).

Valid requests

# No version header (defaults to latest)
curl -s -X POST http://localhost:3001/api/auth/otp/send \
-H "Content-Type: application/json" \
-d '{"email":"test@test.com"}'

# Explicit version 1
curl -s -X POST http://localhost:3001/api/auth/otp/send \
-H "Content-Type: application/json" \
-H "X-API-Version: 1" \
-d '{"email":"test@test.com"}'

Invalid versions (all return 400)

# Non-existent version
curl -s -X POST http://localhost:3001/api/auth/otp/send \
-H "Content-Type: application/json" \
-H "X-API-Version: 5" \
-d '{"email":"test@test.com"}'
# → {"message":"API version 5 does not exist. Latest version is 1.","error":"Bad Request","statusCode":400}

# String instead of integer
curl -s -H "X-API-Version: abc" http://localhost:3001/api/auth/otp/send \
-H "Content-Type: application/json" \
-d '{"email":"test@test.com"}'
# → {"message":"Invalid API version \"abc\". Must be a positive integer.","error":"Bad Request","statusCode":400}

# Zero
curl -s -H "X-API-Version: 0" http://localhost:3001/api/auth/otp/send \
-H "Content-Type: application/json" \
-d '{"email":"test@test.com"}'
# → {"message":"Invalid API version \"0\". Must be a positive integer.","error":"Bad Request","statusCode":400}

# Negative
curl -s -H "X-API-Version: -1" http://localhost:3001/api/auth/otp/send \
-H "Content-Type: application/json" \
-d '{"email":"test@test.com"}'
# → {"message":"Invalid API version \"-1\". Must be a positive integer.","error":"Bad Request","statusCode":400}

Rules and Conventions

Naming

ConventionExample
Transformer filesrc/<module>/transformer.ts
Function namev<number><ResponseType> - e.g., v1Profile, v2StudentList
Namespace key<module>.<responseType> - e.g., auth.profile, student.list

Guidelines

  1. Never modify an existing transformer. Once a version is released, its shape is frozen. Create a new version instead.
  2. Keep transformers pure. No side effects, no async, no service calls. They receive raw data and return a plain object.
  3. Use ?? null for optional fields. This ensures clients always get a consistent shape (field present but null) rather than undefined (field absent).
  4. Spread the previous version when adding fields. This keeps the diff small and makes it obvious what changed.
  5. Write from scratch when restructuring. If the shape is fundamentally different (renaming, nesting, removing), don't spread -- build the new object explicitly.
  6. Register in TransformerRegistry. All registrations happen in src/versioning/transformer-registry.ts, not in controllers.
  7. Use namespaced keys. Format: <module>.<responseType> (e.g., auth.profile, student.detail).
  8. Latest version is auto-detected. No need to maintain a LATEST_VERSION constant -- the registry derives it from the highest registered version number.

Deprecation

When you want to stop supporting an old version:

  1. Remove the version entry from the registerAll() call in transformer-registry.ts
  2. Clients sending that version will receive a 400 Bad Request from the guard or resolver
  3. Optionally log a warning when an unsupported version is requested (can be added to VersioningService.resolve())

Pagination (Opt-in Feature)

Separately from header-based versioning, list endpoints can support pagination via query parameters. Pagination is not a version change -- it's an opt-in feature available within any API version.

ParameterTypeDefaultDescription
pagenumber-Page number (1-based). Activates offset mode.
pageSizenumber20Items per page (min: 1, max: 100).
cursorstring-Cursor value from a previous nextCursor. Activates cursor mode.
cursorColumnstringidDatabase column to paginate on.
cursorDirectionasc | descascSort direction for the cursor column.

When no pagination params are sent, the endpoint returns its original flat array. When page or cursor is present, the response is wrapped:

{
"data": [ ... ],
"meta": {
"total": 142,
"page": 1,
"pageSize": 20,
"pageCount": 8,
"nextCursor": null,
"hasMore": true
}
}

Pagination Files

FilePurpose
src/pagination/pagination.dto.tsPaginationQueryDto and PaginatedResult<T> types
src/pagination/pagination.service.tsGeneric paginate<T>() supporting offset and cursor modes
src/pagination/pagination.module.tsGlobal module exporting PaginationService

Endpoints with Pagination

EndpointSupported
GET /api/studentsYes

Files Summary

FilePurpose
src/versioning/versioning.service.tsVersioningService - central registry with register(), registerAll(), and resolve()
src/versioning/versioning.guard.tsVersioningGuard - global guard that validates X-API-Version before handlers run
src/versioning/transformer-registry.tsTransformerRegistry - singleton that registers all transformers at startup
src/versioning/versioning.module.tsGlobal module providing VersioningService and TransformerRegistry
src/<module>/transformer.tsPer-module transformer functions (pure, typed)
src/createApp.tsGlobal prefix (/api), CORS config allowing X-API-Version header
src/worker.tsSame prefix and CORS config; forwards all headers through Fastify .inject()

Design Decisions

Why header-based, not URL-based? URL-based versioning (/ vs /v2/) duplicates routes and controllers. Header-based versioning keeps one set of routes and transforms the response at the edge.

Why a centralized registry instead of controller onModuleInit? Controllers depend on SupabaseService, which uses Scope.REQUEST. NestJS does not call lifecycle hooks on request-scoped providers, so onModuleInit in controllers never fires. The TransformerRegistry is a singleton whose onModuleInit is guaranteed to run during bootstrap.

Why a global guard for validation? Without the guard, an invalid version header would only be caught inside resolve() -- which runs after the service call. If the service throws first (e.g., a Supabase error), the client sees a 500 instead of a clear 400 version error. The guard rejects bad versions before any handler logic executes.

Why namespaced keys (auth.profile, student.list)? Prevents collisions between modules that might both have a detail or list response type. The namespace makes ownership clear and keys self-documenting.

Why auto-detect latest from the registry? Eliminates a manual LATEST_VERSION constant that can get out of sync. The highest registered version number is always the default.

Why pure functions for transformers? Easy to test, compose, and reason about. No dependencies on NestJS, Supabase, or runtime state.