Files
gridpilot.gg/docs/architecture/USECASES.md
2025-12-21 17:05:36 +01:00

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

  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

Result Model

type GetSponsorsResult = { sponsors: Sponsor[] }

Rules: • Domain objects are allowed • No DTOs • No interfaces • No transport concerns

  1. API Layer

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

  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

This document is the single source of truth for use case architecture in this project.