Files
gridpilot.gg/docs/architecture/USECASES.md
2025-12-28 12:04:12 +01:00

360 lines
7.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 / 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).
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.