Clean Architecture
Clean Architecture is about enforcing a strict separation of concerns — each layer has one well-defined responsibility and must not leak logic into another. In this blueprint, every bounded context follows this layering to guarantee:
- Consistency: Every module behaves predictably — the same patterns everywhere
- Testability: Each layer can be tested in isolation without depending on the others
- Maintainability: Business rules live in one place — the service — not scattered across controllers or query handlers
- Security: Input validation and query safety are enforced at architectural boundaries, not ad-hoc
This is made practical through three abstract base classes that every module extends — so compliance is the path of least resistance, not an afterthought.
The Architecture at a Glance
Section titled “The Architecture at a Glance”Client Request ↓Controller ← HTTP routing, DTO validation, auth decorators ↓Service ← Business logic, DB operations, error handling ↓TypeORM Repository ← Database access (SQL only — no logic here) ↓Databasegraph TD
A[Client Request] --> B(Controller);
subgraph "Controller Layer"
B --> |Extends| C[BaseControllerOperations];
end
C --> |Delegates to| D(Service);
subgraph "Service Layer"
D --> |Extends| E[BaseServiceOperations];
end
E --> |Uses| F[TypeOrmQueryBuilder];
subgraph "Data Access Layer"
F --> |Builds query for| G[TypeORM Repository];
end
G --> |Interacts with| H[(Database)];
The three pillars are:
| Class | Layer | Responsibility |
|---|---|---|
BaseControllerOperations | Controller | HTTP routing, input extraction, response delegation |
BaseServiceOperations | Service | Business logic, DB operations, error normalization |
TypeOrmQueryBuilder | Data Access | Safe, validated URL-to-SQL query translation |
Layer 1: Controller (BaseControllerOperations)
Section titled “Layer 1: Controller (BaseControllerOperations)”Responsibility: Handle HTTP. Delegate everything else.
The controller’s job is to be as thin as possible. It understands HTTP semantics — routes, status codes, guards, decorators — but it contains zero business logic. It provides default CRUD implementations (create, findPaginated, findOne, update, softDelete) that can be selectively overridden.
| Rule | Why |
|---|---|
✅ Extend BaseControllerOperations | Inherits CRUD, error handling, and formatting |
✅ Add @ResourceType('plural-kebab-name') | Required for JSON:API response formatting |
✅ Every endpoint: @RequirePermission('resource:action') | Authorization enforced at the boundary |
✅ Use @ValidatedQuery() not @Query() | Throws 400002 on invalid query params |
| ✅ Explicit return types on every method | TypeScript safety throughout the call chain |
| ❌ Never catch exceptions | Let AllExceptionsFilter handle them consistently |
| ❌ Never put business logic here | If it’s not HTTP routing, it belongs in the service |
❌ Never add @ApiBearerAuth() | Auth is configured globally in bootstrap.util.ts |
When to Override Inherited Methods
Section titled “When to Override Inherited Methods”BaseControllerOperations provides default implementations for all CRUD operations. Override only when you need custom behavior:
| Method | Override when… |
|---|---|
create | Need to inject route params (e.g., parentId) into the DTO |
findPaginated | Need to force a filter from a route param (e.g., visitId) |
findOne | Need a custom projection or enriched response |
update | Need business rule validation before update |
softDelete | Need cascade logic or additional permission checks |
Standard Controller Example
Section titled “Standard Controller Example”@ResourceType('resources')@ApiTags('Resources')@Controller('resources')export class ResourcesController extends BaseControllerOperations< Resource, CreateResourceDTO, UpdateResourceDTO, ResourcesService> { constructor(private readonly resourcesService: ResourcesService) { super(resourcesService); }
@Post() @RequirePermission('resource:create') @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: 'Create a new resource' }) @ApiBody({ type: CreateResourceDTO }) @ApiJsonApiCreatedResponse('resources', ResourceResponseDTO) create(@Body() data: CreateResourceDTO, @CurrentUser() currentUser: IUserSession) { return super.create(data, currentUser); }
@Get() @RequirePermission('resource:view') @ApiOperation({ summary: 'List resources with pagination and filters' }) @ApiQuery({ type: QueryParamsDTO, required: false }) @ApiJsonApiCollectionResponse('resources', HttpStatus.OK, ResourceResponseDTO) findPaginated(@ValidatedQuery(QueryParamsDTO) query: QueryParamsDTO) { return super.findPaginated(query); }
@Get(':id') @RequirePermission('resource:view') @ApiOperation({ summary: 'Get a single resource by ID' }) @ApiParam({ name: 'id', type: 'string' }) @ApiJsonApiResponse('resources', 200, ResourceResponseDTO) findOne(@Param('id') id: string) { return super.findOne(id); }
@Put(':id') @RequirePermission('resource:update') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Update a resource' }) @ApiBody({ type: UpdateResourceDTO }) @ApiJsonApiResponse('resources', HttpStatus.OK, ResourceResponseDTO) update( @Param('id') id: string, @Body() dto: UpdateResourceDTO, @CurrentUser() currentUser: IUserSession, ) { return super.update(id, dto, currentUser); }
@Delete(':id') @RequirePermission('resource:delete') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a resource' }) softDelete(@Param('id') id: string, @CurrentUser() currentUser: IUserSession) { return super.softDelete(id, currentUser); }}Nested Resource Controller
Section titled “Nested Resource Controller”For routes like GET /orders/:orderId/items, override create and findPaginated to inject the parent route parameter:
@ResourceType('order-items')@ApiTags('Order Items')@Controller('orders/:orderId/items')export class OrderItemsController extends BaseControllerOperations< OrderItem, CreateOrderItemDTO, UpdateOrderItemDTO, OrderItemsService> { constructor(private readonly orderItemsService: OrderItemsService) { super(orderItemsService); }
@Post() @RequirePermission('order-item:create') @HttpCode(HttpStatus.CREATED) create( @Param('orderId') orderId: string, @Body() dto: CreateOrderItemDTO, @CurrentUser() currentUser: IUserSession, ) { // Inject the route param into the DTO before delegating return this.orderItemsService.create({ ...dto, order_id: orderId }, currentUser); }
@Get() @RequirePermission('order-item:view') @ApiQuery({ type: QueryParamsDTO, required: false }) findPaginated( @Param('orderId') orderId: string, @ValidatedQuery({ dto: QueryParamsDTO }) query: QueryParamsDTO, ) { // Force-inject the orderId filter — clients cannot override this return super.findPaginated({ ...query, filter: ([] as string[]) .concat(query.filter ?? []) .concat(`order_id||$eq||${orderId}`), }); }}Layer 2: Service (BaseServiceOperations)
Section titled “Layer 2: Service (BaseServiceOperations)”Responsibility: Own all business logic and database operations.
This is the core of every module. It provides default implementations of all CRUD operations and wraps every database call in executeDbOperation() — a try/catch wrapper that translates TypeORM and PostgreSQL-specific errors into normalized NestJS HttpExceptions.
| Rule | Why |
|---|---|
✅ Extend BaseServiceOperations | Inherits CRUD, error handling, logging |
✅ Wrap every DB call in this.executeDbOperation() | Ensures consistent, typed error responses |
✅ Use dataSource.transaction() for multi-table writes | Guarantees atomicity |
| ✅ Throw specific NestJS exceptions | NotFoundException, ConflictException, etc. |
✅ Define protected readonly allowedRelations | Whitelists which relations clients can eagerly load |
❌ Never swallow exceptions with silent try/catch | Masked failures are worse than noisy ones |
❌ Never return null from service methods | Throw NotFoundException instead |
Basic Service Example
Section titled “Basic Service Example”@Injectable()export class OrderItemsService extends BaseServiceOperations< OrderItem, CreateOrderItemDTO, UpdateOrderItemDTO> { protected readonly allowedRelations = ['order', 'product'];
constructor( logger: LogsService, private readonly configService: ConfigService, @InjectRepository(OrderItem, AppDatabases.APP_CORE) private readonly itemRepository: Repository<OrderItem>, ) { super(itemRepository, { logging: { logger, serviceName: configService.get('SERVICE_NAME'), serviceVersion: configService.get('SERVICE_VERSION'), }, }); }
async createItem(dto: CreateOrderItemDTO, user: IUserSession): Promise<OrderItem> { return this.executeDbOperation(async () => { // Business rule: enforce quantity limits const existingItems = await this.itemRepository.count({ where: { order_id: dto.order_id, is_deleted: false }, });
if (existingItems >= 50) { throw new ConflictException('Order cannot have more than 50 line items.'); }
const saved = await this.itemRepository.save({ ...dto, created_by: user.id, updated_by: user.id, });
return this.findById(saved.id); }); }}Transaction Pattern
Section titled “Transaction Pattern”Use dataSource.transaction() when multiple table writes must succeed or fail together:
async createWithRelations(dto: CreateDTO, user: IUserSession): Promise<Entity> { return this.executeDbOperation(async () => { const result = await this.dataSource.transaction(async (em: EntityManager) => { const parent = await em.save(ParentEntity, { ...dto, created_by: user.id, });
await em.save(ChildEntity, { parent_id: parent.id, ...dto.childData, created_by: user.id, });
return parent; });
// Always return the fresh, fully-loaded record return this.findById(result.id); });}Multi-Step Transaction Example
Section titled “Multi-Step Transaction Example”A real-world scenario — creating a parent record with several related entities atomically:
async createOrder(dto: CreateOrderDTO, user: IUserSession): Promise<Order> { return this.executeDbOperation(async () => { const order = await this.dataSource.transaction(async (em: EntityManager) => { // Step 1: Create the order const savedOrder = await em.save(Order, { ...dto, created_by: user.id, updated_by: user.id, });
// Step 2: Create line items await this.saveLineItemsInTransaction( em, dto.line_items, savedOrder.id, user.id, );
// Step 3: Create the billing record await em.save(BillingRecord, { order_id: savedOrder.id, total_amount: dto.total_amount, created_by: user.id, });
return savedOrder; }); // If ANY step throws, the entire transaction rolls back
return this.findById(order.id); });}executeDbOperation Error Translation
Section titled “executeDbOperation Error Translation”This wrapper automatically converts low-level database errors into HTTP-safe exceptions:
| PostgreSQL Code | Meaning | Exception Thrown | HTTP Status |
|---|---|---|---|
23505 | Unique constraint violation | ConflictException | 409 |
23503 | Foreign key violation | BadRequestException | 400 |
23502 | Not-null violation | BadRequestException | 400 |
22P02 | Invalid UUID format | BadRequestException | 400 |
| Optimistic lock | Concurrent modification | ConflictException | 409 |
| Other | Unhandled query error | InternalServerErrorException | 500 |
protected async executeDbOperation<T>(operation: () => Promise<T>): Promise<T> { try { return await operation(); } catch (error) { if (error instanceof BadRequestException) throw error;
if (error instanceof EntityPropertyNotFoundError) { throw new BadRequestException(error.message); }
if (error instanceof OptimisticLockVersionMismatchError) { throw new ConflictException( `The record was modified by another user. Please refresh and try again.`, ); }
if (error instanceof QueryFailedError) { switch (error.driverError?.code) { case '23505': throw new ConflictException( `A record with the provided details already exists. Detail: ${error.driverError.detail}`, ); case '23503': throw new BadRequestException( `Invalid reference to a related record. Detail: ${error.driverError.detail}`, ); case '23502': throw new BadRequestException( `A required field was left empty. Detail: ${error.driverError.detail}`, ); case '22P02': throw new BadRequestException( `Invalid format for an ID or enum field. Detail: ${error.driverError.detail}`, ); default: throw new InternalServerErrorException( `An unexpected database error occurred.`, error.message, ); } } throw error; }}The key insight: the caller never needs to write a try/catch. Errors either bubble up as formatted HTTP exceptions or crash loud enough to be noticed immediately.
Layer 3: Data Access (TypeOrmQueryBuilder)
Section titled “Layer 3: Data Access (TypeOrmQueryBuilder)”Responsibility: Execute SQL. No business logic here.
Repository<Entity>— for standard CRUD queries (find,save,delete)EntityManager— required insidedataSource.transaction()blocksTypeOrmQueryBuilder— used internally byBaseServiceOperationsto safely translate URL query parameters into TypeORMFindManyOptions
Why TypeOrmQueryBuilder Matters for Security
Section titled “Why TypeOrmQueryBuilder Matters for Security”The query builder validates all field names in sort, s, filter, and fields against the entity’s actual schema before executing any query. A client sending ?sort=__proto__:asc or ?filter=nonexistent_field||$eq||1 gets a 400 Bad Request immediately — not a 500 database error, and not a query that executes against an unexpected column.
See Base Operations Architecture for the full query parameter reference — all filtering, sorting, pagination, and relation-loading capabilities.
Module Registration
Section titled “Module Registration”Every module must register its entity with the correct database connection:
@Module({ imports: [ CommonModule, DatabaseModule.registerAsync(AppDatabases.APP_CORE), TypeOrmModule.forFeature([OrderItem], AppDatabases.APP_CORE), ], controllers: [OrderItemsController], providers: [OrderItemsService],})export class OrderItemModule {}The
databaseparameter here must match the value in the entity’s@Entity({ database: AppDatabases.APP_CORE })decorator. Mismatches are caught at startup, not at runtime.
Full Request-to-Database Trace
Section titled “Full Request-to-Database Trace”Let’s trace a real request through all three layers:
Request: GET /api/v1/orders?limit=10&fields=id,reference,status&filter=status||$eq||pending&sort=created_at:desc
-
Controller —
OrdersController.findPaginated()receives the request.@ValidatedQuery()parses and validates URL parameters into a typedQueryParamsDTOobject. -
Delegation — Controller calls
super.findPaginated(query)→this.service.findPaginated(query). -
Service —
OrdersService.findPaginated()wraps inthis.executeDbOperation(). -
Query Building —
TypeOrmQueryBuilder.build()runs:- Validates
status,created_at,id,referenceagainstOrderentity schema - Translates
filter=status||$eq||pending→{ where: { status: 'pending' } } - Translates
sort=created_at:desc→{ order: { created_at: 'DESC' } } - Translates
fields=id,reference,status→{ select: { id: true, reference: true, status: true } }
- Validates
-
Execution —
repository.findAndCount(options)runs:SELECT id, reference, statusFROM ordersWHERE status = 'pending'AND is_deleted = falseORDER BY created_at DESCLIMIT 10 -
Error Handling — Any database error is caught by
executeDbOperationand returned as a typed HTTP exception. -
Response — Service returns
{ data: [...], pagination: {...} }. TheTransformInterceptorformats it into the JSON:API envelope and sends it to the client.
The result: a fully validated, paginated, type-safe, SQL-injection-resistant API endpoint — with a controller that is roughly 10 lines of code.
Related Documentation
Section titled “Related Documentation”- Base Operations Architecture — Full query parameter reference: filtering, sorting, pagination, field selection
- Entity & DTO Principle — Entity and DTO rules
- API Response & Error Handling — Exception hierarchy and response format