7.7 KiB
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.
⸻
- 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 { present(data: T): void }
⸻
Presenter • Implements UseCaseOutputPort • Lives in the API / UI layer • Translates Result → ViewModel / DTO • Holds internal state • Is pulled by the controller after execution
⸻
- Canonical Use Case Structure
Application Layer
Use Case
@Injectable() export class GetSponsorsUseCase { constructor( private readonly sponsorRepository: ISponsorRepository, private readonly output: UseCaseOutputPort, ) {}
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
⸻
Result Model
type GetSponsorsResult = { sponsors: Sponsor[] }
Rules: • Domain objects are allowed • No DTOs • No interfaces • No transport concerns
⸻
- 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 { 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, ) {}
async execute(input: CreatePaymentInput): Promise<Result<void, ApplicationErrorCode>> { // 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 { 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();
} }
⸻
- 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
⸻
- 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).
- 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.
⸻
- 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
This document is the single source of truth for use case architecture in this project.