Guides the implementation of new features in the api project following its Clean Architecture pattern. Use when adding a new endpoint, use case, entity, repository, gateway, or any feature to this project. Covers folder structure, layering rules, dependency injection, framework-agnostic conventions, and OpenAPI generation with Fastify.
This project follows Clean Architecture with a strict dependency rule: inner layers never depend on outer layers. The only framework-dependent code lives in src/main.
src/
├── index.ts # API server entrypoint
├── worker.ts # Queue worker entrypoint
├── application/ # Business logic (framework-agnostic)
├── infra/ # Infrastructure implementations
├── kernel/ # DI container and decorators
├── main/ # HTTP server bootstrap (framework-specific)
└── shared/ # Cross-cutting utilities and types
src/application/Pure business logic. No framework imports. No infrastructure imports.
| Folder | Purpose |
|---|---|
contracts/ |
| Abstract base classes and interfaces consumed across layers |
controllers/ | HTTP controllers grouped by domain; each extends Controller base class |
controllers/<domain>/schemas/ | Zod schemas for request body validation |
entities/ | Domain entities with business rules and value objects |
errors/application/ | Domain errors extending ApplicationError |
errors/http/ | HTTP-level errors (e.g. HttpError, BadRequest) |
events/ | Event handlers for async integration events (e.g. file uploaded) |
query/ | Read-only query objects for complex cross-entity reads |
queues/ | Queue message consumers |
services/ | Stateless domain services with logic that doesn't belong to an entity |
usecases/ | Business use cases grouped by domain |
Controllers extend the abstract Controller<TType, TResponse> base class from contracts/Controller.ts. They are framework-agnostic — they receive a normalised Controller.Request and return a Controller.Response with statusCode and body.
application/controllers/<domain>/
├── create-<domain>-controller.ts
├── get-<domain>-by-id-controller.ts
└── schemas/
└── create-<domain>-schema.ts
Repository interfaces live in application/contracts/. Method-specific input types are declared in a namespace on the same file to keep parameter shapes co-located with the interface.
// application/contracts/project-repository.ts
export interface ProjectRepository {
create(project: Project): Promise<Project>;
findById(id: string): Promise<Project | null>;
findAll(): Promise<Project[]>;
updateStatus(input: ProjectRepository.UpdateStatusInput): Promise<void>;
}
export namespace ProjectRepository {
export type UpdateStatusInput = {
id: string;
status: Project.Status;
errorMessage?: string | null;
};
}
Use cases are plain classes decorated with @Injectable(). They receive dependencies via constructor injection and expose an execute(input): Promise<output> method. Input/Output types are declared in a namespace on the same file.
application/usecases/<domain>/
└── create-<domain>-usecase.ts
Entities are plain TypeScript classes. They hold identity, invariants, and enumerations. The constructor always receives an Attributes object. Enums and the Attributes type are declared inside a namespace on the same file.
application/entities/
└── <domain>.ts
Pattern:
export class Project {
readonly id: string;
readonly title: string;
readonly status: Project.Status;
readonly formatSize: Project.FormatSize;
readonly createdAt?: Date;
readonly updatedAt?: Date;
constructor(attributes: Project.Attributes) {
this.id = attributes.id;
this.title = attributes.title;
this.status = attributes.status ?? Project.Status.PROCESSING;
this.formatSize = attributes.formatSize ?? Project.FormatSize.VERTICAL;
this.createdAt = attributes.createdAt ?? new Date();
this.updatedAt = attributes.updatedAt;
}
}
export namespace Project {
export enum Status {
PROCESSING = 'PROCESSING',
COMPLETED = 'COMPLETED',
CANCELLED = 'CANCELLED',
}
export enum FormatSize {
VERTICAL = 'VERTICAL',
HORIZONTAL = 'HORIZONTAL',
}
export enum PipelineStep {
PdfExtraction = 'PDF_EXTRACTION',
VisionAnalysis = 'VISION_ANALYSIS',
ScriptGen = 'SCRIPT_GEN',
Tts = 'TTS',
Render = 'RENDER',
}
export type Attributes = {
id: string;
title: string;
status?: Status;
formatSize?: FormatSize;
createdAt?: Date;
updatedAt?: Date;
};
}
src/infra/Concrete implementations of contracts defined in application/. Can import infrastructure SDKs (AWS SDK, Drizzle, etc.).
| Folder | Purpose |
|---|---|
ai/gateways/ | AI provider integrations |
ai/prompts/ | Prompt templates |
clients/ | Construction of raw HTTP / SDK clients (factories or thin wrappers) — not decorated with @Injectable() |
database/drizzle/repositories/ | Drizzle repository implementations (implement contracts, decorated with @Injectable()) |
database/drizzle/items/ | Drizzle item mappers — pure functions <domain>FromDrizzle(row) converting a DB record to a domain entity |
database/drizzle/schema.ts | Drizzle table definitions (pgTable, pgEnum, etc.) |
database/drizzle/migrations/ | SQL migrations |
database/drizzle/uow/ | Unit of Work for drizzle transactions |
gateways/ | External service gateways (storage, queues, auth, etc.) |
infra/clients/ — SDK and HTTP clientsPut low-level client wiring here: functions that return configured SDK instances (e.g. S3Client, fetch wrappers with base URL and headers). Gateways and repositories call these factories instead of instantiating SDKs inline.
r2-s3-client.ts, openai-http-client.ts.StorageGateway.Config, or plain options derived from env in the caller).infra/gateways/ and infra/database/... implementations use @Injectable() and participate in the Registry.Example:
infra/clients/
└── r2-s3-client.ts # export function createR2S3Client(config): S3Client
// infra/clients/r2-s3-client.ts
import { S3Client } from '@aws-sdk/client-s3';
import type { StorageGateway } from '@application/contracts/storage-gateway';
export function createR2S3Client(config: StorageGateway.Config): S3Client {
const endpoint =
config.endpoint ?? `https://${config.accountId}.r2.cloudflarestorage.com`;
const useCustomEndpoint = Boolean(config.endpoint);
return new S3Client({
region: 'auto',
endpoint,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
...(useCustomEndpoint ? { forcePathStyle: true as const } : {}),
});
}
Gateways then import createR2S3Client and focus on commands, uploads, and error mapping.
src/kernel/Internal DI framework. Do not add business logic here.
| Folder | Purpose |
|---|---|
di/registry.ts | Singleton IoC container — registers and resolves dependencies |
decorators/ | @Injectable() marks a class for DI; @Schema(zod) attaches a Zod schema to a controller |
Every class that participates in DI must be decorated with @Injectable().
src/main/The only framework-dependent layer. Swap this layer to migrate frameworks.
| Folder | Purpose |
|---|---|
adapters/ | Framework adapters that translate HTTP/event input into Controller.Request and call controller.execute() |
functions/ | Entry point files per route/event — each file imports an adapter and a controller |
server.ts | Fastify server bootstrap, plugin registration, and OpenAPI generation |
utils/ | Helpers for the current adapter (body parsing, error response formatting, etc.) |
When migrating frameworks, only adapters/ and functions/ change. Controllers, use cases, entities, and infra remain untouched.
The backend has two independent entrypoints that share the same application/, infra/, kernel/, and shared/ layers:
| Entrypoint | File | Purpose | Dev command |
|---|---|---|---|
| API server | src/index.ts | Fastify HTTP server, routes, OpenAPI export | pnpm dev |
| Queue worker | src/worker.ts | BullMQ worker consuming the video-generation queue | pnpm dev:worker |
pnpm backend:dev:all (from repo root)worker.ts following the same patternsrc/shared/No business logic. No framework imports.
| Folder | Purpose |
|---|---|
config/ | App-wide environment config (read from process.env) |
saga/ | Saga orchestration utilities for complex multi-step flows |
types/ | Shared TypeScript utility types (e.g. Constructor<T>) |
utils/ | Pure utility functions reused across layers |
main → application ← infra
↑
kernel (DI)
↑
shared
application imports from shared and kernel onlyinfra imports from application (contracts/entities) and sharedmain imports from application, kernel, and infrashared imports nothing from the project@fastify/swaggerThis project generates the OpenAPI 3.1 specification automatically from the Fastify route schemas registered at runtime, using @fastify/swagger and fastify-type-provider-zod. The spec is not authored manually — it is derived from the Zod 4 schemas passed to each route’s schema option (no zod-to-json-schema at call sites; the type provider handles JSON Schema for Swagger).
Backend dependencies: fastify 5.x, zod ^4.1.5, fastify-type-provider-zod ^6.x, openapi-types, @fastify/swagger ^9.5.1+.
@Schema(zodSchema) on the controller class).application/controllers/<domain>/schemas/ and referenced in main/functions/<domain>/ when registering routes.buildServer calls setValidatorCompiler(validatorCompiler) and setSerializerCompiler(serializerCompiler), uses .withTypeProvider<ZodTypeProvider>(), and registers @fastify/swagger with transform: createJsonSchemaTransform({ zodToJsonConfig: { target: 'draft-2020-12' } }) so OpenAPI 3.1 matches Zod output.@fastify/swagger reads all registered route schemas and assembles the OpenAPI spec automatically.pnpm openapi:export in apps/backend) builds the app and dumps the generated spec to docs/openapi.json.docs/openapi.json is committed to the repo and serves as the source of truth for client code generation when configured.main/server.ts)import Fastify from 'fastify';
import fastifySwagger from '@fastify/swagger';
import {
createJsonSchemaTransform,
serializerCompiler,
type ZodTypeProvider,
validatorCompiler,
} from 'fastify-type-provider-zod';
export async function buildServer() {
const app = Fastify({ logger: true }).withTypeProvider<ZodTypeProvider>();
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
await app.register(fastifySwagger, {
openapi: {
openapi: '3.1.0',
info: { title: 'Heroic Vision API', version: '1.0.0' },
tags: [
{ name: 'Projects', description: 'Project management' },
{ name: 'Queue', description: 'Processing queue' },
{ name: 'Dashboard', description: 'Dashboard metrics' },
],
},
transform: createJsonSchemaTransform({
zodToJsonConfig: { target: 'draft-2020-12' },
}),
});
// Scalar UI, CORS, multipart, routes, etc.
return app;
}
scripts/export-openapi.ts)import { buildServer } from '../src/main/server';
import { writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
async function main() {
const app = await buildServer();
await app.ready();
const spec = app.swagger();
const outPath = resolve(__dirname, '../../packages/contracts/openapi.json');
writeFileSync(outPath, JSON.stringify(spec, null, 2));
console.log(`OpenAPI spec exported to ${outPath}`);
await app.close();
}
main();
Add to package.json in apps/backend:
{
"scripts": {
"openapi:export": "tsx scripts/export-openapi.ts"
}
}
main/functions/)Route modules take an AppInstance (FastifyInstance + ZodTypeProvider — see main/types/fastify-app.ts) and pass Zod schemas directly to Fastify’s schema option:
// main/functions/projects/create-project.ts
import { CreateProjectController } from '@application/controllers/projects/create-project-controller';
import {
createProjectRequestBodySchema,
createProjectResponseSchema,
} from '@application/controllers/projects/schemas/create-project-schema';
import { errorResponseSchema } from '@application/controllers/shared/error-response-schema';
import { fastifyMultipartAdapter } from '@main/adapters/fastify-multipart-adapter';
import type { AppInstance } from '@main/types/fastify-app';
export async function createProjectRoute(app: AppInstance) {
app.post('/projects', {
schema: {
tags: ['Projects'],
summary: 'Create project with PDF upload',
consumes: ['multipart/form-data'],
body: createProjectRequestBodySchema,
response: {
201: createProjectResponseSchema,
400: errorResponseSchema,
500: errorResponseSchema,
},
},
handler: fastifyMultipartAdapter(CreateProjectController),
});
}
application/controllers/<domain>/schemas/)Define only Zod schemas (no parallel *JsonSchema exports). Example for multipart create (after @fastify/multipart with attachFieldsToBody: 'keyValues'):
// application/controllers/projects/schemas/create-project-schema.ts
import { z } from 'zod';
import { projectWrappedSchema } from './shared/project-schema';
export const createProjectRequestBodySchema = z.object({
file: z.instanceof(Buffer).describe('Comic book PDF file'),
title: z.string().min(1),
startPage: z.coerce.number().int().min(1),
endPage: z.coerce.number().int().min(1),
creativeBrief: z.string().optional(),
});
export const createProjectResponseSchema = projectWrappedSchema;
After pnpm openapi:export, the spec lands in packages/contracts/openapi.json. The packages/api-client Kubb config reads it from there:
// packages/api-client/kubb.config.ts
export default defineConfig({
input: {
path: '../contracts/openapi.json', // written by openapi:export, not hand-written
},
// ...
});
1. Developer adds/modifies a Zod schema under application/controllers/.../schemas/
2. Route is registered in main/functions/ with the updated Zod schema
3. Run: pnpm --filter @hq-to-video/backend openapi:export
4. packages/contracts/openapi.json is updated
5. Run: pnpm --filter @hq-to-video/api-client generate
6. Commit packages/contracts/openapi.json (and any regenerated client output)
When adding a new domain or endpoint, follow this order:
application/entities/<domain>.tsapplication/errors/application/<error>.tsapplication/usecases/<domain>/<action>-usecase.tsapplication/contracts/<domain>-repository.tsapplication/controllers/<domain>/schemas/<action>-schema.tsapplication/controllers/<domain>/<action>-controller.tsinfra/clients/ (SDK factories), infra/database/drizzle/repositories/, infra/gateways/, infra/ai/gateways/, etc.main/functions/<domain>/<action>.ts (registers route + schema in Fastify)pnpm --filter @hq-to-video/backend openapi:export (writes to packages/contracts/openapi.json)pnpm --filter @hq-to-video/api-client generate (reads packages/contracts/openapi.json)POST /workoutsapplication/entities/workout.ts
export class Workout {
readonly id: string;
readonly accountId: string;
readonly name: string;
readonly status: Workout.Status;
readonly createdAt?: Date;
constructor(attributes: Workout.Attributes) {
this.id = attributes.id;
this.accountId = attributes.accountId;
this.name = attributes.name;
this.status = attributes.status ?? Workout.Status.ACTIVE;
this.createdAt = attributes.createdAt ?? new Date();
}
}
export namespace Workout {
export enum Status {
ACTIVE = 'ACTIVE',
COMPLETED = 'COMPLETED',
}
export type Attributes = {
id: string;
accountId: string;
name: string;
status?: Status;
createdAt?: Date;
};
}
application/usecases/workouts/create-workout-usecase.ts
@Injectable()
export class CreateWorkoutUsecase {
constructor(private readonly workoutRepository: WorkoutRepository) {}
async execute(
input: CreateWorkoutUsecase.Input,
): Promise<CreateWorkoutUsecase.Output> {
const workout = new Workout({ accountId: input.accountId });
await this.workoutRepository.create(workout);
return { workoutId: workout.id };
}
}
export namespace CreateWorkoutUsecase {
export type Input = { accountId: string };
export type Output = { workoutId: string };
}
application/controllers/workouts/create-workout-controller.ts
@Injectable()
@Schema(createWorkoutSchema)
export class CreateWorkoutController extends Controller<
'private',
CreateWorkoutController.Response
> {
constructor(private readonly createWorkoutUsecase: CreateWorkoutUsecase) {
super();
}
protected override async handle({
accountId,
body,
}: Controller.Request<'private', CreateWorkoutBody>) {
const { workoutId } = await this.createWorkoutUsecase.execute({
accountId,
});
return { statusCode: 201, body: { workoutId } };
}
}
main/functions/workouts/create-workout.ts
import { CreateWorkoutController } from '@application/controllers/workouts/create-workout-controller';
import { createWorkoutSchema } from '@application/controllers/workouts/schemas/create-workout-schema';
import { workoutResponseSchema } from '@application/controllers/workouts/schemas/workout-response-schema';
import { fastifyHttpAdapter } from '@main/adapters/fastify-http-adapter';
import type { AppInstance } from '@main/types/fastify-app';
export async function createWorkoutRoute(app: AppInstance) {
app.post('/workouts', {
schema: {
tags: ['Workouts'],
summary: 'Create a new workout',
body: createWorkoutSchema,
response: { 201: workoutResponseSchema },
},
handler: fastifyHttpAdapter(CreateWorkoutController),
});
}
Use Controller<'public'> — accountId will be typed as null.
@Injectable()
export class GetHealthController extends Controller<
'public',
{ status: string }
> {
protected override async handle(_request: Controller.Request<'public'>) {
return { statusCode: 200, body: { status: 'ok' } };
}
}
main/adapters/fastify-http-adapter.ts)Translates Fastify's Request/Reply into Controller.Request and calls controller.execute(). No other layer changes when swapping frameworks.
export function fastifyHttpAdapter(
controllerImpl: Constructor<Controller<any, unknown>>,
) {
return async (request: FastifyRequest, reply: FastifyReply) => {
const controller = Registry.getInstance().resolve(controllerImpl);
const response = await controller.execute({
body: request.body,
params: request.params as Record<string, string>,
queryParams: request.query as Record<string, string>,
accountId: request.user?.internalId ?? null,
});
reply.status(response.statusCode).send(response.body);
};
}