340 lines
6.5 KiB
Markdown
340 lines
6.5 KiB
Markdown
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
|
|
|
|
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
|
|
|
|
⸻
|
|
|
|
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. |