442 lines
9.3 KiB
Markdown
442 lines
9.3 KiB
Markdown
Clean Architecture – Application Services, Use Cases, Ports, and Data Flow (Strict, Final)
|
||
|
||
This document defines the final, non-ambiguous Clean Architecture setup for the project.
|
||
|
||
It explicitly covers:
|
||
• Use Cases vs Application Services
|
||
• Input & Output Ports (and what does not exist)
|
||
• API responsibilities
|
||
• Frontend responsibilities
|
||
• Naming, placement, and dependency rules
|
||
• End-to-end flow with concrete paths and code examples
|
||
|
||
There are no hybrid concepts, no overloaded terms, and no optional interpretations.
|
||
|
||
⸻
|
||
|
||
1. Architectural Layers (Final)
|
||
|
||
Domain → Business truth
|
||
Application → Use Cases + Application Services
|
||
Adapters → API, Persistence, External Systems
|
||
Frontend → UI, View Models, UX logic
|
||
|
||
Only dependency-inward is allowed.
|
||
|
||
⸻
|
||
|
||
2. Domain Layer (Core / Domain)
|
||
|
||
What lives here
|
||
• Entities (classes)
|
||
• Value Objects (classes)
|
||
• Domain Services (stateless business logic)
|
||
• Domain Events
|
||
• Domain Errors / Invariants
|
||
|
||
What NEVER lives here
|
||
• DTOs
|
||
• Models
|
||
• Ports
|
||
• Use Cases
|
||
• Application Services
|
||
• Framework code
|
||
|
||
⸻
|
||
|
||
3. Application Layer (Core / Application)
|
||
|
||
The Application Layer has two distinct responsibilities:
|
||
1. Use Cases – business decisions
|
||
2. Application Services – orchestration of multiple use cases
|
||
|
||
⸻
|
||
|
||
4. Use Cases (Application / Use Cases)
|
||
|
||
Definition
|
||
|
||
A Use Case represents one business intent.
|
||
|
||
Examples:
|
||
• CreateLeague
|
||
• ApproveSponsorship
|
||
• CompleteDriverOnboarding
|
||
|
||
Rules
|
||
• A Use Case:
|
||
• contains business logic
|
||
• enforces invariants
|
||
• operates on domain entities
|
||
• communicates ONLY via ports
|
||
• A Use Case:
|
||
• does NOT orchestrate multiple workflows
|
||
• does NOT know HTTP, UI, DB, queues
|
||
|
||
Structure
|
||
|
||
core/racing/application/use-cases/
|
||
└─ CreateLeagueUseCase.ts
|
||
|
||
Example
|
||
|
||
export class CreateLeagueUseCase {
|
||
constructor(
|
||
private readonly leagueRepository: LeagueRepositoryPort,
|
||
private readonly output: CreateLeagueOutputPort
|
||
) {}
|
||
|
||
execute(input: CreateLeagueInputPort): void {
|
||
// business rules & invariants
|
||
|
||
const league = League.create(input.name, input.maxMembers);
|
||
this.leagueRepository.save(league);
|
||
|
||
this.output.presentSuccess(league.id);
|
||
}
|
||
}
|
||
|
||
|
||
⸻
|
||
|
||
5. Ports (Application / Ports)
|
||
|
||
The Only Two Kinds of Ports
|
||
|
||
Everything crossing the Application boundary is a Port.
|
||
|
||
Input Ports
|
||
|
||
Input Ports describe what a use case needs.
|
||
|
||
export interface CreateLeagueInputPort {
|
||
readonly name: string;
|
||
readonly maxMembers: number;
|
||
}
|
||
|
||
Rules:
|
||
• Interfaces only
|
||
• No behavior
|
||
• No validation logic
|
||
|
||
⸻
|
||
|
||
Output Ports
|
||
|
||
Output Ports describe how a use case emits outcomes.
|
||
|
||
export interface CreateLeagueOutputPort {
|
||
presentSuccess(leagueId: string): void;
|
||
presentFailure(reason: string): void;
|
||
}
|
||
|
||
Rules:
|
||
• No return values
|
||
• No getters
|
||
• No state
|
||
• Use methods, not result objects
|
||
|
||
⸻
|
||
|
||
6. Application Services (Application / Services)
|
||
|
||
Definition
|
||
|
||
An Application Service orchestrates multiple Use Cases.
|
||
|
||
It exists because:
|
||
• No single Use Case should know the whole workflow
|
||
• Orchestration is not business logic
|
||
|
||
Rules
|
||
• Application Services:
|
||
• call multiple Use Cases
|
||
• define execution order
|
||
• handle partial failure & compensation
|
||
• Application Services:
|
||
• do NOT contain business rules
|
||
• do NOT modify entities directly
|
||
|
||
Structure
|
||
|
||
core/racing/application/services/
|
||
└─ LeagueSetupService.ts
|
||
|
||
Example (with Edge Cases)
|
||
|
||
export class LeagueSetupService {
|
||
constructor(
|
||
private readonly createLeague: CreateLeagueUseCase,
|
||
private readonly createSeason: CreateSeasonUseCase,
|
||
private readonly assignOwner: AssignLeagueOwnerUseCase,
|
||
private readonly notify: SendLeagueWelcomeNotificationUseCase
|
||
) {}
|
||
|
||
execute(input: LeagueSetupInputPort): void {
|
||
const leagueId = this.createLeague.execute(input);
|
||
|
||
try {
|
||
this.createSeason.execute({ leagueId });
|
||
this.assignOwner.execute({ leagueId, ownerId: input.ownerId });
|
||
this.notify.execute({ leagueId, ownerId: input.ownerId });
|
||
} catch (error) {
|
||
// compensation / rollback logic
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
|
||
Edge cases that belong ONLY here:
|
||
• Partial failure handling
|
||
• Workflow order
|
||
• Optional steps
|
||
• Retry / idempotency logic
|
||
|
||
⸻
|
||
|
||
7. API Layer (apps/api)
|
||
|
||
Responsibilities
|
||
• Transport (HTTP)
|
||
• Validation (request shape)
|
||
• Mapping to Input Ports
|
||
• Calling Application Services
|
||
• Adapting Output Ports
|
||
|
||
Structure
|
||
|
||
apps/api/leagues/
|
||
├─ LeagueController.ts
|
||
├─ presenters/
|
||
│ └─ CreateLeaguePresenter.ts
|
||
└─ dto/
|
||
├─ CreateLeagueRequestDto.ts
|
||
└─ CreateLeagueResponseDto.ts
|
||
|
||
|
||
⸻
|
||
|
||
API Presenter (Adapter)
|
||
|
||
export class CreateLeaguePresenter implements CreateLeagueOutputPort {
|
||
private response!: CreateLeagueResponseDto;
|
||
|
||
presentSuccess(leagueId: string): void {
|
||
this.response = { success: true, leagueId };
|
||
}
|
||
|
||
presentFailure(reason: string): void {
|
||
this.response = { success: false, errorMessage: reason };
|
||
}
|
||
|
||
getResponse(): CreateLeagueResponseDto {
|
||
return this.response;
|
||
}
|
||
}
|
||
|
||
|
||
⸻
|
||
|
||
8. Frontend Layer (apps/website)
|
||
|
||
The frontend layer contains UI-specific data shapes. None of these cross into the Core.
|
||
|
||
There are three distinct frontend data concepts:
|
||
1. API DTOs (transport)
|
||
2. Command Models (user input / form state)
|
||
3. View Models (UI display state)
|
||
|
||
⸻
|
||
|
||
8.1 API DTOs (Transport Contracts)
|
||
|
||
API DTOs represent exact HTTP contracts exposed by the backend.
|
||
They are usually generated from OpenAPI or manually mirrored.
|
||
|
||
apps/website/lib/dtos/
|
||
└─ CreateLeagueResponseDto.ts
|
||
|
||
Rules:
|
||
• Exact mirror of backend response
|
||
• No UI logic
|
||
• No derived values
|
||
• Never used directly by components
|
||
|
||
⸻
|
||
|
||
8.2 Command Models (User Input / Form State)
|
||
|
||
Command Models represent user intent before submission.
|
||
They are frontend-only and exist to manage:
|
||
• form state
|
||
• validation feedback
|
||
• step-based wizards
|
||
|
||
They are NOT:
|
||
• domain objects
|
||
• API DTOs
|
||
• View Models
|
||
|
||
apps/website/lib/commands/
|
||
└─ CreateLeagueCommandModel.ts
|
||
|
||
Rules:
|
||
• Classes (stateful)
|
||
• May contain client-side validation
|
||
• May contain UX-specific helpers (step validation, dirty flags)
|
||
• Must expose a method to convert to an API Request DTO
|
||
|
||
Example responsibility:
|
||
• hold incomplete or invalid user input
|
||
• guide the user through multi-step flows
|
||
• prepare data for submission
|
||
|
||
Command Models:
|
||
• are consumed by components
|
||
• are passed into services
|
||
• are never sent directly over HTTP
|
||
|
||
⸻
|
||
|
||
8.3 View Models (UI Display State)
|
||
|
||
View Models represent fully prepared UI state after data is loaded.
|
||
|
||
apps/website/lib/view-models/
|
||
└─ CreateLeagueViewModel.ts
|
||
|
||
Rules:
|
||
• Classes only
|
||
• UI logic allowed (formatting, labels, derived flags)
|
||
• No domain logic
|
||
• No mutation after construction
|
||
|
||
⸻
|
||
|
||
8.4 Website Presenters (DTO → ViewModel)
|
||
|
||
Website Presenters are pure mappers.
|
||
|
||
export class CreateLeaguePresenter {
|
||
present(dto: CreateLeagueResponseDto): CreateLeagueViewModel {
|
||
return new CreateLeagueViewModel(dto);
|
||
}
|
||
}
|
||
|
||
Rules:
|
||
• Input: API DTOs
|
||
• Output: View Models
|
||
• No side effects
|
||
• No API calls
|
||
|
||
⸻
|
||
|
||
8.5 Website Services (Orchestration)
|
||
|
||
Website Services orchestrate:
|
||
• Command Models
|
||
• API Client calls
|
||
• Presenter mappings
|
||
|
||
export class LeagueService {
|
||
async createLeague(command: CreateLeagueCommandModel): Promise<CreateLeagueViewModel> {
|
||
const dto = await this.api.createLeague(command.toRequestDto());
|
||
return this.presenter.present(dto);
|
||
}
|
||
}
|
||
|
||
Rules:
|
||
• Services accept Command Models
|
||
• Services return View Models
|
||
• Components never call API clients directly
|
||
|
||
⸻
|
||
|
||
View Models (UI State)
|
||
|
||
apps/website/lib/view-models/
|
||
└─ CreateLeagueViewModel.ts
|
||
|
||
export class CreateLeagueViewModel {
|
||
constructor(private readonly dto: CreateLeagueResponseDto) {}
|
||
|
||
get message(): string {
|
||
return this.dto.success
|
||
? 'League created successfully'
|
||
: this.dto.errorMessage ?? 'Creation failed';
|
||
}
|
||
}
|
||
|
||
Rules:
|
||
• Classes only
|
||
• UI logic allowed
|
||
• No domain logic
|
||
|
||
⸻
|
||
|
||
Website Presenter (DTO → ViewModel)
|
||
|
||
export class CreateLeaguePresenter {
|
||
present(dto: CreateLeagueResponseDto): CreateLeagueViewModel {
|
||
return new CreateLeagueViewModel(dto);
|
||
}
|
||
}
|
||
|
||
|
||
⸻
|
||
|
||
Website Service (Orchestration)
|
||
|
||
export class LeagueService {
|
||
constructor(
|
||
private readonly api: LeaguesApiClient,
|
||
private readonly presenter: CreateLeaguePresenter
|
||
) {}
|
||
|
||
async createLeague(input: unknown): Promise<CreateLeagueViewModel> {
|
||
const dto = await this.api.createLeague(input);
|
||
return this.presenter.present(dto);
|
||
}
|
||
}
|
||
|
||
|
||
⸻
|
||
|
||
9. Full End-to-End Flow (Final)
|
||
|
||
UI Component
|
||
→ Website Service
|
||
→ API Client
|
||
→ HTTP Request DTO
|
||
→ API Controller
|
||
→ Application Service
|
||
→ Use Case(s)
|
||
→ Domain
|
||
→ Output Port
|
||
→ API Presenter
|
||
→ HTTP Response DTO
|
||
→ Website Presenter
|
||
→ View Model
|
||
→ UI
|
||
|
||
|
||
⸻
|
||
|
||
10. Final Non-Negotiable Rules
|
||
• Core knows ONLY Ports + Domain
|
||
• Core has NO Models, DTOs, or ViewModels
|
||
• API talks ONLY to Application Services
|
||
• Controllers NEVER call Use Cases directly
|
||
• Frontend Components see ONLY View Models
|
||
• DTOs never cross into UI components
|
||
|
||
⸻
|
||
|
||
11. Final Merksatz
|
||
|
||
Use Cases decide.
|
||
Application Services orchestrate.
|
||
Adapters translate.
|
||
UI presents.
|
||
|
||
If a class violates more than one of these roles, it is incorrectly placed. |