docs
This commit is contained in:
244
docs/architecture/core/CQRS.md
Normal file
244
docs/architecture/core/CQRS.md
Normal file
@@ -0,0 +1,244 @@
|
||||
CQRS Light with Clean Architecture
|
||||
|
||||
This document defines CQRS Light as a pragmatic, production-ready approach that integrates cleanly with Clean Architecture.
|
||||
|
||||
It is intentionally non-dogmatic, avoids event-sourcing overhead, and focuses on clarity, performance, and maintainability.
|
||||
|
||||
⸻
|
||||
|
||||
1. What CQRS Light Is
|
||||
|
||||
CQRS Light separates how the system writes data from how it reads data — without changing the core architecture.
|
||||
|
||||
Key properties:
|
||||
• Commands and Queries are separated logically, not infrastructurally
|
||||
• Same database is allowed
|
||||
• No event bus required
|
||||
• No eventual consistency by default
|
||||
|
||||
CQRS Light is an optimization, not a foundation.
|
||||
|
||||
⸻
|
||||
|
||||
2. What CQRS Light Is NOT
|
||||
|
||||
CQRS Light explicitly does not include:
|
||||
• Event Sourcing
|
||||
• Message brokers
|
||||
• Projections as a hard requirement
|
||||
• Separate databases
|
||||
• Microservices
|
||||
|
||||
Those can be added later if needed.
|
||||
|
||||
⸻
|
||||
|
||||
3. Why CQRS Light Exists
|
||||
|
||||
Without CQRS:
|
||||
• Reads are forced through domain aggregates
|
||||
• Aggregates grow unnaturally large
|
||||
• Reporting logic pollutes the domain
|
||||
• Performance degrades due to object loading
|
||||
|
||||
CQRS Light solves this by allowing:
|
||||
• Strict domain logic on writes
|
||||
• Flexible, optimized reads
|
||||
|
||||
⸻
|
||||
|
||||
4. Core Architectural Principle
|
||||
|
||||
Writes protect invariants. Reads optimize information access.
|
||||
|
||||
Therefore:
|
||||
• Commands enforce business rules
|
||||
• Queries are allowed to be pragmatic and denormalized
|
||||
|
||||
⸻
|
||||
|
||||
5. Placement in Clean Architecture
|
||||
|
||||
CQRS Light does not introduce new layers.
|
||||
It reorganizes existing ones.
|
||||
|
||||
core/
|
||||
└── <context>/
|
||||
└── application/
|
||||
├── commands/ # Write side (Use Cases)
|
||||
└── queries/ # Read side (Query Use Cases)
|
||||
|
||||
Domain remains unchanged.
|
||||
|
||||
⸻
|
||||
|
||||
6. Command Side (Write Model)
|
||||
|
||||
Purpose
|
||||
• Modify state
|
||||
• Enforce invariants
|
||||
• Emit outcomes
|
||||
|
||||
Characteristics
|
||||
• Uses Domain Entities and Value Objects
|
||||
• Uses Repositories
|
||||
• Uses Output Ports
|
||||
• Transactional
|
||||
|
||||
Example Structure
|
||||
|
||||
core/racing/application/commands/
|
||||
├── CreateLeagueUseCase.ts
|
||||
├── ApplyPenaltyUseCase.ts
|
||||
└── RegisterForRaceUseCase.ts
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
7. Query Side (Read Model)
|
||||
|
||||
Purpose
|
||||
• Read state
|
||||
• Aggregate data
|
||||
• Serve UI efficiently
|
||||
|
||||
Characteristics
|
||||
• No domain entities
|
||||
• No invariants
|
||||
• No side effects
|
||||
• May use SQL/ORM directly
|
||||
|
||||
Example Structure
|
||||
|
||||
core/racing/application/queries/
|
||||
├── GetLeagueStandingsQuery.ts
|
||||
├── GetDashboardOverviewQuery.ts
|
||||
└── GetDriverStatsQuery.ts
|
||||
|
||||
Queries are still Use Cases, just read-only ones.
|
||||
|
||||
⸻
|
||||
|
||||
8. Repositories in CQRS Light
|
||||
|
||||
Write Repositories
|
||||
• Used by command use cases
|
||||
• Work with domain entities
|
||||
• Enforce consistency
|
||||
|
||||
core/racing/domain/repositories/
|
||||
└── LeagueRepositoryPort.ts
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
Read Repositories
|
||||
• Used by query use cases
|
||||
• Return flat, optimized data
|
||||
• Not domain repositories
|
||||
|
||||
core/racing/application/ports/
|
||||
└── LeagueStandingsReadPort.ts
|
||||
|
||||
Implementation lives in adapters.
|
||||
|
||||
⸻
|
||||
|
||||
9. Performance Benefits (Why It Matters)
|
||||
|
||||
Without CQRS Light
|
||||
• Aggregate loading
|
||||
• N+1 queries
|
||||
• Heavy object graphs
|
||||
• CPU and memory overhead
|
||||
|
||||
With CQRS Light
|
||||
• Single optimized queries
|
||||
• Minimal data transfer
|
||||
• Database does aggregation
|
||||
• Lower memory footprint
|
||||
|
||||
This results in:
|
||||
• Faster endpoints
|
||||
• Simpler code
|
||||
• Easier scaling
|
||||
|
||||
⸻
|
||||
|
||||
10. Testing Strategy
|
||||
|
||||
Commands
|
||||
• Unit tests
|
||||
• Mock repositories and ports
|
||||
• Verify output port calls
|
||||
|
||||
Queries
|
||||
• Simple unit tests
|
||||
• Input → Output verification
|
||||
• No mocks beyond data source
|
||||
|
||||
CQRS Light reduces the need for complex integration tests.
|
||||
|
||||
⸻
|
||||
|
||||
11. When CQRS Light Is a Good Fit
|
||||
|
||||
Use CQRS Light when:
|
||||
• Read complexity is high
|
||||
• Write logic must stay strict
|
||||
• Dashboards or analytics exist
|
||||
• Multiple clients consume the system
|
||||
|
||||
Avoid CQRS Light when:
|
||||
• Application is CRUD-only
|
||||
• Data volume is small
|
||||
• Read/write patterns are identical
|
||||
|
||||
⸻
|
||||
|
||||
12. Migration Path
|
||||
|
||||
CQRS Light allows incremental adoption:
|
||||
1. Start with classic Clean Architecture
|
||||
2. Separate commands and queries logically
|
||||
3. Optimize read paths as needed
|
||||
4. Introduce events or projections later (optional)
|
||||
|
||||
No rewrites required.
|
||||
|
||||
⸻
|
||||
|
||||
13. Key Rules (Non-Negotiable)
|
||||
• Commands MUST use the domain
|
||||
• Queries MUST NOT modify state
|
||||
• Queries MUST NOT enforce invariants
|
||||
• Domain MUST NOT depend on queries
|
||||
• Core remains framework-agnostic
|
||||
|
||||
⸻
|
||||
|
||||
14. Mental Model
|
||||
|
||||
Think of CQRS Light as:
|
||||
|
||||
One core truth, two access patterns.
|
||||
|
||||
The domain defines truth.
|
||||
Queries define convenience.
|
||||
|
||||
⸻
|
||||
|
||||
15. Final Summary
|
||||
|
||||
CQRS Light provides:
|
||||
• Cleaner domain models
|
||||
• Faster reads
|
||||
• Reduced complexity
|
||||
• Future scalability
|
||||
|
||||
Without:
|
||||
• Infrastructure overhead
|
||||
• Event sourcing complexity
|
||||
• Premature optimization
|
||||
|
||||
It is the safest way to gain CQRS benefits while staying true to Clean Architecture.
|
||||
264
docs/architecture/core/DOMAIN_OBJECTS.md
Normal file
264
docs/architecture/core/DOMAIN_OBJECTS.md
Normal file
@@ -0,0 +1,264 @@
|
||||
Domain Objects Design Guide (Clean Architecture)
|
||||
|
||||
This document defines all domain object types used in the Core and assigns strict responsibilities and boundaries.
|
||||
|
||||
Its goal is to remove ambiguity between:
|
||||
• Entities
|
||||
• Value Objects
|
||||
• Aggregate Roots
|
||||
• Domain Services
|
||||
• Domain Events
|
||||
|
||||
The rules in this document are non-negotiable.
|
||||
|
||||
⸻
|
||||
|
||||
Core Principle
|
||||
|
||||
Domain objects represent business truth.
|
||||
|
||||
They:
|
||||
• outlive APIs and UIs
|
||||
• must remain stable over time
|
||||
• must not depend on technical details
|
||||
|
||||
If a class answers a business question, it belongs here.
|
||||
|
||||
⸻
|
||||
|
||||
1. Entities
|
||||
|
||||
Definition
|
||||
|
||||
An Entity is a domain object that:
|
||||
• has a stable identity
|
||||
• changes over time
|
||||
• represents a business concept
|
||||
|
||||
Identity matters more than attributes.
|
||||
|
||||
⸻
|
||||
|
||||
Responsibilities
|
||||
|
||||
Entities MUST:
|
||||
• own their identity
|
||||
• enforce invariants on state changes
|
||||
• expose behavior, not setters
|
||||
|
||||
Entities MUST NOT:
|
||||
• depend on DTOs or transport models
|
||||
• access repositories or services
|
||||
• perform IO
|
||||
• know about frameworks
|
||||
|
||||
⸻
|
||||
|
||||
Creation Rules
|
||||
• New entities are created via create()
|
||||
• Existing entities are reconstructed via rehydrate()
|
||||
|
||||
core/<context>/domain/entities/
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
Example
|
||||
• League
|
||||
• Season
|
||||
• Race
|
||||
• Driver
|
||||
|
||||
⸻
|
||||
|
||||
2. Value Objects
|
||||
|
||||
Definition
|
||||
|
||||
A Value Object is a domain object that:
|
||||
• has no identity
|
||||
• is immutable
|
||||
• is defined by its value
|
||||
|
||||
⸻
|
||||
|
||||
Responsibilities
|
||||
|
||||
Value Objects MUST:
|
||||
• validate their own invariants
|
||||
• be immutable
|
||||
• be comparable by value
|
||||
|
||||
Value Objects MUST NOT:
|
||||
• contain business workflows
|
||||
• reference entities
|
||||
• perform IO
|
||||
|
||||
⸻
|
||||
|
||||
Creation Rules
|
||||
• create() for new domain meaning
|
||||
• fromX() for interpreting external formats
|
||||
|
||||
core/<context>/domain/value-objects/
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
Example
|
||||
• Money
|
||||
• LeagueName
|
||||
• RaceTimeOfDay
|
||||
• SeasonSchedule
|
||||
|
||||
⸻
|
||||
|
||||
3. Aggregate Roots
|
||||
|
||||
Definition
|
||||
|
||||
An Aggregate Root is an entity that:
|
||||
• acts as the consistency boundary
|
||||
• protects invariants across related entities
|
||||
|
||||
All access to the aggregate happens through the root.
|
||||
|
||||
⸻
|
||||
|
||||
Responsibilities
|
||||
|
||||
Aggregate Roots MUST:
|
||||
• enforce consistency rules
|
||||
• control modifications of child entities
|
||||
|
||||
Aggregate Roots MUST NOT:
|
||||
• expose internal collections directly
|
||||
• allow partial updates bypassing rules
|
||||
|
||||
⸻
|
||||
|
||||
Example
|
||||
• League (root)
|
||||
• Season (root)
|
||||
|
||||
⸻
|
||||
|
||||
4. Domain Services
|
||||
|
||||
Definition
|
||||
|
||||
A Domain Service encapsulates domain logic that:
|
||||
• does not naturally belong to a single entity
|
||||
• involves multiple domain objects
|
||||
|
||||
⸻
|
||||
|
||||
Responsibilities
|
||||
|
||||
Domain Services MAY:
|
||||
• coordinate entities
|
||||
• calculate derived domain values
|
||||
|
||||
Domain Services MUST:
|
||||
• operate only on domain types
|
||||
• remain stateless
|
||||
|
||||
Domain Services MUST NOT:
|
||||
• access repositories
|
||||
• orchestrate use cases
|
||||
• perform IO
|
||||
|
||||
core/<context>/domain/services/
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
Example
|
||||
• SeasonConfigurationFactory
|
||||
• ChampionshipAggregator
|
||||
• StrengthOfFieldCalculator
|
||||
|
||||
⸻
|
||||
|
||||
5. Domain Events
|
||||
|
||||
Definition
|
||||
|
||||
A Domain Event represents something that:
|
||||
• has already happened
|
||||
• is important to the business
|
||||
|
||||
⸻
|
||||
|
||||
Responsibilities
|
||||
|
||||
Domain Events MUST:
|
||||
• be immutable
|
||||
• carry minimal information
|
||||
|
||||
Domain Events MUST NOT:
|
||||
• contain behavior
|
||||
• perform side effects
|
||||
|
||||
core/<context>/domain/events/
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
Example
|
||||
• RaceCompleted
|
||||
• SeasonActivated
|
||||
|
||||
⸻
|
||||
|
||||
6. What Does NOT Belong in Domain Objects
|
||||
|
||||
❌ DTOs
|
||||
❌ API Models
|
||||
❌ View Models
|
||||
❌ Repositories
|
||||
❌ Framework Types
|
||||
❌ Logging
|
||||
❌ Configuration
|
||||
|
||||
If it depends on infrastructure, it does not belong here.
|
||||
|
||||
⸻
|
||||
|
||||
7. Dependency Rules
|
||||
|
||||
Entities → Value Objects
|
||||
Entities → Domain Services
|
||||
Domain Services → Entities
|
||||
|
||||
Reverse dependencies are forbidden.
|
||||
|
||||
⸻
|
||||
|
||||
8. Testing Requirements
|
||||
|
||||
Domain Objects MUST:
|
||||
• have unit tests for invariants
|
||||
• be tested without mocks
|
||||
|
||||
Domain Services MUST:
|
||||
• have deterministic tests
|
||||
|
||||
⸻
|
||||
|
||||
Mental Model (Final)
|
||||
|
||||
Entities protect state.
|
||||
Value Objects protect meaning.
|
||||
Aggregate Roots protect consistency.
|
||||
Domain Services protect cross-entity rules.
|
||||
Domain Events describe facts.
|
||||
|
||||
⸻
|
||||
|
||||
Final Summary
|
||||
• Domain objects represent business truth
|
||||
• They are pure and framework-free
|
||||
• They form the most stable part of the system
|
||||
|
||||
If domain objects are clean, everything else becomes easier.
|
||||
339
docs/architecture/core/ENUMS.md
Normal file
339
docs/architecture/core/ENUMS.md
Normal file
@@ -0,0 +1,339 @@
|
||||
Enums in Clean Architecture (Strict & Final)
|
||||
|
||||
This document defines how enums are modeled, placed, and used in a strict Clean Architecture setup.
|
||||
|
||||
Enums are one of the most common sources of architectural leakage. This guide removes all ambiguity.
|
||||
|
||||
⸻
|
||||
|
||||
1. Core Principle
|
||||
|
||||
Enums represent knowledge.
|
||||
Knowledge must live where it is true.
|
||||
|
||||
Therefore:
|
||||
• Not every enum is a domain enum
|
||||
• Enums must never cross architectural boundaries blindly
|
||||
• Ports must remain neutral
|
||||
|
||||
⸻
|
||||
|
||||
2. Enum Categories (Authoritative)
|
||||
|
||||
There are four and only four valid enum categories:
|
||||
1. Domain Enums
|
||||
2. Application (Workflow) Enums
|
||||
3. Transport Enums (API)
|
||||
4. UI Enums (Frontend)
|
||||
|
||||
Each category has strict placement and usage rules.
|
||||
|
||||
⸻
|
||||
|
||||
3. Domain Enums
|
||||
|
||||
Definition
|
||||
|
||||
A Domain Enum represents a business concept that:
|
||||
• has meaning in the domain
|
||||
• affects rules or invariants
|
||||
• is part of the ubiquitous language
|
||||
|
||||
Examples:
|
||||
• LeagueVisibility
|
||||
• MembershipRole
|
||||
• RaceStatus
|
||||
• SponsorshipTier
|
||||
• PenaltyType
|
||||
|
||||
⸻
|
||||
|
||||
Placement
|
||||
|
||||
core/<context>/domain/
|
||||
├── value-objects/
|
||||
│ └── LeagueVisibility.ts
|
||||
└── entities/
|
||||
|
||||
Preferred: model domain enums as Value Objects instead of enum keywords.
|
||||
|
||||
⸻
|
||||
|
||||
Example (Value Object)
|
||||
|
||||
export class LeagueVisibility {
|
||||
private constructor(private readonly value: 'public' | 'private') {}
|
||||
|
||||
static from(value: string): LeagueVisibility {
|
||||
if (value !== 'public' && value !== 'private') {
|
||||
throw new DomainError('Invalid LeagueVisibility');
|
||||
}
|
||||
return new LeagueVisibility(value);
|
||||
}
|
||||
|
||||
isPublic(): boolean {
|
||||
return this.value === 'public';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
Usage Rules
|
||||
|
||||
Allowed:
|
||||
• Domain
|
||||
• Use Cases
|
||||
|
||||
Forbidden:
|
||||
• Ports
|
||||
• Adapters
|
||||
• API DTOs
|
||||
• Frontend
|
||||
|
||||
Domain enums must never cross a Port boundary.
|
||||
|
||||
⸻
|
||||
|
||||
4. Application Enums (Workflow Enums)
|
||||
|
||||
Definition
|
||||
|
||||
Application Enums represent internal workflow or state coordination.
|
||||
|
||||
They are not business truth and must not leak.
|
||||
|
||||
Examples:
|
||||
• LeagueSetupStep
|
||||
• ImportPhase
|
||||
• ProcessingState
|
||||
|
||||
⸻
|
||||
|
||||
Placement
|
||||
|
||||
core/<context>/application/internal/
|
||||
└── LeagueSetupStep.ts
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
Example
|
||||
|
||||
export enum LeagueSetupStep {
|
||||
CreateLeague,
|
||||
CreateSeason,
|
||||
AssignOwner,
|
||||
Notify
|
||||
}
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
Usage Rules
|
||||
|
||||
Allowed:
|
||||
• Application Services
|
||||
• Use Cases
|
||||
|
||||
Forbidden:
|
||||
• Domain
|
||||
• Ports
|
||||
• Adapters
|
||||
• Frontend
|
||||
|
||||
These enums must remain strictly internal.
|
||||
|
||||
⸻
|
||||
|
||||
5. Transport Enums (API DTOs)
|
||||
|
||||
Definition
|
||||
|
||||
Transport Enums describe allowed values in HTTP contracts.
|
||||
They exist purely to constrain transport data, not to encode business rules.
|
||||
|
||||
Naming rule:
|
||||
|
||||
Transport enums MUST end with Enum.
|
||||
|
||||
This makes enums immediately recognizable in code reviews and prevents silent leakage.
|
||||
|
||||
Examples:
|
||||
• LeagueVisibilityEnum
|
||||
• SponsorshipStatusEnum
|
||||
• PenaltyTypeEnum
|
||||
|
||||
⸻
|
||||
|
||||
Placement
|
||||
|
||||
apps/api/<feature>/dto/
|
||||
└── LeagueVisibilityEnum.ts
|
||||
|
||||
Website mirrors the same naming:
|
||||
|
||||
apps/website/lib/dtos/
|
||||
└── LeagueVisibilityEnum.ts
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
Example
|
||||
|
||||
export enum LeagueVisibilityEnum {
|
||||
Public = 'public',
|
||||
Private = 'private'
|
||||
}
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
Usage Rules
|
||||
|
||||
Allowed:
|
||||
• API Controllers
|
||||
• API Presenters
|
||||
• Website API DTOs
|
||||
|
||||
Forbidden:
|
||||
• Core Domain
|
||||
• Use Cases
|
||||
|
||||
Transport enums are copies, never reexports of domain enums.
|
||||
|
||||
⸻
|
||||
|
||||
Placement
|
||||
|
||||
apps/api/<feature>/dto/
|
||||
└── LeagueVisibilityDto.ts
|
||||
|
||||
or inline as union types in DTOs.
|
||||
|
||||
⸻
|
||||
|
||||
Example
|
||||
|
||||
export type LeagueVisibilityDto = 'public' | 'private';
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
Usage Rules
|
||||
|
||||
Allowed:
|
||||
• API Controllers
|
||||
• API Presenters
|
||||
• Website API DTOs
|
||||
|
||||
Forbidden:
|
||||
• Core Domain
|
||||
• Use Cases
|
||||
|
||||
Transport enums are copies, never reexports of domain enums.
|
||||
|
||||
⸻
|
||||
|
||||
6. UI Enums (Frontend)
|
||||
|
||||
Definition
|
||||
|
||||
UI Enums describe presentation or interaction state.
|
||||
|
||||
They have no business meaning.
|
||||
|
||||
Examples:
|
||||
• WizardStep
|
||||
• SortOrder
|
||||
• ViewMode
|
||||
• TabKey
|
||||
|
||||
⸻
|
||||
|
||||
Placement
|
||||
|
||||
apps/website/lib/ui/
|
||||
└── LeagueWizardStep.ts
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
Example
|
||||
|
||||
export enum LeagueWizardStep {
|
||||
Basics,
|
||||
Structure,
|
||||
Scoring,
|
||||
Review
|
||||
}
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
Usage Rules
|
||||
|
||||
Allowed:
|
||||
• Frontend only
|
||||
|
||||
Forbidden:
|
||||
• Core
|
||||
• API
|
||||
|
||||
⸻
|
||||
|
||||
7. Absolute Prohibitions
|
||||
|
||||
❌ Enums in Ports
|
||||
|
||||
// ❌ forbidden
|
||||
export interface CreateLeagueInputPort {
|
||||
visibility: LeagueVisibility;
|
||||
}
|
||||
|
||||
✅ Correct
|
||||
|
||||
export interface CreateLeagueInputPort {
|
||||
visibility: 'public' | 'private';
|
||||
}
|
||||
|
||||
Mapping happens inside the Use Case:
|
||||
|
||||
const visibility = LeagueVisibility.from(input.visibility);
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
8. Decision Checklist
|
||||
|
||||
Ask these questions:
|
||||
1. Does changing this enum change business rules?
|
||||
• Yes → Domain Enum
|
||||
• No → continue
|
||||
2. Is it only needed for internal workflow coordination?
|
||||
• Yes → Application Enum
|
||||
• No → continue
|
||||
3. Is it part of an HTTP contract?
|
||||
• Yes → Transport Enum
|
||||
• No → continue
|
||||
4. Is it purely for UI state?
|
||||
• Yes → UI Enum
|
||||
|
||||
⸻
|
||||
|
||||
9. Summary Table
|
||||
|
||||
Enum Type Location May Cross Ports Scope
|
||||
Domain Enum core/domain ❌ No Business rules
|
||||
Application Enum core/application ❌ No Workflow only
|
||||
Transport Enum apps/api + website ❌ No HTTP contracts
|
||||
UI Enum apps/website ❌ No Presentation only
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
10. Final Rule (Non-Negotiable)
|
||||
|
||||
If an enum crosses a boundary, it is in the wrong place.
|
||||
|
||||
This rule alone prevents most long-term architectural decay.
|
||||
513
docs/architecture/core/USECASES.md
Normal file
513
docs/architecture/core/USECASES.md
Normal file
@@ -0,0 +1,513 @@
|
||||
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
|
||||
|
||||
### ⚠️ ARCHITECTURAL VIOLATION ALERT
|
||||
|
||||
**The pattern shown above is INCORRECT and violates Clean Architecture.**
|
||||
|
||||
#### ❌ WRONG PATTERN (What NOT to do)
|
||||
|
||||
```typescript
|
||||
@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 }) // ❌ WRONG: Use case calling presenter
|
||||
return Result.ok(undefined)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why this violates Clean Architecture:**
|
||||
- Use cases **know about presenters** and how to call them
|
||||
- Creates **tight coupling** between application logic and presentation
|
||||
- Makes use cases **untestable** without mocking presenters
|
||||
- Violates the **Dependency Rule** (inner layer depending on outer layer behavior)
|
||||
|
||||
#### ✅ CORRECT PATTERN (Clean Architecture)
|
||||
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class GetSponsorsUseCase {
|
||||
constructor(
|
||||
private readonly sponsorRepository: ISponsorRepository,
|
||||
// NO output port needed in constructor
|
||||
) {}
|
||||
|
||||
async execute(): Promise<Result<GetSponsorsResult, ApplicationError>> {
|
||||
const sponsors = await this.sponsorRepository.findAll()
|
||||
|
||||
return Result.ok({ sponsors })
|
||||
// ✅ Returns Result, period. No .present() call.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**The Controller (in API layer) handles the wiring:**
|
||||
|
||||
```typescript
|
||||
@Controller('/sponsors')
|
||||
export class SponsorsController {
|
||||
constructor(
|
||||
private readonly useCase: GetSponsorsUseCase,
|
||||
private readonly presenter: GetSponsorsPresenter,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async getSponsors() {
|
||||
// 1. Execute use case
|
||||
const result = await this.useCase.execute()
|
||||
|
||||
if (result.isErr()) {
|
||||
throw mapApplicationError(result.unwrapErr())
|
||||
}
|
||||
|
||||
// 2. Wire to presenter
|
||||
this.presenter.present(result.value)
|
||||
|
||||
// 3. Return ViewModel
|
||||
return this.presenter.getViewModel()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**This is the ONLY pattern that respects Clean Architecture.**
|
||||
|
||||
~
|
||||
|
||||
Result Model
|
||||
|
||||
type GetSponsorsResult = {
|
||||
sponsors: Sponsor[]
|
||||
}
|
||||
|
||||
Rules:
|
||||
• Domain objects are allowed
|
||||
• No DTOs
|
||||
• No interfaces
|
||||
• No transport concerns
|
||||
|
||||
~
|
||||
|
||||
3. API Layer
|
||||
|
||||
API Services / Controllers (Thin Orchestration)
|
||||
|
||||
The API layer is a transport boundary. It MUST delegate business logic to `./core`:
|
||||
|
||||
• orchestrate auth + authorization checks (actor/session/roles)
|
||||
• collect/validate transport input (DTOs at the boundary)
|
||||
• execute a Core use case (entities/value objects live here)
|
||||
• map Result → DTO / ViewModel via a Presenter (presenter owns mapping)
|
||||
|
||||
Rules:
|
||||
• Controllers stay thin: no business rules, no domain validation, no decision-making
|
||||
• API services orchestrate: auth + use case execution + presenter mapping
|
||||
• Domain objects never cross the API boundary un-mapped
|
||||
|
||||
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
|
||||
|
||||
~
|
||||
|
||||
Do / Don’t (Boundary Examples)
|
||||
|
||||
✅ DO: Keep pages/components consuming ViewModels returned by website services (DTOs stop at the service boundary), e.g. [LeagueAdminSchedulePage()](apps/website/app/leagues/[id]/schedule/admin/page.tsx:12).
|
||||
✅ DO: Keep controllers/services thin and delegating, e.g. [LeagueController.createLeagueSeasonScheduleRace()](apps/api/src/domain/league/LeagueController.ts:291).
|
||||
❌ DON’T: Put business rules in the API layer; rules belong in `./core` use cases/entities/value objects, e.g. [CreateLeagueSeasonScheduleRaceUseCase.execute()](core/racing/application/use-cases/CreateLeagueSeasonScheduleRaceUseCase.ts:38).
|
||||
|
||||
~
|
||||
|
||||
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
|
||||
|
||||
### 🚨 CRITICAL CLEAN ARCHITECTURE CORRECTION
|
||||
|
||||
**The examples in this document (sections 2, 3, and the Payments Example) demonstrate the WRONG pattern that violates Clean Architecture.**
|
||||
|
||||
#### The Fundamental Problem
|
||||
|
||||
The current architecture shows use cases **calling presenters directly**:
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - This violates Clean Architecture
|
||||
this.output.present({ sponsors })
|
||||
```
|
||||
|
||||
**This is architecturally incorrect.** Use cases must **never** know about presenters or call `.present()`.
|
||||
|
||||
#### The Correct Clean Architecture Pattern
|
||||
|
||||
**Use cases return Results. Controllers wire them to presenters.**
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Use case returns data
|
||||
@Injectable()
|
||||
export class GetSponsorsUseCase {
|
||||
constructor(private readonly sponsorRepository: ISponsorRepository) {}
|
||||
|
||||
async execute(): Promise<Result<GetSponsorsResult, ApplicationError>> {
|
||||
const sponsors = await this.sponsorRepository.findAll()
|
||||
return Result.ok({ sponsors })
|
||||
// NO .present() call!
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ CORRECT - Controller handles wiring
|
||||
@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())
|
||||
}
|
||||
|
||||
this.presenter.present(result.value)
|
||||
return this.presenter.getViewModel()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Why This Matters
|
||||
|
||||
1. **Dependency Rule**: Inner layers (use cases) cannot depend on outer layers (presenters)
|
||||
2. **Testability**: Use cases can be tested without mocking presenters
|
||||
3. **Flexibility**: Same use case can work with different presenters
|
||||
4. **Separation of Concerns**: Use cases do business logic, presenters do transformation
|
||||
|
||||
#### What Must Be Fixed
|
||||
|
||||
**All use cases in the codebase must be updated to:**
|
||||
1. **Remove** the `output: UseCaseOutputPort<T>` constructor parameter
|
||||
2. **Return** `Result<T, E>` directly from `execute()`
|
||||
3. **Remove** all `this.output.present()` calls
|
||||
|
||||
**All controllers must be updated to:**
|
||||
1. **Call** the use case and get the Result
|
||||
2. **Pass** `result.value` to the presenter's `.present()` method
|
||||
3. **Return** the presenter's `.getViewModel()`
|
||||
|
||||
This is the **single source of truth** for correct Clean Architecture in this project.
|
||||
Reference in New Issue
Block a user