10 KiB
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.
⸻
- 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.
⸻
- 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
⸻
- Application Layer (Core / Application)
The Application Layer has two distinct responsibilities: 1. Use Cases – business decisions 2. Application Services – orchestration of multiple use cases
⸻
- 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);
} }
⸻
- 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
⸻
- 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
⸻
- 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; } }
⸻
- Frontend Layer (apps/website)
The frontend layer contains UI-specific data shapes. None of these cross into the Core.
Important: apps/website is a Next.js delivery app with SSR/RSC. This introduces one additional presentation concept to keep server/client boundaries correct.
There are four distinct frontend data concepts: 1. API DTOs (transport) 2. Command Models (user input / form state) 3. View Models (client-only presentation classes) 4. ViewData (template input, serializable)
⸻
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
SSR/RSC rule (website-only): • View Models are client-only and MUST NOT cross server-to-client boundaries. • Templates MUST NOT accept View Models.
⸻
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 { 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 { const dto = await this.api.createLeague(input); return this.presenter.present(dto); } }
⸻
- 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
⸻
- 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 ViewData (Templates) or ViewModels (Client orchestrators) • API DTOs never cross into Templates • View Models never cross into Templates
⸻
- 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. 8.3.1 ViewData (Template Input)
ViewData is the only allowed input for Templates in apps/website.
Definition: • JSON-serializable data structure • Contains only primitives/arrays/plain objects • Ready to render: Templates perform no formatting and no derived computation
Rules: • ViewData is built in client code from: 1) Page DTO (initial SSR-safe render) 2) ViewModel (post-hydration enhancement) • ViewData MUST NOT contain ViewModel instances or Display Object instances.
Authoritative details: • docs/architecture/website/VIEW_DATA.md • plans/nextjs-rsc-viewmodels-concept.md