170 lines
4.5 KiB
Markdown
170 lines
4.5 KiB
Markdown
|
|
# 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<InvoiceList> {
|
||
|
|
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<InvoiceList> {
|
||
|
|
// 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<Invoice> {
|
||
|
|
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<InvoiceList> {
|
||
|
|
// 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)
|