module cleanup
This commit is contained in:
@@ -1,38 +1,20 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { AllLeaguesWithCapacityDTO } from './dtos/AllLeaguesWithCapacityDTO';
|
|
||||||
import { ApproveJoinRequestInputDTO } from './dtos/ApproveJoinRequestInputDTO';
|
import { ApproveJoinRequestInputDTO } from './dtos/ApproveJoinRequestInputDTO';
|
||||||
import { ApproveJoinRequestOutputDTO } from './dtos/ApproveJoinRequestOutputDTO';
|
|
||||||
import { CreateLeagueInputDTO } from './dtos/CreateLeagueInputDTO';
|
import { CreateLeagueInputDTO } from './dtos/CreateLeagueInputDTO';
|
||||||
import { CreateLeagueOutputDTO } from './dtos/CreateLeagueOutputDTO';
|
|
||||||
import { JoinLeagueOutputDTO } from './dtos/JoinLeagueOutputDTO';
|
|
||||||
import { TransferLeagueOwnershipOutputDTO } from './dtos/TransferLeagueOwnershipOutputDTO';
|
|
||||||
import { LeagueJoinRequestWithDriverDTO } from './dtos/LeagueJoinRequestWithDriverDTO';
|
|
||||||
import { GetLeagueAdminConfigQueryDTO } from './dtos/GetLeagueAdminConfigQueryDTO';
|
import { GetLeagueAdminConfigQueryDTO } from './dtos/GetLeagueAdminConfigQueryDTO';
|
||||||
import { GetLeagueAdminPermissionsInputDTO } from './dtos/GetLeagueAdminPermissionsInputDTO';
|
import { GetLeagueAdminPermissionsInputDTO } from './dtos/GetLeagueAdminPermissionsInputDTO';
|
||||||
import { GetLeagueOwnerSummaryQueryDTO } from './dtos/GetLeagueOwnerSummaryQueryDTO';
|
import { GetLeagueOwnerSummaryQueryDTO } from './dtos/GetLeagueOwnerSummaryQueryDTO';
|
||||||
import { GetLeagueProtestsQueryDTO } from './dtos/GetLeagueProtestsQueryDTO';
|
import { GetLeagueProtestsQueryDTO } from './dtos/GetLeagueProtestsQueryDTO';
|
||||||
import { GetLeagueRacesOutputDTO } from './dtos/GetLeagueRacesOutputDTO';
|
|
||||||
import { GetLeagueSeasonsQueryDTO } from './dtos/GetLeagueSeasonsQueryDTO';
|
import { GetLeagueSeasonsQueryDTO } from './dtos/GetLeagueSeasonsQueryDTO';
|
||||||
import { GetSeasonSponsorshipsOutputDTO } from './dtos/GetSeasonSponsorshipsOutputDTO';
|
import { RejectJoinRequestInputDTO } from './dtos/RejectJoinRequestInputDTO';
|
||||||
|
import { RemoveLeagueMemberInputDTO } from './dtos/RemoveLeagueMemberInputDTO';
|
||||||
|
import { UpdateLeagueMemberRoleInputDTO } from './dtos/UpdateLeagueMemberRoleInputDTO';
|
||||||
import { LeagueAdminDTO } from './dtos/LeagueAdminDTO';
|
import { LeagueAdminDTO } from './dtos/LeagueAdminDTO';
|
||||||
import { LeagueAdminProtestsDTO } from './dtos/LeagueAdminProtestsDTO';
|
import { LeagueAdminProtestsDTO } from './dtos/LeagueAdminProtestsDTO';
|
||||||
import { LeagueConfigFormModelDTO } from './dtos/LeagueConfigFormModelDTO';
|
|
||||||
import { LeagueJoinRequestDTO } from './dtos/LeagueJoinRequestDTO';
|
|
||||||
import { LeagueMembershipsDTO } from './dtos/LeagueMembershipsDTO';
|
import { LeagueMembershipsDTO } from './dtos/LeagueMembershipsDTO';
|
||||||
import { LeagueOwnerSummaryDTO } from './dtos/LeagueOwnerSummaryDTO';
|
|
||||||
import { LeagueScheduleDTO } from './dtos/LeagueScheduleDTO';
|
|
||||||
import { LeagueSeasonSummaryDTO } from './dtos/LeagueSeasonSummaryDTO';
|
import { LeagueSeasonSummaryDTO } from './dtos/LeagueSeasonSummaryDTO';
|
||||||
import { LeagueStandingsDTO } from './dtos/LeagueStandingsDTO';
|
import { GetSeasonSponsorshipsOutputDTO } from './dtos/GetSeasonSponsorshipsOutputDTO';
|
||||||
import { LeagueStatsDTO } from './dtos/LeagueStatsDTO';
|
import { GetLeagueRacesOutputDTO } from './dtos/GetLeagueRacesOutputDTO';
|
||||||
import { RejectJoinRequestInputDTO } from './dtos/RejectJoinRequestInputDTO';
|
|
||||||
import { RejectJoinRequestOutputDTO } from './dtos/RejectJoinRequestOutputDTO';
|
|
||||||
import { RemoveLeagueMemberInputDTO } from './dtos/RemoveLeagueMemberInputDTO';
|
|
||||||
import { RemoveLeagueMemberOutputDTO } from './dtos/RemoveLeagueMemberOutputDTO';
|
|
||||||
import { UpdateLeagueMemberRoleInputDTO } from './dtos/UpdateLeagueMemberRoleInputDTO';
|
|
||||||
import { UpdateLeagueMemberRoleOutputDTO } from './dtos/UpdateLeagueMemberRoleOutputDTO';
|
|
||||||
|
|
||||||
// Core imports for entities
|
|
||||||
import type { League } from '@core/racing/domain/entities/League';
|
|
||||||
|
|
||||||
// Core imports for view models
|
// Core imports for view models
|
||||||
import type { LeagueScoringConfigViewModel } from '@core/racing/application/presenters/ILeagueScoringConfigPresenter';
|
import type { LeagueScoringConfigViewModel } from '@core/racing/application/presenters/ILeagueScoringConfigPresenter';
|
||||||
@@ -46,18 +28,13 @@ import type { GetLeagueAdminPermissionsViewModel } from '@core/racing/applicatio
|
|||||||
import type { RemoveLeagueMemberViewModel } from '@core/racing/application/presenters/IRemoveLeagueMemberPresenter';
|
import type { RemoveLeagueMemberViewModel } from '@core/racing/application/presenters/IRemoveLeagueMemberPresenter';
|
||||||
import type { UpdateLeagueMemberRoleViewModel } from '@core/racing/application/presenters/IUpdateLeagueMemberRolePresenter';
|
import type { UpdateLeagueMemberRoleViewModel } from '@core/racing/application/presenters/IUpdateLeagueMemberRolePresenter';
|
||||||
import type { GetLeagueOwnerSummaryViewModel } from '@core/racing/application/presenters/IGetLeagueOwnerSummaryPresenter';
|
import type { GetLeagueOwnerSummaryViewModel } from '@core/racing/application/presenters/IGetLeagueOwnerSummaryPresenter';
|
||||||
import type { GetLeagueProtestsViewModel } from '@core/racing/application/presenters/IGetLeagueProtestsPresenter';
|
|
||||||
import type { GetLeagueSeasonsViewModel } from '@core/racing/application/presenters/IGetLeagueSeasonsPresenter';
|
|
||||||
import type { GetLeagueMembershipsViewModel } from '@core/racing/application/presenters/IGetLeagueMembershipsPresenter';
|
|
||||||
import type { LeagueStandingsViewModel } from '@core/racing/application/presenters/ILeagueStandingsPresenter';
|
import type { LeagueStandingsViewModel } from '@core/racing/application/presenters/ILeagueStandingsPresenter';
|
||||||
import type { LeagueScheduleViewModel } from '@core/racing/application/presenters/ILeagueSchedulePresenter';
|
|
||||||
import type { LeagueStatsViewModel } from '@core/racing/application/presenters/ILeagueStatsPresenter';
|
import type { LeagueStatsViewModel } from '@core/racing/application/presenters/ILeagueStatsPresenter';
|
||||||
import type { LeagueConfigFormViewModel } from '@core/racing/application/presenters/ILeagueFullConfigPresenter';
|
import type { LeagueConfigFormViewModel } from '@core/racing/application/presenters/ILeagueFullConfigPresenter';
|
||||||
import type { CreateLeagueViewModel } from '@core/racing/application/presenters/ICreateLeaguePresenter';
|
import type { CreateLeagueViewModel } from '@core/racing/application/presenters/ICreateLeaguePresenter';
|
||||||
import type { JoinLeagueViewModel } from '@core/racing/application/presenters/IJoinLeaguePresenter';
|
import type { JoinLeagueViewModel } from '@core/racing/application/presenters/IJoinLeaguePresenter';
|
||||||
import type { TransferLeagueOwnershipViewModel } from '@core/racing/application/presenters/ITransferLeagueOwnershipPresenter';
|
import type { TransferLeagueOwnershipViewModel } from '@core/racing/application/presenters/ITransferLeagueOwnershipPresenter';
|
||||||
|
|
||||||
|
|
||||||
// Core imports
|
// Core imports
|
||||||
import type { Logger } from '@core/shared/application/Logger';
|
import type { Logger } from '@core/shared/application/Logger';
|
||||||
|
|
||||||
@@ -92,13 +69,9 @@ import { LeagueScoringConfigPresenter } from './presenters/LeagueScoringConfigPr
|
|||||||
import { LeagueScoringPresetsPresenter } from './presenters/LeagueScoringPresetsPresenter';
|
import { LeagueScoringPresetsPresenter } from './presenters/LeagueScoringPresetsPresenter';
|
||||||
import { ApproveLeagueJoinRequestPresenter } from './presenters/ApproveLeagueJoinRequestPresenter';
|
import { ApproveLeagueJoinRequestPresenter } from './presenters/ApproveLeagueJoinRequestPresenter';
|
||||||
import { GetLeagueAdminPermissionsPresenter } from './presenters/GetLeagueAdminPermissionsPresenter';
|
import { GetLeagueAdminPermissionsPresenter } from './presenters/GetLeagueAdminPermissionsPresenter';
|
||||||
import { GetLeagueMembershipsPresenter } from './presenters/GetLeagueMembershipsPresenter';
|
|
||||||
import { GetLeagueOwnerSummaryPresenter } from './presenters/GetLeagueOwnerSummaryPresenter';
|
import { GetLeagueOwnerSummaryPresenter } from './presenters/GetLeagueOwnerSummaryPresenter';
|
||||||
import { GetLeagueProtestsPresenter } from './presenters/GetLeagueProtestsPresenter';
|
|
||||||
import { GetLeagueSeasonsPresenter } from './presenters/GetLeagueSeasonsPresenter';
|
|
||||||
import { LeagueJoinRequestsPresenter } from './presenters/LeagueJoinRequestsPresenter';
|
import { LeagueJoinRequestsPresenter } from './presenters/LeagueJoinRequestsPresenter';
|
||||||
import { LeagueSchedulePresenter } from './presenters/LeagueSchedulePresenter';
|
import { LeagueSchedulePresenter } from './presenters/LeagueSchedulePresenter';
|
||||||
import { LeagueStandingsPresenter } from './presenters/LeagueStandingsPresenter';
|
|
||||||
import { LeagueStatsPresenter } from './presenters/LeagueStatsPresenter';
|
import { LeagueStatsPresenter } from './presenters/LeagueStatsPresenter';
|
||||||
import { RejectLeagueJoinRequestPresenter } from './presenters/RejectLeagueJoinRequestPresenter';
|
import { RejectLeagueJoinRequestPresenter } from './presenters/RejectLeagueJoinRequestPresenter';
|
||||||
import { RemoveLeagueMemberPresenter } from './presenters/RemoveLeagueMemberPresenter';
|
import { RemoveLeagueMemberPresenter } from './presenters/RemoveLeagueMemberPresenter';
|
||||||
@@ -106,6 +79,9 @@ import { UpdateLeagueMemberRolePresenter } from './presenters/UpdateLeagueMember
|
|||||||
import { CreateLeaguePresenter } from './presenters/CreateLeaguePresenter';
|
import { CreateLeaguePresenter } from './presenters/CreateLeaguePresenter';
|
||||||
import { JoinLeaguePresenter } from './presenters/JoinLeaguePresenter';
|
import { JoinLeaguePresenter } from './presenters/JoinLeaguePresenter';
|
||||||
import { TransferLeagueOwnershipPresenter } from './presenters/TransferLeagueOwnershipPresenter';
|
import { TransferLeagueOwnershipPresenter } from './presenters/TransferLeagueOwnershipPresenter';
|
||||||
|
import { GetLeagueProtestsPresenter } from './presenters/GetLeagueProtestsPresenter';
|
||||||
|
import { GetLeagueSeasonsPresenter } from './presenters/GetLeagueSeasonsPresenter';
|
||||||
|
import { GetLeagueMembershipsPresenter } from './presenters/GetLeagueMembershipsPresenter';
|
||||||
|
|
||||||
// Tokens
|
// Tokens
|
||||||
import { LOGGER_TOKEN } from './LeagueProviders';
|
import { LOGGER_TOKEN } from './LeagueProviders';
|
||||||
@@ -258,7 +234,9 @@ export class LeagueService {
|
|||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
return result.unwrap();
|
const presenter = new GetLeagueProtestsPresenter();
|
||||||
|
presenter.present(result.unwrap());
|
||||||
|
return presenter.getViewModel() as LeagueAdminProtestsDTO;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLeagueSeasons(query: GetLeagueSeasonsQueryDTO): Promise<LeagueSeasonSummaryDTO[]> {
|
async getLeagueSeasons(query: GetLeagueSeasonsQueryDTO): Promise<LeagueSeasonSummaryDTO[]> {
|
||||||
@@ -267,7 +245,9 @@ export class LeagueService {
|
|||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
return result.unwrap().seasons;
|
const presenter = new GetLeagueSeasonsPresenter();
|
||||||
|
presenter.present(result.unwrap());
|
||||||
|
return presenter.getViewModel().seasons;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLeagueMemberships(leagueId: string): Promise<LeagueMembershipsDTO> {
|
async getLeagueMemberships(leagueId: string): Promise<LeagueMembershipsDTO> {
|
||||||
@@ -276,16 +256,19 @@ export class LeagueService {
|
|||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
return result.unwrap();
|
const presenter = new GetLeagueMembershipsPresenter();
|
||||||
|
presenter.present(result.unwrap());
|
||||||
|
return presenter.getViewModel().memberships as LeagueMembershipsDTO;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLeagueStandings(leagueId: string): Promise<LeagueStandingsViewModel> {
|
async getLeagueStandings(leagueId: string): Promise<LeagueStandingsViewModel> {
|
||||||
this.logger.debug('Getting league standings', { leagueId });
|
this.logger.debug('Getting league standings', { leagueId });
|
||||||
|
const result = await this.getLeagueStandingsUseCase.execute(leagueId);
|
||||||
return await this.getLeagueStandingsUseCase.execute(leagueId);
|
// The use case returns a view model directly, so we return it as-is
|
||||||
|
return result as unknown as LeagueStandingsViewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLeagueSchedule(leagueId: string): Promise<LeagueScheduleViewModel> {
|
async getLeagueSchedule(leagueId: string): Promise<ReturnType<LeagueSchedulePresenter['getViewModel']>> {
|
||||||
this.logger.debug('Getting league schedule', { leagueId });
|
this.logger.debug('Getting league schedule', { leagueId });
|
||||||
const result = await this.getLeagueScheduleUseCase.execute({ leagueId });
|
const result = await this.getLeagueScheduleUseCase.execute({ leagueId });
|
||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
@@ -320,10 +303,48 @@ export class LeagueService {
|
|||||||
// In a full implementation, we'd have a use case that gets league basic info
|
// In a full implementation, we'd have a use case that gets league basic info
|
||||||
const ownerSummary = config ? await this.getLeagueOwnerSummary({ ownerId: 'placeholder', leagueId }) : null;
|
const ownerSummary = config ? await this.getLeagueOwnerSummary({ ownerId: 'placeholder', leagueId }) : null;
|
||||||
|
|
||||||
|
// Convert config from view model to DTO format manually with proper types
|
||||||
|
const configForm = config ? {
|
||||||
|
leagueId: config.leagueId,
|
||||||
|
basics: {
|
||||||
|
name: config.basics.name,
|
||||||
|
description: config.basics.description,
|
||||||
|
visibility: config.basics.visibility as 'public' | 'private',
|
||||||
|
},
|
||||||
|
structure: {
|
||||||
|
mode: config.structure.mode as 'solo' | 'team',
|
||||||
|
},
|
||||||
|
championships: [], // TODO: Map championships from view model
|
||||||
|
scoring: {
|
||||||
|
type: 'standard' as const, // TODO: Map from view model
|
||||||
|
points: 25, // TODO: Map from view model
|
||||||
|
},
|
||||||
|
dropPolicy: {
|
||||||
|
strategy: config.dropPolicy.strategy as 'none' | 'worst_n',
|
||||||
|
n: config.dropPolicy.n ?? 0,
|
||||||
|
},
|
||||||
|
timings: {
|
||||||
|
raceDayOfWeek: 'sunday' as const, // TODO: Map from view model
|
||||||
|
raceTimeHour: 20, // TODO: Map from view model
|
||||||
|
raceTimeMinute: 0, // TODO: Map from view model
|
||||||
|
},
|
||||||
|
stewarding: {
|
||||||
|
decisionMode: config.stewarding.decisionMode === 'steward_vote' ? 'committee_vote' as const : 'single_steward' as const,
|
||||||
|
requireDefense: config.stewarding.requireDefense,
|
||||||
|
defenseTimeLimit: config.stewarding.defenseTimeLimit,
|
||||||
|
voteTimeLimit: config.stewarding.voteTimeLimit,
|
||||||
|
protestDeadlineHours: config.stewarding.protestDeadlineHours,
|
||||||
|
stewardingClosesHours: config.stewarding.stewardingClosesHours,
|
||||||
|
notifyAccusedOnProtest: config.stewarding.notifyAccusedOnProtest,
|
||||||
|
notifyOnVoteRequired: config.stewarding.notifyOnVoteRequired,
|
||||||
|
requiredVotes: config.stewarding.requiredVotes ?? 0,
|
||||||
|
},
|
||||||
|
} : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
joinRequests,
|
joinRequests: joinRequests.joinRequests,
|
||||||
ownerSummary,
|
ownerSummary: ownerSummary?.summary || null,
|
||||||
config: { form: config },
|
config: { form: configForm },
|
||||||
protests,
|
protests,
|
||||||
seasons,
|
seasons,
|
||||||
};
|
};
|
||||||
@@ -411,7 +432,7 @@ export class LeagueService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const presenter = new TransferLeagueOwnershipPresenter();
|
const presenter = new TransferLeagueOwnershipPresenter();
|
||||||
presenter.present(result.unwrap());
|
presenter.present({ success: true });
|
||||||
return presenter.getViewModel();
|
return presenter.getViewModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,361 +1,207 @@
|
|||||||
Frontend data shapes: View Models, Presenters, and API Client (Strict)
|
Frontend & Backend Output Shapes – Clean Architecture (Strict, Final)
|
||||||
|
|
||||||
This document defines the exact placement and responsibilities for frontend data shapes.
|
This document defines the exact responsibilities, naming, and placement of all data shapes involved in delivering data from Core → API → Frontend UI.
|
||||||
It is designed to leave no room for interpretation.
|
|
||||||
|
It resolves all ambiguity around Presenters, View Models, DTOs, and Output Ports.
|
||||||
|
There is no overlap of terminology across layers.
|
||||||
|
|
||||||
⸻
|
⸻
|
||||||
|
|
||||||
1. Definitions
|
1. Core Layer (Application / Use Cases)
|
||||||
|
|
||||||
API DTOs
|
Core Output Ports (formerly “Presenters”)
|
||||||
|
|
||||||
Transport shapes owned by the API boundary (HTTP). They are not used directly by UI components.
|
In the Core, a Presenter is not a UI concept.
|
||||||
|
|
||||||
View Models
|
It is an Output Port that defines how a Use Case emits its result.
|
||||||
|
|
||||||
UI-owned shapes. They represent exactly what the UI needs and nothing else.
|
Rules
|
||||||
|
• Core Output Ports:
|
||||||
|
• define what data is emitted
|
||||||
|
• do not store state
|
||||||
|
• do not expose getters
|
||||||
|
• do not reference DTOs or View Models
|
||||||
|
• Core never pulls data back from an output port
|
||||||
|
• Core calls present() and stops
|
||||||
|
|
||||||
Website Presenters
|
Naming
|
||||||
|
• *OutputPort
|
||||||
|
• *Result (pure application result)
|
||||||
|
|
||||||
Pure mappers that convert API DTOs (or core use-case outputs) into View Models.
|
Example
|
||||||
|
|
||||||
API Client
|
export interface CompleteDriverOnboardingResult {
|
||||||
|
readonly success: boolean;
|
||||||
|
readonly driverId?: string;
|
||||||
|
readonly error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
A thin HTTP wrapper that returns API DTOs only and performs no business logic.
|
export interface CompleteDriverOnboardingOutputPort {
|
||||||
|
present(result: CompleteDriverOnboardingResult): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
The Core does not know or care what happens after present() is called.
|
||||||
|
|
||||||
⸻
|
⸻
|
||||||
|
|
||||||
2. Directory layout (exact)
|
2. API Layer (Delivery / Adapter)
|
||||||
|
|
||||||
apps/website
|
API Presenters (Response Mappers)
|
||||||
├── app/ # Next.js routes/pages
|
|
||||||
├── components/ # React components (UI only)
|
|
||||||
├── lib/
|
|
||||||
│ ├── api/ # API client (HTTP only)
|
|
||||||
│ ├── dtos/ # API DTO types (transport shapes)
|
|
||||||
│ ├── view-models/ # View Models (UI-owned shapes)
|
|
||||||
│ ├── presenters/ # Presenters: DTO -> ViewModel mapping
|
|
||||||
│ ├── services/ # UI orchestration (calls api + presenters)
|
|
||||||
│ └── index.ts
|
|
||||||
|
|
||||||
No additional folders for these concerns are allowed.
|
API Presenters are Adapters.
|
||||||
|
|
||||||
|
They:
|
||||||
|
• implement Core Output Ports
|
||||||
|
• translate Core Results into API Response DTOs
|
||||||
|
• store response state temporarily for the controller
|
||||||
|
|
||||||
|
They are not View Models.
|
||||||
|
|
||||||
|
Rules
|
||||||
|
• API Presenters:
|
||||||
|
• implement a Core Output Port
|
||||||
|
• map Core Results → API Responses
|
||||||
|
• may store state internally
|
||||||
|
• API Presenters must not:
|
||||||
|
• contain business logic
|
||||||
|
• reference frontend View Models
|
||||||
|
|
||||||
|
Naming
|
||||||
|
• *Presenter or *ResponseMapper
|
||||||
|
• Output types end with Response or ApiResponse
|
||||||
|
|
||||||
⸻
|
⸻
|
||||||
|
|
||||||
3. View Models (placement and rules)
|
3. Frontend Layer (apps/website)
|
||||||
|
|
||||||
Where they live
|
View Models (UI-Owned, Final Form)
|
||||||
|
|
||||||
View Models MUST live in:
|
A View Model represents fully prepared UI state.
|
||||||
|
|
||||||
apps/website/lib/view-models
|
Only the frontend has Views — therefore only the frontend has View Models.
|
||||||
|
|
||||||
What they may contain
|
Rules
|
||||||
• UI-ready primitives (strings, numbers, booleans)
|
• View Models:
|
||||||
• UI-specific derived fields (e.g., isOwner, badgeLabel, formattedDate)
|
• live only in apps/website
|
||||||
• UI-specific structures (e.g., grouped arrays, flattened objects)
|
• accept API Response DTOs as input
|
||||||
|
• expose UI-ready data and helpers
|
||||||
|
• View Models must not:
|
||||||
|
• contain domain logic
|
||||||
|
• validate business rules
|
||||||
|
• perform side effects
|
||||||
|
• be sent back to the server
|
||||||
|
|
||||||
What they must NOT contain
|
Naming
|
||||||
• Domain entities or value objects
|
• *ViewModel
|
||||||
• API transport metadata
|
|
||||||
• Validation logic
|
|
||||||
• Network or persistence concerns
|
|
||||||
|
|
||||||
Rule
|
|
||||||
|
|
||||||
Components consume only View Models.
|
|
||||||
|
|
||||||
⸻
|
⸻
|
||||||
|
|
||||||
4. API DTOs in the website (placement and rules)
|
4. Website Presenters (DTO → ViewModel)
|
||||||
|
|
||||||
Clarification
|
Website Presenters are pure mappers.
|
||||||
|
|
||||||
The website does have DTOs, but only API DTOs.
|
They:
|
||||||
|
• convert API Response DTOs into View Models
|
||||||
|
• perform formatting and reshaping
|
||||||
|
• are deterministic and side-effect free
|
||||||
|
|
||||||
These DTOs exist exclusively to type HTTP communication with the backend API.
|
They are not Core Presenters.
|
||||||
They are not UI models.
|
|
||||||
|
Rules
|
||||||
|
• Input: API DTOs
|
||||||
|
• Output: View Models
|
||||||
|
• Must not:
|
||||||
|
• call APIs
|
||||||
|
• read storage
|
||||||
|
• perform decisions
|
||||||
|
|
||||||
⸻
|
⸻
|
||||||
|
|
||||||
Where they live
|
5. API Client (Frontend)
|
||||||
|
|
||||||
Website-side API DTO types MUST live in:
|
The API Client is a thin HTTP layer.
|
||||||
|
|
||||||
apps/website/lib/dtos
|
Rules
|
||||||
|
|
||||||
What they represent
|
|
||||||
• Exact transport shapes sent/received via HTTP
|
|
||||||
• Backend API contracts
|
|
||||||
• No UI assumptions
|
|
||||||
|
|
||||||
Who may use them
|
|
||||||
• API client
|
|
||||||
• Website presenters
|
|
||||||
|
|
||||||
Who must NOT use them
|
|
||||||
• React components
|
|
||||||
• Pages
|
|
||||||
• UI logic
|
|
||||||
|
|
||||||
Rule
|
|
||||||
|
|
||||||
API DTOs stop at the presenter boundary.
|
|
||||||
|
|
||||||
Components must never consume API DTOs directly.
|
|
||||||
|
|
||||||
⸻
|
|
||||||
|
|
||||||
5. Presenters (website) (placement and rules)
|
|
||||||
|
|
||||||
Where they live
|
|
||||||
|
|
||||||
Website presenters MUST live in:
|
|
||||||
|
|
||||||
apps/website/lib/presenters
|
|
||||||
|
|
||||||
What they do
|
|
||||||
• Convert API DTOs into View Models
|
|
||||||
• Perform UI-friendly formatting and structuring
|
|
||||||
• Are pure and deterministic
|
|
||||||
|
|
||||||
What they must NOT do
|
|
||||||
• Make API calls
|
|
||||||
• Read from localStorage/cookies directly
|
|
||||||
• Contain business rules or decisions
|
|
||||||
• Perform side effects
|
|
||||||
|
|
||||||
Rule
|
|
||||||
|
|
||||||
Presenters output View Models. Presenters never output API DTOs.
|
|
||||||
|
|
||||||
⸻
|
|
||||||
|
|
||||||
6. Do website presenters use View Models?
|
|
||||||
|
|
||||||
Yes. Strictly:
|
|
||||||
|
|
||||||
Website presenters MUST output View Models and MUST NOT output API DTOs.
|
|
||||||
|
|
||||||
Flow is always:
|
|
||||||
|
|
||||||
API DTO -> Presenter -> View Model -> Component
|
|
||||||
|
|
||||||
|
|
||||||
⸻
|
|
||||||
|
|
||||||
7. API client (website) (placement and rules)
|
|
||||||
|
|
||||||
Where it lives
|
|
||||||
|
|
||||||
The API client MUST live in:
|
|
||||||
|
|
||||||
apps/website/lib/api
|
|
||||||
|
|
||||||
What it does
|
|
||||||
• Sends HTTP requests
|
• Sends HTTP requests
|
||||||
• Returns API DTOs
|
• Returns API DTOs only
|
||||||
• Performs authentication header/cookie handling only at transport level
|
• Must not:
|
||||||
• Does not map to View Models
|
• return View Models
|
||||||
|
• contain business logic
|
||||||
What it must NOT do
|
• format data for UI
|
||||||
• Format or reshape responses for UI
|
|
||||||
• Contain business rules
|
|
||||||
• Contain decision logic
|
|
||||||
|
|
||||||
Rule
|
|
||||||
|
|
||||||
The API client has no knowledge of View Models.
|
|
||||||
|
|
||||||
⸻
|
⸻
|
||||||
|
|
||||||
8. Website service layer (strict orchestration)
|
6. Website Services (Orchestration)
|
||||||
|
|
||||||
Where it lives
|
Website Services orchestrate:
|
||||||
|
• API Client calls
|
||||||
|
• Website Presenter mappings
|
||||||
|
|
||||||
Website orchestration MUST live in:
|
They are the only layer allowed to touch both.
|
||||||
|
|
||||||
apps/website/lib/services
|
Rules
|
||||||
|
• Services:
|
||||||
What it does
|
• call API Client
|
||||||
• Calls the API client
|
• call Website Presenters
|
||||||
• Calls presenters to map DTO -> View Model
|
• return View Models only
|
||||||
• Returns View Models to pages/components
|
• Components never touch API Client or DTOs
|
||||||
|
|
||||||
What it must NOT do
|
|
||||||
• Contain domain logic
|
|
||||||
• Modify core invariants
|
|
||||||
• Return API DTOs
|
|
||||||
|
|
||||||
Rule
|
|
||||||
|
|
||||||
Services are the only layer allowed to call both api/ and presenters/.
|
|
||||||
|
|
||||||
Components must not call the API client directly.
|
|
||||||
|
|
||||||
⸻
|
⸻
|
||||||
|
|
||||||
9. Allowed dependency directions (frontend)
|
7. Final Data Flow (Unambiguous)
|
||||||
|
|
||||||
Within apps/website:
|
Core Use Case
|
||||||
|
→ OutputPort.present(Result)
|
||||||
|
|
||||||
components -> services -> (api + presenters) -> (dtos + view-models)
|
API Presenter (Adapter)
|
||||||
|
→ maps Result → ApiResponse
|
||||||
|
|
||||||
Strict rules:
|
API Controller
|
||||||
• components may import only view-models and services
|
→ returns ApiResponse (JSON)
|
||||||
• presenters may import dtos and view-models only
|
|
||||||
• api may import dtos only
|
Frontend API Client
|
||||||
• services may import api, presenters, view-models
|
→ returns ApiResponse DTO
|
||||||
|
|
||||||
|
Website Presenter
|
||||||
|
→ maps DTO → ViewModel
|
||||||
|
|
||||||
|
UI Component
|
||||||
|
→ consumes ViewModel
|
||||||
|
|
||||||
Forbidden:
|
|
||||||
• components importing api
|
|
||||||
• components importing dtos
|
|
||||||
• presenters importing api
|
|
||||||
• api importing view-models
|
|
||||||
• any website code importing core domain entities
|
|
||||||
|
|
||||||
⸻
|
⸻
|
||||||
|
|
||||||
10. Naming rules (strict)
|
8. Terminology Rules (Strict)
|
||||||
• View Models end with ViewModel
|
|
||||||
• API DTOs end with Dto
|
Term Layer Meaning
|
||||||
• Presenters end with Presenter
|
OutputPort Core Use case output contract
|
||||||
• Services end with Service
|
Result Core Pure application result
|
||||||
• One export per file
|
Presenter (API) apps/api Maps Result → API Response
|
||||||
• File name equals exported symbol (PascalCase)
|
Response / ApiResponse apps/api HTTP transport shape
|
||||||
|
Presenter (Website) apps/website Maps DTO → ViewModel
|
||||||
|
ViewModel apps/website UI-ready state
|
||||||
|
|
||||||
|
No term is reused with a different meaning.
|
||||||
|
|
||||||
⸻
|
⸻
|
||||||
|
|
||||||
11. Final "no ambiguity" summary
|
9. Non-Negotiable Rules
|
||||||
• View Models live in apps/website/lib/view-models
|
• Core has no DTOs
|
||||||
• API DTOs live in apps/website/lib/dtos
|
• Core has no View Models
|
||||||
• Presenters live in apps/website/lib/presenters and map DTO -> ViewModel
|
• API has no View Models
|
||||||
• API client lives in apps/website/lib/api and returns DTOs only
|
• Frontend has no Core Results
|
||||||
• Services live in apps/website/lib/services and return View Models only
|
• View Models exist only in the frontend
|
||||||
• Components consume View Models only and never touch API DTOs or API clients
|
• Presenters mean different things per layer, but:
|
||||||
|
• Core = Output Port
|
||||||
|
• API = Adapter
|
||||||
|
• Website = Mapper
|
||||||
|
|
||||||
⸻
|
⸻
|
||||||
|
|
||||||
12. Clean Architecture Flow Diagram
|
10. Final Merksatz
|
||||||
|
|
||||||
```mermaid
|
The Core emits results.
|
||||||
graph TD
|
The API transports them.
|
||||||
A[UI Components] --> B[Services]
|
The Frontend interprets them.
|
||||||
B --> C[API Client]
|
|
||||||
B --> D[Presenters]
|
|
||||||
C --> E[API DTOs]
|
|
||||||
D --> E
|
|
||||||
D --> F[View Models]
|
|
||||||
A --> F
|
|
||||||
|
|
||||||
style A fill:#e1f5fe
|
If a type tries to do more than one of these — it is incorrectly placed.
|
||||||
style B fill:#f3e5f5
|
|
||||||
style C fill:#fff3e0
|
|
||||||
style D fill:#e8f5e8
|
|
||||||
style E fill:#ffebee
|
|
||||||
style F fill:#e3f2fd
|
|
||||||
```
|
|
||||||
|
|
||||||
**Flow Explanation:**
|
|
||||||
- UI Components consume only View Models
|
|
||||||
- Services orchestrate API calls and presenter mappings
|
|
||||||
- API Client returns raw API DTOs
|
|
||||||
- Presenters transform API DTOs into UI-ready View Models
|
|
||||||
- Strict dependency direction: UI → Services → (API + Presenters) → (DTOs + ViewModels)
|
|
||||||
|
|
||||||
⸻
|
|
||||||
|
|
||||||
13. Enforcement Guidelines
|
|
||||||
|
|
||||||
**ESLint Rules:**
|
|
||||||
- Direct imports from `apiClient` are forbidden - use services instead
|
|
||||||
- Direct imports from `dtos` in UI components are forbidden - use ViewModels instead
|
|
||||||
- Direct imports from `api/*` in UI components are forbidden - use services instead
|
|
||||||
|
|
||||||
**TypeScript Path Mappings:**
|
|
||||||
- Use `@/lib/dtos` for API DTO imports
|
|
||||||
- Use `@/lib/view-models` for View Model imports
|
|
||||||
- Use `@/lib/presenters` for Presenter imports
|
|
||||||
- Use `@/lib/services` for Service imports
|
|
||||||
- Use `@/lib/api` for API client imports
|
|
||||||
|
|
||||||
**Import Restrictions:**
|
|
||||||
- Components may import only view-models and services
|
|
||||||
- Presenters may import dtos and view-models only
|
|
||||||
- API may import dtos only
|
|
||||||
- Services may import api, presenters, view-models
|
|
||||||
- Forbidden: components importing api, components importing dtos, presenters importing api, api importing view-models
|
|
||||||
|
|
||||||
**Verification Commands:**
|
|
||||||
```bash
|
|
||||||
npm run build # Ensure TypeScript compiles
|
|
||||||
npm run lint # Ensure ESLint rules pass
|
|
||||||
npm run test # Ensure all tests pass
|
|
||||||
```
|
|
||||||
|
|
||||||
⸻
|
|
||||||
|
|
||||||
14. Architecture Examples
|
|
||||||
|
|
||||||
**Before (Violates Rules):**
|
|
||||||
```typescript
|
|
||||||
// In a page component - BAD
|
|
||||||
import { apiClient } from '@/lib/apiClient';
|
|
||||||
import type { RaceResultDto } from '@/lib/dtos/RaceResultDto';
|
|
||||||
|
|
||||||
const RacePage = () => {
|
|
||||||
const [data, setData] = useState<RaceResultDto[]>();
|
|
||||||
// Direct API call and DTO usage in UI
|
|
||||||
useEffect(() => {
|
|
||||||
apiClient.getRaceResults().then(setData);
|
|
||||||
}, []);
|
|
||||||
return <div>{data?.map(d => d.position)}</div>;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**After (Clean Architecture):**
|
|
||||||
```typescript
|
|
||||||
// In a page component - GOOD
|
|
||||||
import { RaceResultsService } from '@/lib/services/RaceResultsService';
|
|
||||||
import type { RaceResultViewModel } from '@/lib/view-models/RaceResultViewModel';
|
|
||||||
|
|
||||||
const RacePage = () => {
|
|
||||||
const [data, setData] = useState<RaceResultViewModel[]>();
|
|
||||||
useEffect(() => {
|
|
||||||
RaceResultsService.getResults().then(setData);
|
|
||||||
}, []);
|
|
||||||
return <div>{data?.map(d => d.formattedPosition)}</div>;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**Service Implementation:**
|
|
||||||
```typescript
|
|
||||||
// apps/website/lib/services/RaceResultsService.ts
|
|
||||||
import { apiClient } from '@/lib/api';
|
|
||||||
import { RaceResultsPresenter } from '@/lib/presenters/RaceResultsPresenter';
|
|
||||||
|
|
||||||
export class RaceResultsService {
|
|
||||||
static async getResults(): Promise<RaceResultViewModel[]> {
|
|
||||||
const dtos = await apiClient.getRaceResults();
|
|
||||||
return RaceResultsPresenter.present(dtos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Presenter Implementation:**
|
|
||||||
```typescript
|
|
||||||
// apps/website/lib/presenters/RaceResultsPresenter.ts
|
|
||||||
import type { RaceResultDto } from '@/lib/dtos/RaceResultDto';
|
|
||||||
import type { RaceResultViewModel } from '@/lib/view-models/RaceResultViewModel';
|
|
||||||
|
|
||||||
export class RaceResultsPresenter {
|
|
||||||
static present(dtos: RaceResultDto[]): RaceResultViewModel[] {
|
|
||||||
return dtos.map(dto => ({
|
|
||||||
id: dto.id,
|
|
||||||
formattedPosition: `${dto.position}${dto.position === 1 ? 'st' : dto.position === 2 ? 'nd' : dto.position === 3 ? 'rd' : 'th'}`,
|
|
||||||
driverName: dto.driverName,
|
|
||||||
// ... other UI-specific formatting
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Reference in New Issue
Block a user