Files
gridpilot.gg/docs/architecture/core/USECASES.md
2026-01-11 13:04:33 +01:00

12 KiB
Raw Blame History

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

  1. 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

⚠️ ARCHITECTURAL VIOLATION ALERT

The pattern shown above is INCORRECT and violates Clean Architecture.

WRONG PATTERN (What NOT to do)

@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)

@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:

@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

  1. 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();

} }

  1. 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

  1. 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 / Dont (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).
❌ DONT: 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).

  1. 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.

  1. 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:

// ❌ 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.

// ✅ 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.