# ADR-006: Thin Controllers **Date**: 2025-01-15 **Status**: Accepted ## Context NestJS controllers can contain varying amounts of logic: - **Fat controllers**: Business logic, validation, transformation all in controller - **Thin controllers**: HTTP handling only, delegate everything else ## Decision **Controllers handle HTTP concerns only**. All business logic lives in services. ```typescript // ✅ GOOD: Thin controller @Get() async getInvoices( @Request() req: RequestWithUser, @Query() query: InvoiceListQueryDto ): Promise { return this.invoiceService.getInvoices(req.user.id, query); } // ❌ BAD: Fat controller with business logic @Get() async getInvoices(@Request() req, @Query() query) { const clientId = await this.mappings.getClientId(req.user.id); const raw = await this.whmcs.getInvoices({ clientId }); const filtered = raw.filter(inv => inv.status !== 'draft'); return filtered.map(inv => transformInvoice(inv)); } ``` ## Rationale ### Why Thin Controllers? 1. **Single responsibility**: Controllers handle HTTP (request parsing, response formatting, status codes). Services handle business logic. 2. **Testability**: Business logic in services can be unit tested without HTTP mocking. 3. **Reusability**: Service methods can be called from: - Multiple controllers - Background jobs - Event handlers - CLI commands 4. **Consistency**: Developers know controllers are just HTTP glue. ### Controller Responsibilities **DO in controllers:** - Route definition (`@Get()`, `@Post()`, etc.) - Request parsing (`@Body()`, `@Query()`, `@Param()`) - Authentication extraction (`@Request() req`) - HTTP status codes (`@HttpCode()`) - OpenAPI documentation (`@ZodResponse()`) - Call ONE service method **DON'T in controllers:** - Business logic - Data transformation - External API calls - Database queries - Multiple service orchestration - Error handling beyond HTTP concerns ### Alternatives Considered | Approach | Pros | Cons | | -------------------- | ----------------------- | ------------------------ | | **Fat controllers** | Everything in one place | Untestable, not reusable | | **Thin controllers** | Testable, reusable, SRP | More files | ## Consequences ### Positive - Clear separation of concerns - Testable business logic - Reusable service methods - Consistent patterns ### Negative - More files (controller + service) - Developers might create pass-through services ## Implementation ### Controller Structure ```typescript // apps/bff/src/modules/billing/billing.controller.ts @Controller("invoices") export class BillingController { constructor(private readonly invoiceService: InvoiceRetrievalService) {} @Get() @ZodResponse({ description: "List invoices", type: InvoiceListDto }) async getInvoices( @Request() req: RequestWithUser, @Query() query: InvoiceListQueryDto ): Promise { // One service call - that's it return this.invoiceService.getInvoices(req.user.id, query); } @Get(":id") @ZodResponse({ description: "Get invoice", type: InvoiceDto }) async getInvoice(@Request() req: RequestWithUser, @Param("id") id: string): Promise { return this.invoiceService.getInvoice(req.user.id, parseInt(id)); } } ``` ### Service Structure ```typescript // apps/bff/src/modules/billing/services/invoice-retrieval.service.ts @Injectable() export class InvoiceRetrievalService { constructor( private readonly whmcsInvoice: WhmcsInvoiceService, private readonly mappings: MappingsService ) {} async getInvoices(userId: string, query: InvoiceQuery): Promise { // Business logic here const clientId = await this.mappings.getWhmcsClientId(userId); const invoices = await this.whmcsInvoice.getInvoices(clientId, { status: query.status, limit: query.limit, }); return { items: invoices, total: invoices.length, }; } } ``` ### What NOT to Do ```typescript // ❌ DON'T: Business logic in controller @Get() async getInvoices(@Request() req, @Query() query) { const clientId = await this.mappings.getClientId(req.user.id); const raw = await this.whmcs.api.getInvoices({ clientId }); // Filtering, transformation, validation - all wrong here return raw.invoices .filter(inv => query.status ? inv.status === query.status : true) .map(inv => transformWhmcsInvoice(inv)); } ``` ## Related - [BFF Integration Patterns](../development/bff/integration-patterns.md) - [ADR-005: Feature Module Pattern](./005-feature-module-pattern.md)