Use when creating new backend features for GitPaaS. Guides through Clean Architecture layer creation with domain models, DTOs, repository interfaces, gateway interfaces, use cases, orchestrators, Prisma/Docker infrastructure, controllers, routes, and validation.
Step-by-step instructions for creating new backend features following the established Clean Architecture patterns in the GitPaaS monorepo.
.github/instructions/backend-architecture.instructions.md)Every backend feature lives in apps/backend/src/features/{feature-name}/ and follows a strict 4-layer Clean Architecture:
features/{feature-name}/
├── domain/ ← Pure types, no dependencies
│ ├── models/ ← Entity interfaces
│ ├── dtos/ ← Data transfer objects
│ ├── repositories/ ← Repository/Gateway interface contracts
│ └── constants/ ← Feature-specific constants (optional)
├── application/ ← Business logic, depends only on domain
│ ├── use-cases/ ← Single-responsibility operations
│ └── orchestrators/ ← Coordinates multiple use cases
├── infrastructure/ ← Concrete implementations
│ ├── database/ ← Prisma repository + mapper (for DB features)
│ └── docker/ ← Docker API gateway + mapper (for Docker features)
└── ui/ ← HTTP layer
├── controllers/ ← Express request handlers
├── routes/ ← Express router definitions
└── validators/ ← Joi validation schemas
Dependency flow: Domain ← Application ← Infrastructure ← UI
Create the entity model as a TypeScript interface. Models are pure data structures with no behavior.
File: domain/models/{entity}.models.ts
/**
* {Entity} model
*/
export interface {Entity} {
id: string;
name: string;
// ... domain properties
}
Conventions:
string for IDs (even if numeric in external systems){Entity}With{Relation}, {Entity}With{Relation}And{OtherRelation}status: 'todo' | 'in_progress' | 'done'null (not undefined) for optional values in modelsExample with relations:
export interface Issue {
id: string;
number: string;
title: string;
body: string | null;
url: string;
repositoryId: string;
status: 'todo' | 'in_progress' | 'done';
priority: 'low' | 'medium' | 'high';
labels: Array<{ name: string; color: string }>;
assignee: { name: string; avatarUrl: string } | null;
}
export interface IssueWithRepository extends Issue {
repository: Repository;
}
export interface IssueWithRepositoryAndOrganization extends Issue {
repository: RepositoryWithOrganization;
}
Create DTOs for each distinct operation (create, update, upsert). DTOs carry data between layers.
File: domain/dtos/{operation}-{entity}.dto.ts
Naming convention: {Operation}{Entity}Dto
/**
* Upsert {entity} DTO
*/
export interface Upsert{Entity}Dto {
id: string;
name: string;
// ... only fields needed for the operation
}
Common DTO types per feature:
Create{Entity}Dto — For creation operationsUpdate{Entity}Dto — For partial updates{Entity}Dto — For external API operations (Docker, GitHub)Example:
/**
* Create network DTO
*/
export interface CreateNetworkDto {
name: string;
}
Define contracts for database persistence operations (for features like projects).
File: domain/repositories/{entity}.repository.ts
import { Create{Entity}Dto } from '../dtos/create-{entity}.dto';
import { {Entity} } from '../models/{entity}.models';
/**
* {Entity} repository
*/
export interface {Entity}Repository {
/**
* Get all {entities}
*
* @returns List of {entities}
*/
getAll: () => Promise<{Entity}[]>;
/**
* Create a {entity}
*
* @param createDto {Entity} create data
*/
create: (createDto: Create{Entity}Dto) => Promise<{Entity}>;
}
Conventions:
Promise<T> — everything is async{Entity}Repository (e.g., ProjectRepository){entity}.repository.tsDefine contracts for external API operations (for features like networks with Docker API). Note: GitPaaS follows the convention of naming these as repositories even when they're actually gateways.
File: domain/repositories/{entity}.gateway.ts
import { Create{Entity}Dto } from '../dtos/create-{entity}.dto';
import { {Entity} } from '../models/{entity}.models';
/**
* {Entity} gateway
*/
export interface {Entity}Gateway {
/**
* Get all {entities}
*
* @returns List of {entities}
*/
getAllNetworks: () => Promise<{Entity}[]>;
/**
* Create a {entity}
*
* @param createDto {Entity} create data
*/
createNetwork: (createDto: Create{Entity}Dto) => Promise<{Entity}>;
}
Conventions:
Promise<T> — everything is async{Entity}Gateway (e.g., NetworkGateway)domain/repositories/ (follows project convention){entity}.gateway.ts
**Conventions:**
- Always use JSDoc on the interface and each method
- Methods return `Promise<T>` — everything is async
- Use DTOs for write parameters, domain models for return types
- Interface names: `{Entity}GithubGateway` (e.g., `IssuesGithubGateway`, `RepositoriesGithubGateway`, `OrganizationsGithubGateway`)
- One file per entity: `{entity}-github.gateway.ts`
---
### Step 4: Domain layer — Constants (optional)
For feature-specific configuration values.
**File:** `domain/constants/{entity}.const.ts`
```typescript
/**
* Excluded organization name for sync
*/
export const EXCLUDED_ORGANIZATION_NAME = 'my-org';
/**
* Standard labels for issues
*/
export const STANDARD_LABELS = [
{ name: 'priority/low', color: '0e8a16' },
{ name: 'priority/medium', color: 'fbca04' },
// ...
];
Each use case is a single pure function with one responsibility. Repository interfaces are injected via function parameters.
File: application/use-cases/{action}-{entity}.use-case.ts
Naming patterns:
get-all-{entities}.use-case.ts — Persistence read operationsget-{entity}-by-id.use-case.ts — Persistence read by IDget-{entities}-by-{field}.use-case.ts — Persistence filtered readcreate-{entity}.use-case.ts — Persistence createupdate-{entity}.use-case.ts — Persistence updateget-{entities}-by-{field}-from-github.use-case.ts — GitHub read operationscreate-github-{entity}.use-case.ts — GitHub createupdate-github-{entity}.use-case.ts — GitHub updatechange-github-{entity}-status.use-case.ts — GitHub status changeUse case for persistence (repository):
import { {Entity} } from '../../domain/models/{entity}.models';
import { {Entity}Repository } from '../../domain/repositories/{entity}.repository';
/**
* Get all {entities} use case.
*
* @param repository {Entity} repository
*
* @returns {Entity} list
*/
export async function getAll{Entities}UseCase(
repository: {Entity}Repository,
): Promise<{Entity}[]> {
return repository.getAll();
}
Use case for external API (gateway):
import { {Entity}Gateway } from '../../domain/repositories/{entity}.gateway';
import { {Entity} } from '../../domain/models/{entity}.models';
/**
* Get all {entities} from external API use case.
*
* @param gateway {Entity} gateway
*
* @returns {Entity} list
*/
export async function getAll{Entities}UseCase(
gateway: {Entity}Gateway,
): Promise<{Entity}[]> {
return gateway.getAllNetworks();
}
Use case for creation with DTO mapping:
import { Create{Entity}Dto } from '../../domain/dtos/create-{entity}.dto';
import { {Entity} } from '../../domain/models/{entity}.models';
import { {Entity}Repository } from '../../domain/repositories/{entity}.repository';
/**
* Create {entity} use case.
*
* @param repository {Entity} repository
* @param createDto {Entity} creation data
*/
export async function create{Entity}UseCase(
repository: {Entity}Repository,
createDto: Create{Entity}Dto,
): Promise<{Entity}> {
return repository.create(createDto);
}
Conventions:
Orchestrators coordinate multiple use cases for complex workflows. They are the main entry point called by controllers.
File: application/orchestrators/{action}.orchestrator.ts
import { {Entity}GithubGateway } from '../../domain/gateways/{entity}-github.gateway';
import { {Entity}Repository } from '../../domain/repositories/{entity}.repository';
import { create{Entity}UseCase } from '../use-cases/create-{entity}.use-case';
import { getAll{Entities}FromGithubUseCase } from '../use-cases/get-all-{entities}-from-github.use-case';
/**
* Synchronize {entities} orchestrator
*
* @param gateway GitHub {entity} gateway
* @param repository {Entity} repository
*/
export async function sync{Entities}Orchestrator(
gateway: {Entity}GithubGateway,
repository: {Entity}Repository,
): Promise<void> {
// Get from GitHub
const githubEntities = await getAll{Entities}FromGithubUseCase(gateway);
// Persist all
const createPromises = githubEntities.map((entity) =>
create{Entity}UseCase(repository, entity),
);
await Promise.all(createPromises);
}
Simple orchestrator for database feature (delegation only):
export async function get{Entities}Orchestrator(
repository: {Entity}Repository,
): Promise<{Entity}[]> {
return getAll{Entities}UseCase(repository);
}
Conventions:
Promise.all for parallel independent operationsget, create, update, deletegateway parameter name for external API interfaces, repository for database interfacesBidirectional mapper between Prisma types and domain models (for features like projects).
File: infrastructure/database/{entity}-prisma.mapper.ts
import { Create{Entity}Dto } from '../../domain/dtos/create-{entity}.dto';
import { {Entity} } from '../../domain/models/{entity}.models';
import { {Entity} as Prisma{Entity} } from '@core/infrastructure/prisma/client';
/**
* {Entity} Prisma data mapper
*/
export const {entity}PrismaMapper = {
toDomain: (prisma{Entity}: Prisma{Entity}): {Entity} => ({
id: prisma{Entity}.id,
name: prisma{Entity}.name,
// ... map all fields from Prisma to domain
}),
toPersistenceCreate: (createDto: Create{Entity}Dto): Prisma.{Entity}CreateInput => ({
id: createDto.id,
name: createDto.name,
// ... map all fields from DTO to Prisma create input
}),
};
Bidirectional mapper between Docker API responses and domain models (for features like networks).
File: infrastructure/docker/{entity}-docker.mapper.ts
import { Create{Entity}Dto } from '../../domain/dtos/create-{entity}.dto';
import { {Entity} } from '../../domain/models/{entity}.models';
/**
* {Entity} Docker mapper
*/
export const {entity}DockerMapper = {
/**
* Maps Docker API response to domain model
*
* @param dockerNetwork Docker API response
*
* @returns {Entity} domain model
*/
toDomain: (dockerResponse: any): {Entity} => ({
id: dockerResponse.Id,
name: dockerResponse.Name,
// ... map fields from Docker API to domain
}),
/**
* Maps domain DTO to Docker API options
*
* @param createDto Domain create DTO
*
* @returns Docker API options
*/
toDockerCreateOptions: (createDto: Create{Entity}Dto): any => ({
Name: createDto.name,
// ... map DTO fields to Docker API format
}),
};
Concrete implementation of the repository interface for database persistence.
File: infrastructure/database/{entity}-prisma.repository.ts
import { Create{Entity}Dto } from '../../domain/dtos/create-{entity}.dto';
import { {Entity} } from '../../domain/models/{entity}.models';
import { {Entity}Repository } from '../../domain/repositories/{entity}.repository';
import { {entity}PrismaMapper } from './{entity}-prisma.mapper';
import { DatabaseError, DatabaseErrorType } from '@core/domain/errors/database.error';
import { PrismaClient } from '@core/infrastructure/prisma/generated/client';
import { prismaClient } from '@core/infrastructure/prisma/prisma.client';
/**
* {Entity} Prisma repository
*/
export const {entity}PrismaRepository: {Entity}Repository = {
getAll: async (): Promise<{Entity}[]> => {
try {
const prisma = prismaClient.getInstance() as PrismaClient;
const entities = await prisma.{entity}.findMany({
orderBy: { createdAt: 'desc' },
});
return entities.map({entity}PrismaMapper.toDomain);
} catch (error: unknown) {
throw new DatabaseError(
`Failed to retrieve {entities} from database: ${(error as Error).message}`,
DatabaseErrorType.DATABASE_CONNECTION_ERROR,
);
}
},
create: async (createDto: Create{Entity}Dto): Promise<{Entity}> => {
try {
const prisma = prismaClient.getInstance() as PrismaClient;
const created{Entity} = await prisma.{entity}.create({
data: {entity}PrismaMapper.toPersistenceCreate(createDto),
});
return {entity}PrismaMapper.toDomain(created{Entity});
} catch (error: unknown) {
throw new DatabaseError(
`Failed to create {entity}: ${(error as Error).message}`,
DatabaseErrorType.DATABASE_CONNECTION_ERROR,
);
}
},
};
Concrete implementation of the gateway interface for Docker API communication.
File: infrastructure/docker/{entity}-docker.gateway.ts
import { Create{Entity}Dto } from '../../domain/dtos/create-{entity}.dto';
import { {Entity} } from '../../domain/models/{entity}.models';
import { {Entity}Gateway } from '../../domain/gateways/{entity}.gateway';
import { {entity}DockerMapper } from './{entity}-docker.mapper';
import { DockerError, DockerErrorType } from '@core/domain/errors/docker.error';
import { dockerClient } from '@core/infrastructure/docker/docker.client';
/**
* {Entity} Docker gateway
*/
export const {entity}DockerGateway: {Entity}Gateway = {
getAllNetworks: async (): Promise<{Entity}[]> => {
try {
const networks = await dockerClient.listNetworks();
return networks.map({entity}DockerMapper.toDomain);
} catch (error) {
throw new DockerError(
`Failed to get networks: ${(error as Error).message}`,
DockerErrorType.API_ERROR
);
}
},
createNetwork: async (createDto: Create{Entity}Dto): Promise<{Entity}> => {
try {
const createOptions = {entity}DockerMapper.toDockerCreateOptions(createDto);
const result = await dockerClient.createNetwork(createOptions);
const network = dockerClient.getNetwork(result.Id);
const networkInfo = await network.inspect();
return {entity}DockerMapper.toDomain(networkInfo);
} catch (error) {
throw new DockerError(
`Failed to create network: ${(error as Error).message}`,
DockerErrorType.API_ERROR
);
}
},
};
Conventions for database features (Prisma repositories):
prismaClient.getInstance() as PrismaClientDatabaseError with descriptive message and DatabaseErrorTypeinclude: { relation: true } for reading entities with relationsconnect: { id } for writing relation references{entity}PrismaRepositoryConventions for external API features (Docker gateways):
@core/infrastructure/docker/docker.clientDockerError){entity}DockerGatewayJoi schemas for request validation.
File: ui/validation/{entity}.validation.ts
import Joi from 'joi';
/**
* Schema for validating {entity} ID parameter
*/
export const {entity}IdParamsSchema = Joi.object({
{entity}Id: Joi.string().required().messages({
'any.required': '{Entity} ID is required',
}),
});
/**
* Schema for creating a {entity}
*/
export const create{Entity}Schema = Joi.object({
title: Joi.string().required().max(256).messages({
'any.required': 'Title is required',
'string.max': 'Title must not exceed 256 characters',
}),
body: Joi.string().optional().allow('').max(65535).messages({
'string.max': 'Body must not exceed 65535 characters',
}),
// ... field validations with custom messages
});
Conventions:
{entity}IdParamsSchema, create{Entity}Schema, update{Entity}Schema.messages() for user-friendly error messagesvalidateInput(schema, 'body' | 'params' | 'query') middleware from coreThin async Express handlers. They wire together concrete infrastructure implementations and orchestrators.
File: ui/controllers/{action}.controller.ts (one file per action)
import { RequestHandler } from 'express';
import { StatusCodes } from 'http-status-codes';
import { get{Entities}Orchestrator } from '../../application/orchestrators/get-{entities}.orchestrator';
import { {entity}PrismaRepository } from '../../infrastructure/database/{entity}-prisma.repository';
import { appLogger } from '@core/infrastructure/loggers/winston.logger';
import { handleError } from '@core/ui/handlers/error.handler';
/**
* Get {entities} controller
*
* @param req Request
* @param res Response
*/
export const get{Entities}Controller: RequestHandler = async (_req, res) => {
try {
const result = await get{Entities}Orchestrator({entity}PrismaRepository);
res.status(StatusCodes.OK).send(result);
} catch (error) {
appLogger.error(
{ message: `Error: ${(error as Error).message}` },
'Get {entities} controller',
);
handleError(error as Error, res);
}
};
Controller with GitHub gateway (e.g., create, sync):
import { create{Entity}Orchestrator } from '../../application/orchestrators/create-{entity}.orchestrator';
import { {entity}PrismaRepository } from '../../infrastructure/database/{entity}-prisma.repository';
import { {entity}OctokitGateway } from '../../infrastructure/octokit/{entity}-octokit.gateway';
export const create{Entity}Controller: RequestHandler = async (req, res) => {
try {
const result = await create{Entity}Orchestrator(
{entity}OctokitGateway,
{entity}PrismaRepository,
req.body,
);
res.status(StatusCodes.CREATED).send(result);
} catch (error) {
appLogger.error(
{ message: `Error: ${(error as Error).message}` },
'Create {entity} controller',
);
handleError(error as Error, res);
}
};
Conventions:
RequestHandler from Expresstry/catch with appLogger.error + handleError_req when request is unused, req when accessing params/body/queryStatusCodes enum from http-status-codes (never raw numbers)StatusCodes.OK for reads, StatusCodes.CREATED for successful createsError: ${(error as Error).message}'Get {entities} controller'Register routes with middleware chain.
File: ui/routes/{entity}.routes.ts
import { Router } from 'express';
import { get{Entities}Controller } from '../controllers/get-{entities}.controller';
import { create{Entity}Controller } from '../controllers/create-{entity}.controller';
import { sync{Entities}Controller } from '../controllers/sync-{entities}.controller';
import { authenticationMiddleware } from '@features/authentication/ui/middlewares/authentication.middleware';
import { validateInput } from '@core/ui/middlewares/validation.middleware';
import { create{Entity}Schema } from '../validation/{entity}.validation';
import { {entity}IdParamsSchema } from '../validation/{entity}.validation';
const {entity}Router = Router();
// GET endpoints
{entity}Router.get('/', authenticationMiddleware, get{Entities}Controller);
{entity}Router.get('/sync', authenticationMiddleware, sync{Entities}Controller);
{entity}Router.get('/:entityId', authenticationMiddleware, validateInput({entity}IdParamsSchema, 'params'), getEntityByIdController);
// POST endpoints
{entity}Router.post('/', authenticationMiddleware, validateInput(create{Entity}Schema, 'body'), create{Entity}Controller);
// PATCH endpoints
{entity}Router.patch('/:entityId', authenticationMiddleware, validateInput({entity}IdParamsSchema, 'params'), validateInput(update{Entity}Schema, 'body'), update{Entity}Controller);
export { {entity}Router };
Conventions:
Router() instance, named as {entity}RouterauthenticationMiddlewarevalidateInput(schema, source) for routes with params/body/queryauth → validateParams → validateBody → controller{ {entity}Router }index.ts)Add the router to the main Express app.
File: apps/backend/src/index.ts
// Add import
import { {entity}Router } from '@features/{feature-name}/ui/routes/{entity}.routes';
// Add route mounting (after existing routes)
app.use(`/${expressConfig.apiVersion}/{entities}`, {entity}Router);
Convention: Route prefix is /${apiVersion}/{feature-name-plural} (e.g., /v1/organizations, /v1/repositories, /v1/issues, /v1/settings).
The backend uses TypeScript path aliases configured in tsconfig.json:
| Alias | Path |
|---|---|
@core/* | src/core/* |
@features/* | src/features/* |
Usage examples:
import { appLogger } from '@core/infrastructure/loggers/winston.logger';
import { handleError } from '@core/ui/handlers/error.handler';
import { DatabaseError, DatabaseErrorType } from '@core/domain/errors/database.error';
import { PrismaClient } from '@core/infrastructure/prisma/generated/client';
import { prismaClient } from '@core/infrastructure/prisma/prisma.client';
import { getOctokitInstance } from '@core/infrastructure/octokit/client.octokit';
import { validateInput } from '@core/ui/middlewares/validation.middleware';
import { authenticationMiddleware } from '@features/authentication/ui/middlewares/authentication.middleware';
Use the appropriate domain error in infrastructure implementations:
| Error Class | Import | HTTP Status | When to Use |
|---|---|---|---|
AuthenticationError | @core/domain/errors/authentication.error | 401 | Invalid/missing auth token |
AuthorizationError | @core/domain/errors/authorization.error | 403 | Insufficient permissions |
BadRequestError | @core/domain/errors/bad-request.error | 400 | Invalid input data |
NotFoundError | @core/domain/errors/not-found.error | 404 | Entity not found |
ConflictError | @core/domain/errors/conflict.error | 409 | Duplicate/conflict |
DatabaseError | @core/domain/errors/database.error | varies | DB operations failed |
GitHubError | @core/domain/errors/github.error | varies | GitHub API failed |
DockerError | @core/domain/errors/docker.error | varies | Docker API failed |
ConfigurationError | @core/domain/errors/configuration.error | 500 | Missing env vars |
TimeoutError | @core/domain/errors/timeout.error | 408/504 | Operation timed out |
Features can import use cases from other features when needed:
// In projects orchestrator — importing from other features
import { getAllProjectsUseCase } from
'@features/projects/application/use-cases/get-all-projects.use-case';
Allowed cross-feature imports:
application/use-cases/domain/models/domain/repositories/domain/gateways/infrastructure/database/ (only in controllers for DI)infrastructure/docker/ (only in controllers for DI)Not allowed:
Not all features need external API integration. For database-only features (like projects):
domain/gateways/ directory entirelyinfrastructure/docker/ directory entirely{Entity}Repository in domain (no gateway interface)For database features (like projects):
iac/database/schema.prismanpx prisma migrate devdomain/models/domain/dtos/domain/repositories/domain/constants/application/use-cases/application/orchestrators/infrastructure/database/infrastructure/database/ui/validation/ (if needed)ui/controllers/ (one per endpoint)ui/routes/apps/backend/src/index.tsFor external API features (like networks):
domain/models/domain/dtos/domain/gateways/domain/constants/application/use-cases/application/orchestrators/infrastructure/docker/infrastructure/docker/ui/validation/ (if needed)ui/controllers/ (one per endpoint)ui/routes/apps/backend/src/index.ts| Mistake | Fix |
|---|---|
| Importing Prisma types directly in domain layer | Domain must be pure — only use domain interfaces/models |
| Using raw HTTP status codes (200, 404) | Use StatusCodes enum from http-status-codes |
| Catching errors without re-throwing as domain errors | Always wrap in DatabaseError / DockerError in infrastructure |
Missing authenticationMiddleware on routes | Every feature route MUST include auth middleware |
| Calling orchestrators from other orchestrators | Compose at orchestrator level using use cases instead |
Forgetting to register router in index.ts | Add app.use() mount after creating the router |
Using undefined instead of null for optional model fields | Models use null, DTOs can use ?: optional syntax |
| Not mapping between layers | Always use mappers — never pass Prisma/Docker API types to domain |
| Confusing repositories and gateways | Repositories are for persistence (DB), gateways are for external APIs (Docker) |
Using External{Entity}Repository naming | Use {Entity}Gateway in domain/gateways/ instead |