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 { 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 ⸻ 2. Canonical Use Case Structure Application Layer Use Case @Injectable() export class GetSponsorsUseCase { constructor( private readonly sponsorRepository: ISponsorRepository, private readonly output: UseCaseOutputPort, ) {} async execute(): Promise> { 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 ⸻ 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 { 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>> { // 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(); } } ⸻ 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 This document is the single source of truth for use case architecture in this project.