This commit is contained in:
2026-01-11 14:42:54 +01:00
parent 2f0b83f030
commit 90b6e73a22
27 changed files with 980 additions and 2513 deletions

View File

@@ -0,0 +1,92 @@
# Core Data Flow (Strict)
This document defines the **Core** data flow rules and boundaries.
Core scope:
- `core/**`
Core does not know:
- HTTP
- Next.js
- databases
- DTOs
- UI models
## 1) Layers inside Core
Core contains two inner layers:
- Domain
- Application
### 1.1 Domain
Domain is business truth.
Allowed:
- Entities
- Value Objects
- Domain Services
Forbidden:
- DTOs
- frameworks
- IO
See [`docs/architecture/core/DOMAIN_OBJECTS.md`](docs/architecture/core/DOMAIN_OBJECTS.md:1).
### 1.2 Application
Application coordinates business intents.
Allowed:
- Use Cases (commands and queries)
- Application-level ports (repository ports, gateways)
Forbidden:
- HTTP
- persistence implementations
- frontend models
## 2) Core I/O boundary
All communication across the Core boundary occurs through **ports**.
Rules:
- Port interfaces live in Core.
- Implementations live outside Core.
## 3) Core data types (strict)
- Use Case inputs are plain data and/or domain types.
- Use Case outputs are plain data and/or domain types.
Core MUST NOT define HTTP DTOs.
## 4) Canonical flow
```text
Delivery App (HTTP or Website)
Core Application (Use Case)
Core Domain (Entities, Value Objects)
Ports (repository, gateway)
Adapter implementation (outside Core)
```
## 5) Non-negotiable rules
1. Core is framework-agnostic.
2. DTOs do not enter Core.
3. Core defines ports; outer layers implement them.

View File

@@ -0,0 +1,57 @@
# Core File Structure (Strict)
This document defines the canonical **physical** structure for `core/`.
It describes where code lives, not the full behavioral rules.
Core rules and responsibilities are defined elsewhere.
## 1) Core is feature-based
Core is organized by bounded context / feature.
```text
core/
shared/
<context>/
domain/
application/
```
## 2) `core/<context>/domain/`
Domain contains business truth.
Canonical folders:
```text
core/<context>/domain/
entities/
value-objects/
services/
events/
errors/
```
See [`docs/architecture/core/DOMAIN_OBJECTS.md`](docs/architecture/core/DOMAIN_OBJECTS.md:1).
## 3) `core/<context>/application/`
Application coordinates business intents.
Canonical folders:
```text
core/<context>/application/
commands/
queries/
use-cases/
services/
ports/
```
See:
- [`docs/architecture/core/USECASES.md`](docs/architecture/core/USECASES.md:1)
- [`docs/architecture/core/CQRS.md`](docs/architecture/core/CQRS.md:1)

View File

@@ -196,15 +196,16 @@ Avoid CQRS Light when:
12. Migration Path
12. Adoption Rule (Strict)
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)
CQRS Light is a structural rule inside Core.
No rewrites required.
If CQRS Light is used:
- commands and queries MUST be separated by responsibility
- queries MUST remain read-only and must not enforce invariants
This document does not define a migration plan.
@@ -241,4 +242,4 @@ Without:
• Event sourcing complexity
• Premature optimization
It is the safest way to gain CQRS benefits while staying true to Clean Architecture.
It is the safest way to gain CQRS benefits while staying true to Clean Architecture.

View File

@@ -1,339 +0,0 @@
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

@@ -1,513 +1,62 @@
Use Case Architecture Guide
# Use Cases (Core Application Boundary) (Strict)
This document defines the correct structure and responsibilities of Application Use Cases
according to Clean Architecture, in a NestJS-based system.
This document defines the strict rules for Core Use Cases.
The goal is:
• strict separation of concerns
• correct terminology (no fake "ports")
• minimal abstractions
• long-term consistency
Scope:
This is the canonical reference for all use cases in this codebase.
- `core/**`
Non-scope:
1. Core Concepts (Authoritative Definitions)
- HTTP controllers
- DTOs
- Next.js pages
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
## 1) Definition
The public execute() method is the input port.
A Use Case represents one business intent.
It answers:
Input
• Pure data
• Not a port
• Not an interface
• May be omitted if the use case has no parameters
- what the system does
type GetSponsorsInput = {
leagueId: LeagueId
}
## 2) Non-negotiable rules
1. Use Cases contain business logic.
2. Use Cases enforce invariants.
3. Use Cases do not know about HTTP.
4. Use Cases do not know about UI.
5. Use Cases do not depend on delivery-layer presenters.
6. Use Cases do not accept or return HTTP DTOs.
## 3) Inputs and outputs
Result
• The business outcome of a use case
• May contain Entities and Value Objects
• Not a DTO
• Never leaves the core directly
Inputs:
type GetSponsorsResult = {
sponsors: Sponsor[]
}
- plain data and/or domain types
Outputs:
- a `Result` containing plain data and/or domain types
Output Port
• A behavioral boundary
• Defines how the core communicates outward
• Never a data structure
• Lives in the Application Layer
Rule:
export interface UseCaseOutputPort<T> {
present(data: T): void
}
- mapping to and from HTTP DTOs happens in the API, not in the Core.
See API wiring: [`docs/architecture/api/USE_CASE_WIRING.md`](docs/architecture/api/USE_CASE_WIRING.md:1)
## 4) Ports
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)
}
}
Use Cases depend on ports for IO.
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
- port interfaces live in Core
- implementations live in adapters or delivery apps
**The pattern shown above is INCORRECT and violates Clean Architecture.**
## 5) CQRS
#### ❌ WRONG PATTERN (What NOT to do)
If CQRS-light is used, commands and queries are separated by responsibility.
```typescript
@Injectable()
export class GetSponsorsUseCase {
constructor(
private readonly sponsorRepository: ISponsorRepository,
private readonly output: UseCaseOutputPort<GetSponsorsResult>,
) {}
See [`docs/architecture/core/CQRS.md`](docs/architecture/core/CQRS.md:1).
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.