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

⸻

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

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