513 lines
12 KiB
Markdown
513 lines
12 KiB
Markdown
Use Case Architecture Guide
|
||
|
||
This document defines the correct structure and responsibilities of Application Use Cases
|
||
according to Clean Architecture, in a NestJS-based system.
|
||
|
||
The goal is:
|
||
• strict separation of concerns
|
||
• correct terminology (no fake "ports")
|
||
• minimal abstractions
|
||
• long-term consistency
|
||
|
||
This is the canonical reference for all use cases in this codebase.
|
||
|
||
~
|
||
|
||
1. Core Concepts (Authoritative Definitions)
|
||
|
||
Use Case
|
||
• Encapsulates application-level business logic
|
||
• Is the Input Port
|
||
• Is injected via DI
|
||
• Knows no API, no DTOs, no transport
|
||
• Coordinates domain objects and infrastructure
|
||
|
||
The public execute() method is the input port.
|
||
|
||
~
|
||
|
||
Input
|
||
• Pure data
|
||
• Not a port
|
||
• Not an interface
|
||
• May be omitted if the use case has no parameters
|
||
|
||
type GetSponsorsInput = {
|
||
leagueId: LeagueId
|
||
}
|
||
|
||
|
||
~
|
||
|
||
Result
|
||
• The business outcome of a use case
|
||
• May contain Entities and Value Objects
|
||
• Not a DTO
|
||
• Never leaves the core directly
|
||
|
||
type GetSponsorsResult = {
|
||
sponsors: Sponsor[]
|
||
}
|
||
|
||
|
||
~
|
||
|
||
Output Port
|
||
• A behavioral boundary
|
||
• Defines how the core communicates outward
|
||
• Never a data structure
|
||
• Lives in the Application Layer
|
||
|
||
export interface UseCaseOutputPort<T> {
|
||
present(data: T): void
|
||
}
|
||
|
||
|
||
~
|
||
|
||
Presenter
|
||
• Implements UseCaseOutputPort<T>
|
||
• Lives in the API / UI layer
|
||
• Translates Result → ViewModel / DTO
|
||
• Holds internal state
|
||
• Is pulled by the controller after execution
|
||
|
||
~
|
||
|
||
2. Canonical Use Case Structure
|
||
|
||
Application Layer
|
||
|
||
Use Case
|
||
|
||
@Injectable()
|
||
export class GetSponsorsUseCase {
|
||
constructor(
|
||
private readonly sponsorRepository: ISponsorRepository,
|
||
private readonly output: UseCaseOutputPort<GetSponsorsResult>,
|
||
) {}
|
||
|
||
async execute(): Promise<Result<void, ApplicationError>> {
|
||
const sponsors = await this.sponsorRepository.findAll()
|
||
|
||
this.output.present({ sponsors })
|
||
|
||
return Result.ok(undefined)
|
||
}
|
||
}
|
||
|
||
Rules:
|
||
• execute() is the Input Port
|
||
• The use case does not return result data
|
||
• All output flows through the OutputPort
|
||
• The return value signals success or failure only
|
||
|
||
### ⚠️ ARCHITECTURAL VIOLATION ALERT
|
||
|
||
**The pattern shown above is INCORRECT and violates Clean Architecture.**
|
||
|
||
#### ❌ WRONG PATTERN (What NOT to do)
|
||
|
||
```typescript
|
||
@Injectable()
|
||
export class GetSponsorsUseCase {
|
||
constructor(
|
||
private readonly sponsorRepository: ISponsorRepository,
|
||
private readonly output: UseCaseOutputPort<GetSponsorsResult>,
|
||
) {}
|
||
|
||
async execute(): Promise<Result<void, ApplicationError>> {
|
||
const sponsors = await this.sponsorRepository.findAll()
|
||
|
||
this.output.present({ sponsors }) // ❌ WRONG: Use case calling presenter
|
||
return Result.ok(undefined)
|
||
}
|
||
}
|
||
```
|
||
|
||
**Why this violates Clean Architecture:**
|
||
- Use cases **know about presenters** and how to call them
|
||
- Creates **tight coupling** between application logic and presentation
|
||
- Makes use cases **untestable** without mocking presenters
|
||
- Violates the **Dependency Rule** (inner layer depending on outer layer behavior)
|
||
|
||
#### ✅ CORRECT PATTERN (Clean Architecture)
|
||
|
||
```typescript
|
||
@Injectable()
|
||
export class GetSponsorsUseCase {
|
||
constructor(
|
||
private readonly sponsorRepository: ISponsorRepository,
|
||
// NO output port needed in constructor
|
||
) {}
|
||
|
||
async execute(): Promise<Result<GetSponsorsResult, ApplicationError>> {
|
||
const sponsors = await this.sponsorRepository.findAll()
|
||
|
||
return Result.ok({ sponsors })
|
||
// ✅ Returns Result, period. No .present() call.
|
||
}
|
||
}
|
||
```
|
||
|
||
**The Controller (in API layer) handles the wiring:**
|
||
|
||
```typescript
|
||
@Controller('/sponsors')
|
||
export class SponsorsController {
|
||
constructor(
|
||
private readonly useCase: GetSponsorsUseCase,
|
||
private readonly presenter: GetSponsorsPresenter,
|
||
) {}
|
||
|
||
@Get()
|
||
async getSponsors() {
|
||
// 1. Execute use case
|
||
const result = await this.useCase.execute()
|
||
|
||
if (result.isErr()) {
|
||
throw mapApplicationError(result.unwrapErr())
|
||
}
|
||
|
||
// 2. Wire to presenter
|
||
this.presenter.present(result.value)
|
||
|
||
// 3. Return ViewModel
|
||
return this.presenter.getViewModel()
|
||
}
|
||
}
|
||
```
|
||
|
||
**This is the ONLY pattern that respects Clean Architecture.**
|
||
|
||
~
|
||
|
||
Result Model
|
||
|
||
type GetSponsorsResult = {
|
||
sponsors: Sponsor[]
|
||
}
|
||
|
||
Rules:
|
||
• Domain objects are allowed
|
||
• No DTOs
|
||
• No interfaces
|
||
• No transport concerns
|
||
|
||
~
|
||
|
||
3. API Layer
|
||
|
||
API Services / Controllers (Thin Orchestration)
|
||
|
||
The API layer is a transport boundary. It MUST delegate business logic to `./core`:
|
||
|
||
• orchestrate auth + authorization checks (actor/session/roles)
|
||
• collect/validate transport input (DTOs at the boundary)
|
||
• execute a Core use case (entities/value objects live here)
|
||
• map Result → DTO / ViewModel via a Presenter (presenter owns mapping)
|
||
|
||
Rules:
|
||
• Controllers stay thin: no business rules, no domain validation, no decision-making
|
||
• API services orchestrate: auth + use case execution + presenter mapping
|
||
• Domain objects never cross the API boundary un-mapped
|
||
|
||
Presenter
|
||
|
||
@Injectable()
|
||
export class GetSponsorsPresenter
|
||
implements UseCaseOutputPort<GetSponsorsResult>
|
||
{
|
||
private viewModel!: GetSponsorsViewModel
|
||
|
||
present(result: GetSponsorsResult): void {
|
||
this.viewModel = {
|
||
sponsors: result.sponsors.map(s => ({
|
||
id: s.id.value,
|
||
name: s.name,
|
||
websiteUrl: s.websiteUrl,
|
||
})),
|
||
}
|
||
}
|
||
|
||
getViewModel(): GetSponsorsViewModel {
|
||
return this.viewModel
|
||
}
|
||
}
|
||
|
||
|
||
~
|
||
|
||
Controller
|
||
|
||
@Controller('/sponsors')
|
||
export class SponsorsController {
|
||
constructor(
|
||
private readonly useCase: GetSponsorsUseCase,
|
||
private readonly presenter: GetSponsorsPresenter,
|
||
) {}
|
||
|
||
@Get()
|
||
async getSponsors() {
|
||
const result = await this.useCase.execute()
|
||
|
||
if (result.isErr()) {
|
||
throw mapApplicationError(result.unwrapErr())
|
||
}
|
||
|
||
return this.presenter.getViewModel()
|
||
}
|
||
}
|
||
|
||
|
||
~
|
||
|
||
Payments Example
|
||
|
||
Application Layer
|
||
|
||
Use Case
|
||
|
||
@Injectable()
|
||
export class CreatePaymentUseCase {
|
||
constructor(
|
||
private readonly paymentRepository: IPaymentRepository,
|
||
private readonly output: UseCaseOutputPort<CreatePaymentResult>,
|
||
) {}
|
||
|
||
async execute(input: CreatePaymentInput): Promise<Result<void, ApplicationErrorCode<CreatePaymentErrorCode>>> {
|
||
// business logic
|
||
const payment = await this.paymentRepository.create(payment);
|
||
|
||
this.output.present({ payment });
|
||
|
||
return Result.ok(undefined);
|
||
}
|
||
}
|
||
|
||
Result Model
|
||
|
||
type CreatePaymentResult = {
|
||
payment: Payment;
|
||
};
|
||
|
||
API Layer
|
||
|
||
Presenter
|
||
|
||
@Injectable()
|
||
export class CreatePaymentPresenter
|
||
implements UseCaseOutputPort<CreatePaymentResult>
|
||
{
|
||
private viewModel: CreatePaymentViewModel | null = null;
|
||
|
||
present(result: CreatePaymentResult): void {
|
||
this.viewModel = {
|
||
payment: this.mapPaymentToDto(result.payment),
|
||
};
|
||
}
|
||
|
||
getViewModel(): CreatePaymentViewModel | null {
|
||
return this.viewModel;
|
||
}
|
||
|
||
reset(): void {
|
||
this.viewModel = null;
|
||
}
|
||
|
||
private mapPaymentToDto(payment: Payment): PaymentDto {
|
||
return {
|
||
id: payment.id,
|
||
// ... other fields
|
||
};
|
||
}
|
||
}
|
||
|
||
Controller
|
||
|
||
@Controller('/payments')
|
||
export class PaymentsController {
|
||
constructor(
|
||
private readonly useCase: CreatePaymentUseCase,
|
||
private readonly presenter: CreatePaymentPresenter,
|
||
) {}
|
||
|
||
@Post()
|
||
async createPayment(@Body() input: CreatePaymentInput) {
|
||
const result = await this.useCase.execute(input);
|
||
|
||
if (result.isErr()) {
|
||
throw mapApplicationError(result.unwrapErr());
|
||
}
|
||
|
||
return this.presenter.getViewModel();
|
||
}
|
||
}
|
||
|
||
~
|
||
|
||
4. Module Wiring (Composition Root)
|
||
|
||
@Module({
|
||
providers: [
|
||
GetSponsorsUseCase,
|
||
GetSponsorsPresenter,
|
||
{
|
||
provide: USE_CASE_OUTPUT_PORT,
|
||
useExisting: GetSponsorsPresenter,
|
||
},
|
||
],
|
||
})
|
||
export class SponsorsModule {}
|
||
|
||
Rules:
|
||
• The use case depends only on the OutputPort interface
|
||
• The presenter is bound as the OutputPort implementation
|
||
• process.env is not used inside the use case
|
||
|
||
~
|
||
|
||
5. Explicitly Forbidden
|
||
|
||
❌ DTOs in use cases
|
||
❌ Domain objects returned directly to the API
|
||
❌ Output ports used as data structures
|
||
❌ present() returning a value
|
||
❌ Input data named InputPort
|
||
❌ Mapping logic inside use cases
|
||
❌ Environment access inside the core
|
||
|
||
~
|
||
|
||
Do / Don’t (Boundary Examples)
|
||
|
||
✅ DO: Keep pages/components consuming ViewModels returned by website services (DTOs stop at the service boundary), e.g. [LeagueAdminSchedulePage()](apps/website/app/leagues/[id]/schedule/admin/page.tsx:12).
|
||
✅ DO: Keep controllers/services thin and delegating, e.g. [LeagueController.createLeagueSeasonScheduleRace()](apps/api/src/domain/league/LeagueController.ts:291).
|
||
❌ DON’T: Put business rules in the API layer; rules belong in `./core` use cases/entities/value objects, e.g. [CreateLeagueSeasonScheduleRaceUseCase.execute()](core/racing/application/use-cases/CreateLeagueSeasonScheduleRaceUseCase.ts:38).
|
||
|
||
~
|
||
|
||
6. Optional Extensions
|
||
|
||
Custom Output Ports
|
||
|
||
Only introduce a dedicated OutputPort interface if:
|
||
• multiple presentation paths exist
|
||
• streaming or progress updates are required
|
||
• more than one output method is needed
|
||
|
||
interface ComplexOutputPort {
|
||
presentSuccess(...)
|
||
presentFailure(...)
|
||
}
|
||
|
||
|
||
~
|
||
|
||
Input Port Interfaces
|
||
|
||
Only introduce an explicit InputPort interface if:
|
||
• multiple implementations of the same use case exist
|
||
• feature flags or A/B variants are required
|
||
• the use case itself must be substituted
|
||
|
||
Otherwise:
|
||
|
||
The use case class itself is the input port.
|
||
|
||
~
|
||
|
||
7. Key Rules (Memorize These)
|
||
|
||
Use cases answer what.
|
||
Presenters answer how.
|
||
|
||
Ports have behavior.
|
||
Data does not.
|
||
|
||
The core produces truth.
|
||
The API interprets it.
|
||
|
||
~
|
||
|
||
TL;DR
|
||
• Use cases are injected via DI
|
||
• execute() is the Input Port
|
||
• Outputs flow only through Output Ports
|
||
• Results are business models, not DTOs
|
||
• Interfaces exist only for behavior variability
|
||
|
||
### 🚨 CRITICAL CLEAN ARCHITECTURE CORRECTION
|
||
|
||
**The examples in this document (sections 2, 3, and the Payments Example) demonstrate the WRONG pattern that violates Clean Architecture.**
|
||
|
||
#### The Fundamental Problem
|
||
|
||
The current architecture shows use cases **calling presenters directly**:
|
||
|
||
```typescript
|
||
// ❌ WRONG - This violates Clean Architecture
|
||
this.output.present({ sponsors })
|
||
```
|
||
|
||
**This is architecturally incorrect.** Use cases must **never** know about presenters or call `.present()`.
|
||
|
||
#### The Correct Clean Architecture Pattern
|
||
|
||
**Use cases return Results. Controllers wire them to presenters.**
|
||
|
||
```typescript
|
||
// ✅ CORRECT - Use case returns data
|
||
@Injectable()
|
||
export class GetSponsorsUseCase {
|
||
constructor(private readonly sponsorRepository: ISponsorRepository) {}
|
||
|
||
async execute(): Promise<Result<GetSponsorsResult, ApplicationError>> {
|
||
const sponsors = await this.sponsorRepository.findAll()
|
||
return Result.ok({ sponsors })
|
||
// NO .present() call!
|
||
}
|
||
}
|
||
|
||
// ✅ CORRECT - Controller handles wiring
|
||
@Controller('/sponsors')
|
||
export class SponsorsController {
|
||
constructor(
|
||
private readonly useCase: GetSponsorsUseCase,
|
||
private readonly presenter: GetSponsorsPresenter,
|
||
) {}
|
||
|
||
@Get()
|
||
async getSponsors() {
|
||
const result = await this.useCase.execute()
|
||
|
||
if (result.isErr()) {
|
||
throw mapApplicationError(result.unwrapErr())
|
||
}
|
||
|
||
this.presenter.present(result.value)
|
||
return this.presenter.getViewModel()
|
||
}
|
||
}
|
||
```
|
||
|
||
#### Why This Matters
|
||
|
||
1. **Dependency Rule**: Inner layers (use cases) cannot depend on outer layers (presenters)
|
||
2. **Testability**: Use cases can be tested without mocking presenters
|
||
3. **Flexibility**: Same use case can work with different presenters
|
||
4. **Separation of Concerns**: Use cases do business logic, presenters do transformation
|
||
|
||
#### What Must Be Fixed
|
||
|
||
**All use cases in the codebase must be updated to:**
|
||
1. **Remove** the `output: UseCaseOutputPort<T>` constructor parameter
|
||
2. **Return** `Result<T, E>` directly from `execute()`
|
||
3. **Remove** all `this.output.present()` calls
|
||
|
||
**All controllers must be updated to:**
|
||
1. **Call** the use case and get the Result
|
||
2. **Pass** `result.value` to the presenter's `.present()` method
|
||
3. **Return** the presenter's `.getViewModel()`
|
||
|
||
This is the **single source of truth** for correct Clean Architecture in this project. |