Skip to content

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.


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)
Database
graph 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:

ClassLayerResponsibility
BaseControllerOperationsControllerHTTP routing, input extraction, response delegation
BaseServiceOperationsServiceBusiness logic, DB operations, error normalization
TypeOrmQueryBuilderData AccessSafe, 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.

RuleWhy
✅ Extend BaseControllerOperationsInherits 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 methodTypeScript safety throughout the call chain
❌ Never catch exceptionsLet AllExceptionsFilter handle them consistently
❌ Never put business logic hereIf it’s not HTTP routing, it belongs in the service
❌ Never add @ApiBearerAuth()Auth is configured globally in bootstrap.util.ts

BaseControllerOperations provides default implementations for all CRUD operations. Override only when you need custom behavior:

MethodOverride when…
createNeed to inject route params (e.g., parentId) into the DTO
findPaginatedNeed to force a filter from a route param (e.g., visitId)
findOneNeed a custom projection or enriched response
updateNeed business rule validation before update
softDeleteNeed cascade logic or additional permission checks
apps/data-owner-bc/src/modules/resource/controllers/resources.controller.ts
@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);
}
}

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}`),
});
}
}

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.

RuleWhy
✅ Extend BaseServiceOperationsInherits CRUD, error handling, logging
✅ Wrap every DB call in this.executeDbOperation()Ensures consistent, typed error responses
✅ Use dataSource.transaction() for multi-table writesGuarantees atomicity
✅ Throw specific NestJS exceptionsNotFoundException, ConflictException, etc.
✅ Define protected readonly allowedRelationsWhitelists which relations clients can eagerly load
❌ Never swallow exceptions with silent try/catchMasked failures are worse than noisy ones
❌ Never return null from service methodsThrow NotFoundException instead
@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);
});
}
}

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);
});
}

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);
});
}

This wrapper automatically converts low-level database errors into HTTP-safe exceptions:

PostgreSQL CodeMeaningException ThrownHTTP Status
23505Unique constraint violationConflictException409
23503Foreign key violationBadRequestException400
23502Not-null violationBadRequestException400
22P02Invalid UUID formatBadRequestException400
Optimistic lockConcurrent modificationConflictException409
OtherUnhandled query errorInternalServerErrorException500
libs/common/src/utils/base-operations/base-service-operations.util.ts
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 inside dataSource.transaction() blocks
  • TypeOrmQueryBuilder — used internally by BaseServiceOperations to safely translate URL query parameters into TypeORM FindManyOptions

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.


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 database parameter here must match the value in the entity’s @Entity({ database: AppDatabases.APP_CORE }) decorator. Mismatches are caught at startup, not at runtime.


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

  1. ControllerOrdersController.findPaginated() receives the request. @ValidatedQuery() parses and validates URL parameters into a typed QueryParamsDTO object.

  2. Delegation — Controller calls super.findPaginated(query)this.service.findPaginated(query).

  3. ServiceOrdersService.findPaginated() wraps in this.executeDbOperation().

  4. Query BuildingTypeOrmQueryBuilder.build() runs:

    • Validates status, created_at, id, reference against Order entity 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 } }
  5. Executionrepository.findAndCount(options) runs:

    SELECT id, reference, status
    FROM orders
    WHERE status = 'pending'
    AND is_deleted = false
    ORDER BY created_at DESC
    LIMIT 10
  6. Error Handling — Any database error is caught by executeDbOperation and returned as a typed HTTP exception.

  7. Response — Service returns { data: [...], pagination: {...} }. The TransformInterceptor formats 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.