Appearance
API framework implementation ​
Status: Reference tutorial — no application code exists yet. All examples are illustrative and reflect the architecture decisions locked in ADRs 0003, 0006, 0008, and 0024.
Source ADRs: 0003 (Clerk auth), 0006 (two-plane role-based access control), 0008 (platform composition), 0024 (cloud portability and provider abstraction).
Overview ​
This tutorial walks through the implementation of the apps/api backend — the single place where all business logic, data access, and authorization decisions are made (ADR 0008). Every surface (web, iOS, Android) reaches the platform exclusively through the typed api-client SDK over this API. No client holds business logic, talks to the database directly, or makes authorization decisions of record.
By the end of this tutorial you will understand:
- how the
apps/apidirectory is structured and why; - how a request flows from the network through auth, role enforcement, business logic, and the database;
- how to implement a complete endpoint using the controller → service → repository pattern;
- how to write and wire the Clerk JWT middleware and the role-based access control (RBAC) guard;
- how to shape error responses consistently;
- how to isolate provider-specific code behind repository and adapter interfaces; and
- how to validate configuration at startup.
Project structure ​
apps/api/
├── src/
│ ├── index.ts # entrypoint — creates app, connects to DB, starts server
│ ├── app.ts # Express/Fastify app factory — registers middleware + routers
│ ├── config/
│ │ └── index.ts # config module — reads and validates env vars at startup
│ ├── middleware/
│ │ ├── requireAuth.ts # Clerk JWT verification; attaches AuthContext to request
│ │ └── requireRole.ts # RBAC guard factory — enforces minimum role per route
│ ├── lib/
│ │ ├── errors.ts # ApiError class and standard error shapes
│ │ ├── response.ts # success / error response helpers
│ │ └── logger.ts # structured logger (wraps observability layer — ADR 0005)
│ ├── db/
│ │ └── client.ts # Prisma client singleton
│ ├── adapters/
│ │ ├── storage/
│ │ │ ├── StorageProvider.ts # interface: upload(), delete(), signedUrl()
│ │ │ └── AzureBlobStorageAdapter.ts # Azure Blob adapter — only file that imports @azure/storage-blob
│ │ └── secrets/
│ │ ├── SecretsProvider.ts # interface: getSecret(name)
│ │ └── AzureKeyVaultAdapter.ts # Azure Key Vault adapter
│ └── features/
│ ├── auth/
│ │ ├── auth.router.ts
│ │ ├── auth.controller.ts
│ │ ├── auth.service.ts
│ │ └── auth.repository.ts
│ ├── members/
│ │ ├── members.router.ts
│ │ ├── members.controller.ts
│ │ ├── members.service.ts
│ │ └── members.repository.ts
│ ├── announcements/ # example feature used throughout this tutorial
│ │ ├── announcements.router.ts
│ │ ├── announcements.controller.ts
│ │ ├── announcements.service.ts
│ │ └── announcements.repository.ts
│ └── ... # one directory per feature
├── Dockerfile
└── package.jsonKey conventions ​
- One feature slice per domain. Each feature lives in
src/features/<feature>/and owns its router, controller, service, and repository. Features do not import from each other — they communicate only through platform services andpackages/shared-types. - Provider SDK imports are banned outside adapter modules. No Azure SDK, no database client, and no secrets client may appear in a router, controller, service, or entity file. The lint rule
import/no-restricted-pathsenforces this boundary (ADR 0024). - Contracts come from
packages/shared-types. Request and response shapes are defined once there and imported by the API, web, and mobile — no inline type definitions for contract-level shapes.
Request lifecycle ​
The following diagram shows how an authenticated, role-guarded request moves through the stack.
The request lifecycle for the Heritage Community Hub API.
A few rules the diagram encodes:
requireAuthruns beforerequireRoleon every protected route. There is no situation where role is checked before identity is established.- The role is read from the database (
Users.role), never from a JWT claim. A Clerk JWT proves identity (sub); the platform database proves role (ADR 0006). - The controller does not contain business logic. It validates the request shape, calls the service, and formats the response. Business rules live in the service.
- The service does not touch the database directly. It calls the repository, which is the only layer that issues SQL.
Implementing an endpoint ​
This section walks through a complete implementation of GET /api/v1/announcements — list announcements visible to the caller. This endpoint is representative: it requires auth, enforces a minimum role, queries the database with caller-scoped filtering, and returns a paginated list.
Step 1: define the route ​
typescript
// src/features/announcements/announcements.router.ts
import { Router } from 'express';
import { requireAuth } from '../../middleware/requireAuth';
import { requireRole } from '../../middleware/requireRole';
import { AnnouncementsController } from './announcements.controller';
const router = Router();
const controller = new AnnouncementsController();
// All announcements routes require an authenticated, approved member.
router.use(requireAuth);
router.get(
'/',
requireRole('member'), // minimum role — visitor cannot access
controller.list.bind(controller)
);
router.post(
'/',
requireRole('comms_author'), // minimum role to create a draft
controller.create.bind(controller)
);
router.post(
'/:announcementId/submit',
requireRole('comms_author'),
controller.submit.bind(controller)
);
router.post(
'/:announcementId/approve',
requireRole('ministry_leader'), // only ministry_leader or admin may approve
controller.approve.bind(controller)
);
export { router as announcementsRouter };Register the router in the app factory:
typescript
// src/app.ts (excerpt)
import { announcementsRouter } from './features/announcements/announcements.router';
app.use('/api/v1/announcements', announcementsRouter);Step 2: implement the controller ​
The controller parses and validates input, delegates to the service, and writes the HTTP response. It contains no business logic.
typescript
// src/features/announcements/announcements.controller.ts
import { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { AnnouncementsService } from './announcements.service';
import { sendOk, sendError } from '../../lib/response';
import { ApiError } from '../../lib/errors';
// Query schema for the list endpoint.
const ListAnnouncementsQuerySchema = z.object({
limit: z.coerce.number().min(1).max(100).default(20),
cursor: z.string().optional(),
priority: z.coerce.number().min(0).max(2).optional(),
});
export class AnnouncementsController {
private service = new AnnouncementsService();
async list(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const query = ListAnnouncementsQuerySchema.safeParse(req.query);
if (!query.success) {
throw ApiError.validation(query.error.flatten().fieldErrors);
}
const result = await this.service.listForCaller({
callerId: req.auth.userId, // attached by requireAuth middleware
callerRole: req.auth.role,
...query.data,
});
sendOk(res, result);
} catch (err) {
next(err);
}
}
async create(req: Request, res: Response, next: NextFunction): Promise<void> {
// ... similar pattern: validate body, call service, send response
next(new ApiError(501, 'not_implemented', 'Create not yet implemented'));
}
async submit(req: Request, res: Response, next: NextFunction): Promise<void> {
next(new ApiError(501, 'not_implemented', 'Submit not yet implemented'));
}
async approve(req: Request, res: Response, next: NextFunction): Promise<void> {
next(new ApiError(501, 'not_implemented', 'Approve not yet implemented'));
}
}Step 3: implement the service ​
The service applies business rules and calls the repository. It has no knowledge of HTTP — it receives plain objects and returns plain objects (or throws ApiError).
typescript
// src/features/announcements/announcements.service.ts
import { RoleSlug, Announcement, PaginatedResult } from '@heritage/shared-types';
import { AnnouncementsRepository } from './announcements.repository';
import { ROLE_HIERARCHY } from '../../lib/roles';
interface ListForCallerOptions {
callerId: string;
callerRole: RoleSlug;
limit: number;
cursor?: string;
priority?: 0 | 1 | 2;
}
export class AnnouncementsService {
private repo = new AnnouncementsRepository();
async listForCaller(opts: ListForCallerOptions): Promise<PaginatedResult<Announcement>> {
// Business rule: audience scoping is enforced here, not in the repository query.
// Members see announcements where audienceRole is null (all members) or matches
// their role, and audienceGroupId is null or matches their family group.
// The repository receives explicit filter arguments — it does not interpret roles.
const callerFamilyGroupId = await this.repo.getFamilyGroupId(opts.callerId);
return this.repo.findVisible({
callerRole: opts.callerRole,
callerFamilyGroupId,
limit: opts.limit,
cursor: opts.cursor,
priority: opts.priority,
});
}
}Step 4: implement the repository ​
The repository is the only layer that executes SQL (via Prisma). It receives typed arguments and returns typed domain objects. No business logic lives here — it translates between the service's intent and the database.
typescript
// src/features/announcements/announcements.repository.ts
import { prisma } from '../../db/client';
import { RoleSlug, Announcement, PaginatedResult } from '@heritage/shared-types';
import { encodeCursor, decodeCursor } from '../../lib/cursor';
interface FindVisibleOptions {
callerRole: RoleSlug;
callerFamilyGroupId: string | null;
limit: number;
cursor?: string;
priority?: 0 | 1 | 2;
}
export class AnnouncementsRepository {
async getFamilyGroupId(userId: string): Promise<string | null> {
const user = await prisma.users.findUnique({
where: { id: userId },
select: { familyGroupId: true },
});
return user?.familyGroupId ?? null;
}
async findVisible(opts: FindVisibleOptions): Promise<PaginatedResult<Announcement>> {
const cursorId = opts.cursor ? decodeCursor(opts.cursor) : undefined;
const rows = await prisma.announcements.findMany({
where: {
status: 'approved',
expiresAt: { gt: new Date() },
OR: [
{ audienceRole: null },
{ audienceRole: opts.callerRole },
],
AND: [
{
OR: [
{ audienceGroupId: null },
{ audienceGroupId: opts.callerFamilyGroupId ?? undefined },
],
},
],
...(opts.priority !== undefined && { priority: opts.priority }),
},
orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }],
take: opts.limit + 1,
...(cursorId && { cursor: { id: cursorId }, skip: 1 }),
});
const hasMore = rows.length > opts.limit;
const items = hasMore ? rows.slice(0, opts.limit) : rows;
return {
data: items as Announcement[],
pagination: {
nextCursor: hasMore ? encodeCursor(items[items.length - 1].id) : null,
limit: opts.limit,
},
};
}
}Authentication middleware ​
requireAuth is the entry point for every protected request. It runs before any controller and is responsible for three things:
- Verify the Clerk JWT signature and expiry.
- Look up the
Usersrow for thesubclaim. - Attach
AuthContextto the request object for downstream use.
typescript
// src/middleware/requireAuth.ts
import { Request, Response, NextFunction } from 'express';
import { clerkClient } from '@clerk/clerk-sdk-node';
import { prisma } from '../db/client';
import { ApiError } from '../lib/errors';
// Extend Express's Request type to carry auth context.
// Place this augmentation in a global .d.ts file (e.g. src/types/express.d.ts).
declare global {
namespace Express {
interface Request {
auth: AuthContext;
}
}
}
export interface AuthContext {
userId: string; // platform Users.id (UUID)
role: string; // Users.role slug
status: string; // Users.status
clerkSub: string; // Clerk sub claim
}
export async function requireAuth(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
throw new ApiError(401, 'unauthenticated', 'Authorization header required');
}
const token = authHeader.slice(7);
// Verify the Clerk JWT. clerkClient.verifyToken() checks the signature
// against Clerk's JWKS endpoint and validates expiry, iss, and aud.
let payload: { sub: string };
try {
payload = await clerkClient.verifyToken(token);
} catch {
throw new ApiError(401, 'unauthenticated', 'Invalid or expired token');
}
// Resolve the Clerk sub to a platform Users row.
// The role is read from the database — never from the JWT claim (ADR 0006).
const user = await prisma.users.findUnique({
where: { clerkSub: payload.sub },
select: { id: true, role: true, status: true },
});
if (!user) {
// The Clerk identity exists but has no platform Users row.
// This should not happen after a successful /auth/session exchange,
// but guard against it defensively.
throw new ApiError(401, 'unauthenticated', 'No platform account for this identity');
}
// Attach to request for downstream middleware and controllers.
req.auth = {
userId: user.id,
role: user.role,
status: user.status,
clerkSub: payload.sub,
};
next();
} catch (err) {
next(err);
}
}Notes on the auth flow ​
clerkClient.verifyToken()validates the signature against Clerk's public JWKS endpoint. The SDK caches the JWKS response; it does not hit the network on every request.- The
clerkSubcolumn inUsersis populated byPOST /api/v1/auth/sessionon first sign-in — that endpoint creates theUsersrow withrole = 'visitor', status = 'pending_approval'. - Child (parent-managed) sub-accounts do not hold a Clerk JWT. They authenticate via
POST /api/v1/auth/child-session, which uses a separate credential path and issues a platform-signed token. The middleware handles the child token path separately from the Clerk path; that implementation follows the same pattern but calls the platform's own token verification logic instead ofclerkClient.
RBAC middleware ​
requireRole is a factory function. It accepts a minimum role slug and returns an Express middleware handler. Attach it per-route after requireAuth.
typescript
// src/middleware/requireRole.ts
import { Request, Response, NextFunction } from 'express';
import { RoleSlug } from '@heritage/shared-types';
import { ApiError } from '../lib/errors';
// Role hierarchy from lowest to highest privilege.
// A role at a higher index implies all permissions of roles below it.
export const ROLE_ORDER: RoleSlug[] = [
'visitor',
'member',
'comms_author', // narrowly scoped to Announcements — no hierarchy above member for other resources
'group_leader',
'ministry_leader',
'admin',
];
// Returns the numeric rank of a role (higher = more privileged).
function rankOf(role: RoleSlug): number {
const index = ROLE_ORDER.indexOf(role);
return index === -1 ? -1 : index;
}
/**
* Require that the authenticated caller holds at least the specified role.
*
* Usage:
* router.get('/admin/audit-log', requireAuth, requireRole('admin'), controller.list)
*/
export function requireRole(minimum: RoleSlug) {
return function roleGuard(
req: Request,
res: Response,
next: NextFunction
): void {
const callerRole = req.auth?.role as RoleSlug;
if (!callerRole) {
next(new ApiError(401, 'unauthenticated', 'Authentication required'));
return;
}
if (rankOf(callerRole) < rankOf(minimum)) {
next(new ApiError(403, 'forbidden', `Requires role '${minimum}' or higher`));
return;
}
next();
};
}The comms_author edge case ​
comms_author is a narrowly scoped role added by ADR 0023. It sits between member and group_leader in the hierarchy table above, but it does not carry general write permissions. The hierarchy is used only for Announcements routes where comms_author is explicitly the minimum. For every other resource, comms_author has no more access than member.
To enforce this, Announcements routes use explicit minimum roles:
requireRole('comms_author')— for draft creation and submission (allowscomms_author,group_leader,ministry_leader,admin).requireRole('ministry_leader')— for approval (blockscomms_authorandgroup_leader).
The comms_author rank in ROLE_ORDER handles the first case. The ministry_leader minimum on approval routes handles the second. No special case logic is needed beyond the hierarchy.
Error handling ​
All error responses conform to the envelope defined in the API contracts reference:
json
{
"error": {
"code": "string",
"message": "string",
"details": {}
}
}The ApiError class ​
typescript
// src/lib/errors.ts
export class ApiError extends Error {
constructor(
public readonly statusCode: number,
public readonly code: string,
message: string,
public readonly details?: unknown
) {
super(message);
this.name = 'ApiError';
}
// Convenience constructors for common cases.
static notFound(message = 'Not found'): ApiError {
return new ApiError(404, 'not_found', message);
}
static forbidden(message = 'Forbidden'): ApiError {
return new ApiError(403, 'forbidden', message);
}
static validation(details: unknown): ApiError {
return new ApiError(400, 'validation_error', 'Request validation failed', details);
}
static conflict(message: string): ApiError {
return new ApiError(409, 'conflict', message);
}
}Global error handler ​
Register this as the last middleware in app.ts:
typescript
// src/app.ts (error handler, added last)
import { Request, Response, NextFunction } from 'express';
import { ApiError } from './lib/errors';
import { logger } from './lib/logger';
app.use((err: unknown, req: Request, res: Response, _next: NextFunction) => {
if (err instanceof ApiError) {
res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
details: err.details ?? undefined,
},
});
return;
}
// Unhandled error — log it and return a generic 500.
logger.error({ err, path: req.path, method: req.method }, 'Unhandled error');
res.status(500).json({
error: {
code: 'server_error',
message: 'An unexpected error occurred',
},
});
});HTTP status code reference ​
| Situation | Status code | Code slug |
|---|---|---|
| Request body or query param fails validation | 400 | validation_error |
| Missing or invalid Bearer token | 401 | unauthenticated |
| Valid identity but insufficient role | 403 | forbidden |
| Resource not found | 404 | not_found |
| Unique constraint violation (duplicate) | 409 | conflict |
| Semantically invalid input (passes schema, fails business rule) | 422 | unprocessable |
| Rate limit exceeded | 429 | rate_limited |
| All other unhandled errors | 500 | server_error |
Repository pattern ​
Repositories exist because of ADR 0024's portability requirement: no provider SDK import may appear in a router, controller, or service. The repository is the translation layer between the service's domain intent and the Prisma ORM (and, through Prisma, the PostgreSQL database on Azure Database for PostgreSQL Flexible Server).
Why Prisma and not raw SQL ​
Prisma abstracts the Postgres dialect so the codebase has no inline SQL strings in service or controller files. If the managed database provider changes (e.g. AWS RDS Postgres, Supabase, self-hosted), the connection string in config is the only thing that changes — no query rewrites are needed because the Postgres dialect is identical across all managed Postgres hosts (ADR 0024 rationale).
Defining a repository interface ​
For platform-shared domain objects (where mocking in tests is important), define an interface before the concrete class:
typescript
// src/features/announcements/announcements.repository.ts
import { RoleSlug, Announcement, PaginatedResult } from '@heritage/shared-types';
export interface IAnnouncementsRepository {
getFamilyGroupId(userId: string): Promise<string | null>;
findVisible(opts: FindVisibleOptions): Promise<PaginatedResult<Announcement>>;
findById(id: string): Promise<Announcement | null>;
create(data: CreateAnnouncementData): Promise<Announcement>;
updateStatus(id: string, status: string, actorId: string): Promise<Announcement>;
}The concrete AnnouncementsRepository class (shown in the endpoint implementation section above) implements this interface. In tests, pass a fake or partial implementation of IAnnouncementsRepository into the service constructor — no database connection required.
Cursor-based pagination ​
All list repositories follow the same cursor pattern. The cursor is an opaque base64 string encoding the last-seen row ID. Callers must not construct or inspect it.
typescript
// src/lib/cursor.ts
export function encodeCursor(id: string): string {
return Buffer.from(id).toString('base64url');
}
export function decodeCursor(cursor: string): string {
return Buffer.from(cursor, 'base64url').toString('utf8');
}Environment configuration ​
Configuration is read from environment variables and validated at startup. If a required variable is missing or malformed, the process exits with a clear error before serving any requests. This prevents subtle runtime failures from misconfigured deployments.
typescript
// src/config/index.ts
import { z } from 'zod';
const ConfigSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(), // postgres:// connection string
CLERK_SECRET_KEY: z.string().min(1), // Clerk secret key from Key Vault
CLERK_PUBLISHABLE_KEY: z.string().min(1),
AZURE_STORAGE_ACCOUNT_URL: z.string().url().optional(), // optional in local dev with emulator
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
function loadConfig() {
const result = ConfigSchema.safeParse(process.env);
if (!result.success) {
console.error('Configuration error — missing or invalid environment variables:');
console.error(result.error.flatten().fieldErrors);
process.exit(1);
}
return result.data;
}
export const config = loadConfig();config is imported directly in modules that need it. Do not pass config values as constructor arguments throughout the call stack — that creates unnecessary coupling. Modules that need a config value import config at the top of the file.
Secrets and Key Vault ​
In production, secrets (DATABASE_URL, CLERK_SECRET_KEY, etc.) are sourced from Azure Key Vault via the SecretsProvider adapter (ADR 0024). The Container Apps runtime injects them as environment variables using Key Vault references in the Container Apps environment configuration — the application code does not call Key Vault at startup. The SecretsProvider interface exists for features that need to read secrets dynamically at runtime (e.g. rotating tokens), not for startup config.
In local development, a .env file (not committed) provides the same variables. The .env.example file at the repo root documents all required and optional variables with placeholder values.
Related docs and ADRs ​
| Document | Path | What it covers |
|---|---|---|
| ADR 0003 | docs/internal/adr/0003-authentication-social-login.md | Clerk as auth provider; Apple/Google social login; child credential path |
| ADR 0006 | docs/internal/adr/0006-two-plane-rbac.md | Two-plane RBAC; six canonical roles; server-side enforcement requirement |
| ADR 0008 | docs/internal/adr/0008-platform-composition.md | API-first platform; no business logic in clients; feature slice structure |
| ADR 0024 | docs/internal/adr/0024-cloud-portability-provider-abstraction.md | Docker container; Postgres; provider abstraction interfaces; restricted SDK imports |
| API contracts | docs/internal/design/api-contracts.md | Full endpoint reference with request/response shapes for all implemented domains |
| Monorepo structure | docs/internal/adr/0001-monorepo-three-layer-structure.md | pnpm workspaces + Turborepo; apps/, packages/, infrastructure/ layout |
Last updated: 2026-06-18