Files
gridpilot.gg/docs/architecture/shared/DATA_FLOW.md
2026-01-11 13:04:33 +01:00

10 KiB
Raw Blame History

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.

  1. 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

  1. Application Layer (Core / Application)

The Application Layer has two distinct responsibilities: 1. Use Cases business decisions 2. Application Services orchestration of multiple use cases

  1. 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);

} }

  1. 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

  1. 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

  1. 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; } }

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

  1. 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

  1. 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

  1. 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.mdplans/nextjs-rsc-viewmodels-concept.md