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