This commit is contained in:
2026-01-11 13:04:33 +01:00
parent 6f2ab9fc56
commit 971aa7288b
44 changed files with 2168 additions and 1240 deletions

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

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

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

View 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 / Dont (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).
❌ DONT: 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.