63 KiB
Website Architecture Violations Report
Scope: apps/website/** aligned against docs/architecture/website/**, with the authoritative contract at WEBSITE_CONTRACT.md.
This report lists violations as: rule ⇒ evidence ⇒ impact ⇒ fix direction.
This version also includes a concrete remediation plan with file-by-file actions.
0) Target architecture (what good looks like)
Per-route structure required by WEBSITE_RSC_PRESENTATION.md:
app/<route>/page.tsx server: composition only
lib/page-queries/<Route>... server: fetch + assemble Page DTO + return PageQueryResult
app/<route>/<Route>PageClient.tsx client: ViewData creation + client state
templates/<Route>Template.tsx client: pure UI, ViewData-only, no computation
lib/view-models/** client-only: ViewModels + Presenters (pure)
lib/display-objects/** deterministic formatting helpers (no locale APIs)
Hard boundaries to enforce:
- No server code imports from
lib/view-models/**(guardrail:WEBSITE_GUARDRAILS.md). - Templates import neither
lib/view-models/**norlib/display-objects/**(guardrail:WEBSITE_GUARDRAILS.md). - No locale APIs (
Intl.*,toLocale*) in any formatting path (guardrails:VIEW_DATA.md,WEBSITE_GUARDRAILS.md). - All writes enter via Server Actions (contract:
FORM_SUBMISSION.md).
0.1 Naming: stop calling everything “dto” (clarify the model taxonomy)
This repo currently uses dto as a generic variable name, which collapses three different concepts.
We will use the contract’s model names and enforce naming conventions to prevent category errors.
Authoritative types:
-
API Transport DTO: returned by
apps/apiover HTTP. In this repo, often generated from OpenAPI.- Canonical placement:
apps/website/lib/types/**perWEBSITE_CONTRACT.md. - Naming rule:
- Type names end with
ApiDto(preferred) orDTO(existing generated types). - Variable names:
apiDto,apiResponse.
- Type names end with
- Canonical placement:
-
Page DTO: website-owned server-to-client payload assembled by PageQueries.
- Canonical placement:
apps/website/lib/page-queries/**perWEBSITE_CONTRACT.md. - Naming rule:
- Type names end with
PageDto. - Variable names:
pageDto.
- Type names end with
- Canonical placement:
-
ViewData: the only allowed input to Templates.
- Canonical placement:
apps/website/templates/**perWEBSITE_CONTRACT.md. - Naming rule:
- Type names end with
ViewData. - Variable names:
viewData.
- Type names end with
- Canonical placement:
Other model names we should keep explicit:
-
ViewModel: client-only class instance.
- Naming rule:
*ViewModeltypes/classes, variablesviewModel.
- Naming rule:
-
Command DTO (write intent): the payload sent to Server Actions / API for mutations.
- Naming rule:
*CommandDto, variablescommandDto.
- Naming rule:
Non-negotiable rule:
- Never use a bare
dtovariable name. UseapiDto,pageDto,viewData, orcommandDto.
This is a Clean Architecture guardrail: when names are precise, wrong-layer dependencies become obvious during review.
0.2 Enforce the taxonomy with required abstractions (not lint)
You asked for “proper abstractions that must be implemented” (interfaces and/or abstract classes), rather than naming-only or ESLint enforcement.
The goal is that the compiler (and the module boundaries) makes it hard to mix:
- API Transport DTO vs Page DTO vs ViewData
- server-only vs client-only
- orchestration vs presentation mapping
Proposed required abstractions (website-owned):
-
API Client contracts (transport boundary)
- Interface per API area, returns ApiDto/DTO only.
- Example:
LeaguesApiClientalready exists; enforce that it returns*DTOtypes only. - Prohibit returning ViewModels.
-
Service contracts (orchestration boundary, server-safe)
- Interface per feature:
*Servicereturns ApiDto or PageDto only. - Must be stateless.
- Must not import
lib/view-models/**.
- Interface per feature:
-
PageQuery contract (server composition boundary)
- A generic interface:
execute(params) -> PageQueryResult<PageDto>
- PageQueries must only depend on API clients + services (manual wiring).
- Must not import
lib/view-models/**.
- A generic interface:
-
Presenter contract (pure mapping boundary, client-only)
- Presenter is a pure mapper:
PageDto -> ViewDataORPageDto -> ViewModelORViewModel -> ViewData
- No HTTP, no side effects.
- Presenter is a pure mapper:
-
ViewModel base (client-only)
- Optional but useful to enforce non-serializability:
- e.g.
abstract class ViewModel { abstract toViewData(): ViewData }
- e.g.
- No
toDTO()method that resembles server DTOs (it tempts server usage).
- Optional but useful to enforce non-serializability:
-
DisplayObject base (deterministic formatting)
- Abstract base or interface to enforce “no locale APIs” policy by convention and review.
- Encourages
new MoneyDisplay(amount).formatted()style, but only used from Presenters/ViewModels.
Implementation note:
- These abstractions live in
apps/website/lib/contracts/**so every feature must conform. - This is complementary to guardrails: guardrails prevent forbidden imports; contracts ensure the correct “shape” of each layer.
1) Remediation plan (explicit actions)
This is the minimum actionable work to align the codebase. It is ordered so earlier steps unblock later ones.
1.1 Block server-side container usage (DI contract)
Goal: zero usages of ContainerManager.getInstance().getContainer() in server execution paths.
Actions (final standard):
-
Ban DI from server execution paths entirely.
apps/website/app/**/page.tsxandapps/website/lib/page-queries/**MUST NOT use DI.- PageQueries MUST do manual wiring (construct API clients + services explicitly).
-
Delete or rewrite any helper that makes server DI easy.
- Replace any server usage of
PageDataFetcher.fetch()with manual wiring. - Stop calling
ContainerManager.getInstance().getContainer()from server.
- Replace any server usage of
-
Enforcement:
- Add a guardrail test/ESLint rule that forbids importing
ContainerManagerfrom:apps/website/app/**/page.tsxapps/website/lib/page-queries/**
- Add a guardrail test/ESLint rule that forbids importing
Deliverable for this section:
- A CI-failing rule that prevents future regressions.
- A grep for
getContainer()in server modules returns zero hits.
Rationale: why DI is banned on the server side
This is a deliberate safety simplification.
-
Next.js server execution is concurrent; DI containers make it easy to accidentally share state across requests.
- The contract explicitly warns about this risk (
WEBSITE_CONTRACT.md,WEBSITE_DI_RULES.md). - The singleton container is explicitly called out as unsafe (
ContainerManager,WEBSITE_DI_RULES.md).
- The contract explicitly warns about this risk (
-
The codebase already contains stateful “service” patterns (e.g. blockers stored as instance fields), which are harmless in a per-request graph but dangerous in a shared container.
- Example:
LeagueServicestoressubmitBlocker.
- Example:
-
Clean Architecture intent: server modules are composition roots; manual wiring makes dependencies explicit and reviewable.
- This reduces hidden coupling and prevents accidental imports of client-only types like
lib/view-models/**.
- This reduces hidden coupling and prevents accidental imports of client-only types like
Note: the strict contract technically allows request-scoped server DI (WEBSITE_DI_RULES.md), but we choose the stronger rule (manual wiring only) to eliminate an entire class of cross-request bugs and enforcement ambiguity.
1.2 Standardize PageQuery contract (one discriminated union)
Goal: all PageQueries return the exact contract described in WEBSITE_PAGE_QUERIES.md.
Actions:
- Define a single
PageQueryResulttype in a shared place underapps/website/lib/page-queries/**. - Update all PageQueries to return:
okwith{ dto }notFoundredirectwith{ to }errorwith{ errorId }(do not return raw Error objects)
Example violation:
- Current local type uses
data/destination:PageQueryResult<TData>
Deliverable:
- All pages use
switch(result.status)and callnotFound()/redirect()only frompage.tsx.
1.3 Fix Template purity (ViewData-only, no imports, no compute)
Goal: Templates become “dumb” renderers: no useMemo, no filtering/sorting, no ViewModel or DisplayObject imports.
Actions:
-
Remove ViewModel props from Templates.
- Example: replace
DriverRankingsTemplateProps.drivers: DriverLeaderboardItemViewModel[]withdrivers: DriverRankingsViewData[](a ViewData type).
- Example: replace
-
Remove compute from Templates.
- Example: move
drivers.filter()andsort(...)into:- a ViewModel (client) OR
- a Presenter (client) OR
- the PageClient container (client) if it is driven by UI state.
- Example: move
-
Remove DisplayObject usage from Templates.
- Example violation:
LeagueRoleDisplay - Replace with primitive values in ViewData (badge label, badge classes).
- Example violation:
Deliverable:
- Guardrail tests (or ESLint) that fail if any
apps/website/templates/**imports from:apps/website/lib/view-models/**apps/website/lib/display-objects/**
1.4 Eliminate locale APIs from all formatting paths
Goal: zero usage of Intl.* and toLocale* in:
apps/website/templates/**apps/website/app/**/page.tsxapps/website/lib/view-models/**apps/website/lib/display-objects/**- shared formatting helpers like
time.ts
Actions:
-
Replace locale formatting in Templates.
- Example:
toLocaleDateString() - Replace with: (a) deterministic formatter in a Display Object, or (b) API-provided display labels.
- Example:
-
Replace locale formatting in ViewModels.
- Example:
BillingViewModel.InvoiceViewModel.formattedTotalAmount - Replace with deterministic formatting helpers (no runtime locale).
- Example:
-
Remove locale formatting helpers in
lib/utilities.- Example:
formatDate() - Replace with deterministic formatters.
- Example:
Deliverable:
- The search pattern
\\bIntl\\.|toLocalereturns zero results for production code.
1.5 Enforce write boundary: Server Actions only
Goal: no client-initiated writes (no fetch POST/PUT/PATCH/DELETE from client components).
Actions:
- Replace client logout POST with Server Action.
- Example violation:
fetch('/api/auth/logout', { method: 'POST' }) - Replace with a Server Action and use
<form action={logoutAction}>. - Ensure the action does the mutation and then triggers navigation and/or revalidation.
- Example violation:
Deliverable:
- Search for
fetch(with write methods in client components returns zero hits.
1.6 Remove UX Blockers from services (state leakage risk)
Goal: services remain stateless and safe regardless of DI scope.
Actions:
- Remove or relocate blockers from
LeagueService.- Evidence:
submitBlockerandthrottle
- Evidence:
- Re-introduce blockers at the client boundary (component/hook) where they belong.
Deliverable:
- No stateful blockers stored on service instances.
1.7 Consolidate hooks into the canonical folder
Goal: one place for React-only helpers: apps/website/lib/hooks/**.
Contract:
- Canonical placement is
apps/website/lib/hooks/**(WEBSITE_FILE_STRUCTURE.md,WEBSITE_CONTRACT.md).
Actions:
- Move
apps/website/hooks/**→apps/website/lib/hooks/**. - Update imports across
apps/website/**. - Remove
hooks/from TS include list intsconfig.json. - Add a guardrail test to fail if
apps/website/hooks/**reappears.
1.8 Tighten the Service boundary (server-safe only)
Goal: services become safe to call from server modules (Page Queries) with zero ambiguity.
New non-negotiable rule:
apps/website/lib/services/**returns API DTOs or Page DTOs only (JSON-serializable). It MUST NOT import or instantiate anything fromapps/website/lib/view-models/**.
Why (Clean Architecture + SOLID):
- SRP: services orchestrate IO and composition; they do not prepare UI.
- DIP: server modules depend on service interfaces/DTO contracts, not client-only classes.
- Eliminates the current “maybe server, maybe client” ambiguity.
Actions:
- Refactor any service returning ViewModels to return DTOs/Page DTOs instead.
- Move mapping/formatting responsibilities into Presenters colocated with ViewModels.
- Add a boundary test: forbid
lib/services/**importinglib/view-models/**.
1.9 Remove AdminViewModelService (Presenter misclassified as Service)
Goal: all mapping lives in Presenters, not in services.
Action:
- Replace
AdminViewModelServicewith a Presenter colocated with its ViewModel(s), then delete the service + test.
1.10 Fix the confirmed “PageQuery constructs ViewModel” violation
Evidence:
ProfilePageQuery.execute()resolvesDriverServiceand then callsviewModel.toDTO()(ProfilePageQuery).
Rule violated:
- Page Queries MUST NOT instantiate ViewModels (
WEBSITE_PAGE_QUERIES.md).
Action:
- Refactor any service method used by a PageQuery that currently returns a ViewModel to return a Page DTO instead.
12) Generic integrity rules for untrusted transport data (no case studies)
This is the durable architectural rule behind the “as looks vulnerable” concern.
12.1 Rule: treat API Transport DTO values as untrusted input
Even with OpenAPI generation, runtime values can drift (backend bug, contract mismatch, migrations, older clients).
Therefore:
- Never use
as SomeClosedUnionon fields coming from an API response. - Never assume string enums are safe.
12.2 Where validation/coercion belongs
- API Transport DTO remains raw (what the API sent).
- Page DTO can remain raw but should be structurally stable.
- Presenter/ViewModel is the correct place to normalize/coerce for UI resilience.
This keeps the website as a delivery layer: we’re not enforcing business rules; we’re preventing UI crashes.
12.3 Required pattern: parsers for string unions
Define small pure parsers (in a Presenter-adjacent module) for every “closed set” field:
parseSocialPlatform(value: unknown): SocialPlatform | 'unknown'parseAchievementIcon(value: unknown): AchievementIcon | 'unknown'parseAchievementRarity(value: unknown): AchievementRarity | 'unknown'
Policy (agreed):
- ViewModel keeps
'unknown'for debugging/telemetry. - ViewData omits unknown items (UI stays clean).
This keeps code safe without turning the website into a second source of truth (the API still owns validation).
2) Route-by-route refactor recipes (concrete file actions)
This section tells you exactly what to change for the already-identified hot spots.
2.1 profile/leagues route
Current violations:
- DI container used on server:
ContainerManager.getInstance().getContainer() - Inline server “template” inside
page.tsx:ProfileLeaguesTemplate() - Server code manipulates ViewModels:
LeagueSummaryViewModel
Required changes:
-
Create a PageQuery:
apps/website/lib/page-queries/ProfileLeaguesPageQuery.ts.- It should:
- Read session (server-side) and determine
currentDriverId. - Call API clients directly (manual wiring).
- Assemble a Page DTO like:
ownedLeagues: Array<{ leagueId; name; description; membershipRole }>memberLeagues: Array<{ leagueId; name; description; membershipRole }>
- Return
PageQueryResult.
- Read session (server-side) and determine
- It should:
-
Replace
apps/website/app/profile/leagues/page.tsxwith composition:- Call PageQuery.
- Switch on result.
- Render
<ProfileLeaguesPageClient dto={dto} />.
-
Create
apps/website/app/profile/leagues/ProfileLeaguesPageClient.tsx('use client').- Convert Page DTO into ViewData (via Presenter function in
lib/view-models/**). - Render a pure Template.
- Convert Page DTO into ViewData (via Presenter function in
-
Create
apps/website/templates/ProfileLeaguesTemplate.tsx.- Props: ViewData only.
- No API calls. No
useMemo. No filtering/sorting.
2.2 teams routes
Current violations:
- Server route does sorting/filtering:
computeDerivedData() - Server route imports ViewModels:
TeamSummaryViewModel - Team detail server route passes ViewModels into Template:
TeamDetailTemplateWrapper() - Placeholder “currentDriverId” on server:
const currentDriverId = ''
Required changes:
-
Introduce PageQueries:
TeamsPageQueryreturns Page DTO{ teams: Array<{ id; name; rating; ...raw fields }> }.TeamDetailPageQueryreturns Page DTO{ team; memberships; currentDriverId; }(raw serializable).
-
Move derived computations out of server route.
teamsByLevel,topTeams, etc. belong in client ViewModel or PageClient.
-
Add
TeamsPageClient.tsxandTeamDetailPageClient.tsx.- Instantiate ViewModels client-side.
- Produce ViewData for Templates.
-
Change Templates to take ViewData.
- Stop passing ViewModels into
TeamDetailTemplate.
- Stop passing ViewModels into
2.3 dashboard PageQuery
Current violations:
- Server PageQuery uses singleton DI container:
ContainerManager.getInstance().getContainer() - PageQuery imports from
lib/view-models/**:DashboardOverviewViewModelData
Required changes:
- Move the DTO-like shape out of view-models and into
lib/page-queries/**. - Replace DI with manual wiring.
- Return
PageQueryResultas spec. - Ensure the result payload is a Page DTO (raw values only).
2.4 Logout write boundary
Current violation:
- Client-side POST request:
fetch('/api/auth/logout', { method: 'POST' })
Required changes:
- Create a server action in an appropriate server-only module.
- Replace the demo-user logout handler with a
<form action={logoutAction}>submission. - Ensure post-logout navigation uses server-side redirect.
3) What to add to make this enforceable (guardrails)
Minimum guardrails to add:
- ESLint or test guard: fail on
toLocale*/Intl.*underapps/website/**formatting paths. - ESLint or test guard: fail if
apps/website/templates/**imports ViewModels or Display Objects. - ESLint or test guard: fail if
apps/website/app/**/page.tsximports fromlib/view-models/**. - ESLint or test guard: fail if server code imports
ContainerManager.
4) Missing pieces found in the second scan (add to plan)
This section is the “coverage pass”: it enumerates additional concrete hotspots that must be refactored using the recipes above.
4.1 Remaining server-side singleton container usage (must be removed)
-
Home SSR data fetch uses container directly
- Evidence:
ContainerManager.getInstance().getContainer() - Action: rewrite
getHomeData()to use explicit wiring (manual construction) or move the logic into a PageQuery that does manual wiring.
- Evidence:
-
Profile page query dynamically imports container and uses singleton
- Evidence:
(await import('@/lib/di/container')).ContainerManager.getInstance().getContainer()inProfilePageQuery - Action: ban all container usage in
apps/website/lib/page-queries/**; refactor to manual wiring.
- Evidence:
Deliverable:
- Add these files to the “no server DI” guardrail allowlist/banlist:
4.2 Routes still importing ViewModels from server modules (must be converted to Page DTO + PageClient)
The following are server route modules (App Router) that import ViewModels and therefore violate the server/client boundary.
Actions for each: create a PageQuery returning Page DTO, and a *PageClient.tsx that builds ViewData and renders the Template.
-
Leaderboards entry route
- Evidence: ViewModel types imported in
leaderboards/page.tsx - Action:
- Replace
*PageWrapperpatterns with a*PageClientcontainer. - Ensure the template receives ViewData only (no ViewModels).
- Remove ViewModel imports from:
- Replace
- Evidence: ViewModel types imported in
-
Driver leaderboards route
- Evidence: ViewModel import in
leaderboards/drivers/page.tsx - Action: same as above; rename wrapper to
DriverRankingsPageClient.tsxand pass ViewData to Templates.
- Evidence: ViewModel import in
-
Team leaderboard route
- Evidence: ViewModel import in
teams/leaderboard/page.tsxand wrapper import inTeamLeaderboardPageWrapper.tsx - Action: same as above.
- Evidence: ViewModel import in
-
Leagues routes
- Evidence:
- Action:
- Introduce PageQueries per route:
LeagueDetailPageQuery,LeagueRulebookPageQuery,LeagueSchedulePageQuery,LeagueStandingsPageQuery. - PageQueries return Page DTO only.
- Client creates ViewModels + ViewData.
- Introduce PageQueries per route:
-
Sponsor routes
- Evidence: server route imports ViewModel and performs reduce/filter on ViewModel instance:
AvailableLeaguesViewModel - Action:
- Convert to PageQuery returning Page DTO.
- Move summary computations (counts/averages) to Presenter/ViewModel in client.
- Evidence: server route imports ViewModel and performs reduce/filter on ViewModel instance:
-
Races + stewarding routes
- Evidence:
- ViewModel imports in
races/[id]/page.tsx - Non-canonical hooks usage and ViewModel transformer import in
races/[id]/results/page.tsx - ViewModel import in
races/[id]/stewarding/page.tsx
- ViewModel imports in
- Action:
- Convert each to PageQuery + PageClient.
- Move hooks from top-level
apps/website/hooks/**intoapps/website/lib/hooks/**(canonical perWEBSITE_FILE_STRUCTURE.md). - Rename the “transformer” pattern to a Presenter under
lib/view-models/**and ensure it stays deterministic.
- Evidence:
-
Profile routes (beyond
profile/leagues)- Evidence: ViewModel import and locale formatting in
profile/page.tsxandtoLocaleDateStringusage (e.g.profile/page.tsx) - Action: convert to PageQuery + ProfilePageClient + ViewData.
- Evidence: ViewModel import and locale formatting in
-
Races listing route doing server-side filtering
- Evidence:
races/all/page.tsxuses.filterin route module - Action: same route split; if filtering is canonical (not UX-only), push it into API; if UX-only, do it client-side in a ViewModel.
- Evidence:
4.3 “Templates” living under app/** (structural and guardrail gap)
The architecture assumes Templates are under apps/website/templates/**.
- Evidence: wallet UI is a “Template” in a route folder:
WalletTemplate.tsxand it imports a ViewModel:LeagueWalletViewModel
Actions:
- Move this file to
apps/website/templates/LeagueWalletTemplate.tsx. - Ensure it takes ViewData only.
- Add a guardrail: forbid
*Template.tsxunderapps/website/app/**.
4.4 Remaining write-boundary violations (client-side POST)
Additional client-side logout write:
Actions:
- Replace with Server Action flow.
- Ensure any other write HTTP calls from client code are removed (search pattern already exists).
4.5 Locale formatting still present outside Templates (components + lib)
The strict contract forbids locale APIs in formatting paths; the codebase currently uses toLocale* heavily in:
- Components (examples):
- Infrastructure (example):
- ViewModels (examples):
Actions (make it explicit):
- Introduce deterministic formatting primitives under
apps/website/lib/display-objects/**(or a dedicated deterministic formatter module) and route all formatting through them. - Replace locale formatting calls in ViewModels first (largest fan-out).
- Replace locale formatting in components by ensuring components consume ViewData strings/numbers that are already formatted deterministically.
- Add guardrails for
apps/website/components/**too (not only Templates), otherwise the same issue migrates.
5) Third scan: contract checks against the questions
This section explicitly answers:
- do we have any filtering, sorting, formatting or any business logic in pages or ui components?
- do we have clear separation of concerns?
- did you consider correct DI usage?
- did you consider server actions?
5.1 Filtering, sorting, formatting, business logic in app/**
Contract expectations:
app/**/page.tsxshould be server composition only (WEBSITE_RSC_PRESENTATION.md,WEBSITE_GUARDRAILS.md).- Formatting must be deterministic and must not use
Intl.*ortoLocale*(VIEW_DATA.md).
Observed violations (representative, not exhaustive):
| Category | Evidence in app/** |
Why this violates the contract | Required remediation |
|---|---|---|---|
Filtering/sorting in page.tsx |
reduce/filter/sort in computeDerivedData() |
Server route does presentation shaping (guardrail violation). | Convert to PageQuery + TeamsPageClient.tsx; keep page.tsx orchestration only. |
| Filtering inside route module | Filtering races in races/all/page.tsx |
Filtering belongs in API (canonical) or in client ViewModel/PageClient (UX-only), not in route module. | Split route into PageQuery + PageClient. |
| “Authorization-like” decisions in route modules | isAdmin derived from role in races/[id]/results/page.tsx and races/[id]/stewarding/page.tsx |
Website may hide UI for UX but must not drift into enforcing security truth (WEBSITE_CONTRACT.md). These checks are mixed with data/load/render responsibilities. |
Ensure these are strictly UI flags derived from API truth and produced in client ViewModels/Presenters. |
| Locale formatting in route modules | toLocaleDateString/toLocaleString in profile/page.tsx and sponsor/campaigns/page.tsx |
Forbidden locale APIs in formatting paths. | Replace with deterministic formatting, passed via ViewData. |
| ViewModel instantiation in routes | new DriverViewModel(...) in leagues/[id]/standings/page.tsx |
ViewModels are client-only (VIEW_MODELS.md). |
Replace with Page DTO; create ViewModels client-side only. |
5.2 Filtering, sorting, formatting, business logic in components/**
Observed violations (high signal):
| Category | Evidence in components/** |
Why this violates the contract | Required remediation |
|---|---|---|---|
| Locale formatting in components | toLocale* in TeamAdmin.tsx, DriverTopThreePodium.tsx, NotificationCenter.tsx |
Determinism/hydration risk; forbidden formatting path. | Ensure components consume deterministic ViewData strings/numbers produced by Presenters/Display Objects. |
| Client-initiated write calls | fetch('/api/auth/logout', { method: 'POST' }) in UserPill.tsx and AdminLayout.tsx |
Violates Server Action-only write boundary (FORM_SUBMISSION.md). |
Replace with Server Action invoked via <form action={...}>. |
5.3 Separation of concerns
Answer: No; boundaries are blurred.
Concrete evidence:
-
Templates import ViewModels and compute.
- Import example:
DriverLeaderboardItemViewModel - Compute example:
drivers.filter()andsort(...)
- Import example:
-
Templates exist under
app/**.- Example:
WalletTemplate.tsx
- Example:
-
Services construct ViewModels.
- Example:
return new RaceDetailViewModel(...)inRaceService - Example:
return ...map(... => new TeamSummaryViewModel(...))inTeamService
- Example:
Remediation:
- Server: PageQueries return Page DTO.
- Client: Presenters/ViewModels produce ViewData.
- Templates: ViewData-only, no compute.
5.4 DI usage
Answer: Not correct today; server uses the singleton container.
Confirmed violations:
- Server route DI:
ContainerManager.getInstance().getContainer() - Server PageQuery DI:
ContainerManager.getInstance().getContainer() - Server helper enabling DI:
PageDataFetcher.fetch() - Server service function DI:
getHomeData() - Dynamic import DI in PageQuery:
ProfilePageQuery
Remediation:
- Add guardrail: forbid server imports of
ContainerManager. - Refactor these call sites to manual wiring or request-scoped container.
5.5 Server Actions
Answer: Not fully enforced; client-side POSTs exist.
Confirmed violations:
- Client POST logout in
UserPill.tsx - Client POST logout in
AdminLayout.tsx - Client POST logout in
sponsor/settings/page.tsx
Remediation:
- Implement a Server Action for logout.
- Replace all client-side POSTs with
<form action={logoutAction}>. - Add guardrail to forbid
fetchwrite methods in client code.
A) DI contract violations (server-side singleton container usage)
| Rule | Evidence | Why this is a violation | Fix direction |
|---|---|---|---|
Server app/**/page.tsx MUST NOT access the DI container (WEBSITE_CONTRACT.md, WEBSITE_DI_RULES.md) |
ContainerManager.getInstance().getContainer() |
Server route is directly resolving from a singleton container; violates the client-first DI contract and request-safety. | Replace with a PageQuery + manual per-request wiring, or a request-scoped container factory. |
ContainerManager.getContainer() is client-only (WEBSITE_DI_RULES.md) |
PageDataFetcher.fetch() calls ContainerManager.getInstance().getContainer() |
This helper enables forbidden server access to the singleton container. | Ban server use of PageDataFetcher.fetch(). Use explicit construction in PageQueries. |
PageQueries may use DI only if request-scoped and stateless (WEBSITE_DI_RULES.md) |
DashboardPageQuery.execute() uses ContainerManager.getInstance().getContainer() |
PageQuery runs on the server; the singleton container is explicitly unsafe for concurrent server requests. | Convert to manual wiring (construct API client + service per call), or create a fresh container per request. |
B) RSC route structure violations (page.tsx doing more than composition)
| Rule | Evidence | Why this is a violation | Fix direction |
|---|---|---|---|
page.tsx does composition only (WEBSITE_FILE_STRUCTURE.md, WEBSITE_RSC_PRESENTATION.md) |
Data fetch + categorization + inline template in a server route module: fetchProfileLeaguesData() and ProfileLeaguesTemplate() |
Responsibilities are mixed: server data fetching, decision logic, and rendering composition in one module. | Split into per-route shape: page.tsx calls a PageQuery and passes Page DTO; a *PageClient.tsx builds ViewData; Template renders ViewData only. |
PageQueries MUST return the documented discriminated union (WEBSITE_PAGE_QUERIES.md) |
Local type differs from spec: PageQueryResult<TData> uses data and destination |
Breaks the documented contract (ok with dto, redirect with to). |
Standardize a single PageQueryResult type per WEBSITE_PAGE_QUERIES.md and enforce across queries. |
C) ViewModel boundary violations (ViewModels used/created on server and/or passed into Templates)
| Rule | Evidence | Why this is a violation | Fix direction |
|---|---|---|---|
ViewModels are client-only and never serialized (WEBSITE_CONTRACT.md, VIEW_MODELS.md) |
Server route data types include ViewModels: TeamDetailData.team: TeamDetailsViewModel and are passed into Template wrapper: TeamDetailTemplateWrapper() |
Implies ViewModels cross the server→client boundary, contradicting the “client-only class” rule and risking serialization/hydration issues. | Use Page DTOs for server-to-client, and instantiate ViewModels only in 'use client' modules. |
Templates accept ViewData only (WEBSITE_CONTRACT.md, VIEW_DATA.md) |
Template prop uses ViewModel instances: DriverRankingsTemplateProps.drivers: DriverLeaderboardItemViewModel[] |
Template is no longer receiving ViewData (JSON-serializable primitive shapes). | Introduce Presenters that map Page DTO → ViewData and pass ViewData into Templates. |
PageQueries must not instantiate client-only types / ViewModels (WEBSITE_PAGE_QUERIES.md) |
PageQuery imports from lib/view-models/**: DashboardOverviewViewModelData |
lib/view-models/** is the client-only boundary (also where Presenters live per PRESENTERS.md). |
Move Page DTO types under lib/page-queries/** and keep view-model types under lib/view-models/**. |
D) Template purity violations (Templates importing forbidden layers and doing computation)
| Rule | Evidence | Why this is a violation | Fix direction |
|---|---|---|---|
Templates MUST NOT import ViewModels (WEBSITE_GUARDRAILS.md) |
Example: DriverLeaderboardItemViewModel |
Template depends on client-only classes, drifting into Presenter/ViewModel responsibilities. | Replace ViewModel props with ViewData props. |
Templates MUST NOT import Display Objects (WEBSITE_GUARDRAILS.md) |
Example: LeagueRoleDisplay |
Display Objects are forbidden inside Templates. | Use Presenters/ViewModels to emit primitive strings into ViewData. |
Templates MUST NOT compute derived values (VIEW_DATA.md) |
Filtering/sorting occurs inside Template: drivers.filter() and sort(...) |
Template performs orchestration and non-trivial computation rather than rendering prepared ViewData. | Move to ViewModels (client) and/or Presenters; Templates render only. |
“Templates are pure” (WEBSITE_FILE_STRUCTURE.md) |
Hooks in templates: useMemo() used for filtering/grouping |
Pure-template contract is violated by stateful/reactive computations. | Create a *PageClient.tsx container for state/computation; keep template as pure function over ViewData. |
E) Determinism violations: forbidden locale/time formatting paths (Intl.*, toLocale*)
| Rule | Evidence | Why this is a violation | Fix direction |
|---|---|---|---|
Templates must not call locale APIs (VIEW_DATA.md, WEBSITE_GUARDRAILS.md) |
Example: toLocaleDateString() |
Locale/timezone-dependent formatting can differ between SSR and browser, risking hydration mismatches and non-determinism. | Replace with deterministic formatting via Display Objects, or API-provided labels, passed through Presenters into ViewData. |
Formatting code paths must not use locale APIs (DISPLAY_OBJECTS.md, VIEW_DATA.md) |
Example: toLocaleString() |
Even though ViewModels may format, this repo’s strict determinism rules forbid runtime-locale APIs. | Replace with deterministic numeric/date formatting helpers (no Intl.*, no toLocale*). |
Utility helpers used in presentation must not use locale APIs (WEBSITE_GUARDRAILS.md) |
formatDate() calls toLocaleDateString() |
Utilities become hidden sources of non-determinism across the app. | Replace these helpers with deterministic formatters and enforce via guardrail tests/ESLint. |
F) Page.tsx guardrail violations (sorting/filtering in server routes and forbidden imports)
| Rule | Evidence | Why this is a violation | Fix direction |
|---|---|---|---|
RSC page.tsx must not perform sorting/filtering beyond trivial checks (WEBSITE_GUARDRAILS.md) |
Server route computes derived values via reduce/filter/sort: computeDerivedData() |
Server route contains presentation shaping. | Move shaping into PageQuery output (as Page DTO only) and/or into client ViewModel/Presenter. |
RSC page.tsx must not import ViewModels (WEBSITE_GUARDRAILS.md) |
Example: TeamSummaryViewModel |
ViewModels are client-only. | Replace with Page DTO types; construct ViewModels client-side. |
G) Write-flow violations (writes not entering through Server Actions)
| Rule | Evidence | Why this is a violation | Fix direction |
|---|---|---|---|
All writes MUST enter through Server Actions (WEBSITE_CONTRACT.md, FORM_SUBMISSION.md) |
Client component performs POST: fetch('/api/auth/logout', { method: 'POST' }) |
Client-initiated writes are explicitly forbidden. | Replace with a Server Action invoked via <form action={...}> or button action, then revalidate/navigate. |
H) UX-only blockers embedded in services (state leakage risk)
| Rule | Evidence | Why this is a violation | Fix direction |
|---|---|---|---|
Blockers are UX-only and should be local + reversible (BLOCKERS.md, CLIENT_STATE.md) |
Stateful blockers inside a service: submitBlocker and throttle |
If the service is DI-singleton or reused across requests, this state can leak across users/requests; also mixes UI concerns into a service boundary. | Move blockers into client UI boundary (hook/component) or ensure strict client-only, per-instance usage. Services should remain stateless. |
6) Display Objects, Command Models, Blockers (fourth scan)
This section answers:
- Are Display Objects used as intended?
- Where should we introduce Display Objects instead of ad-hoc formatting/mapping?
- Are Command Models used as intended?
- Where should we introduce Command Models instead of ad-hoc form state/validation?
- Are Blockers used as intended?
- Where should we introduce Blockers instead of ad-hoc UX prevention?
6.1 Display Objects: current state vs contract
Contract excerpts:
- Display Objects are deterministic UI-only formatting/mapping helpers (
DISPLAY_OBJECTS.md). - Placement rule:
apps/website/lib/display-objects/**(WEBSITE_CONTRACT.md). - Must be class-based, immutable, deterministic (
DISPLAY_OBJECTS.md). - Must NOT call
Intl.*ortoLocale*(DISPLAY_OBJECTS.md). - Templates must not import Display Objects (guardrail:
WEBSITE_GUARDRAILS.md).
6.1.1 Violations: Display Objects imported from Templates
| Violation | Evidence | Why it matters | Required remediation |
|---|---|---|---|
| Template imports a Display Object | LeagueRoleDisplay |
Templates are required to be ViewData-only and must not import display objects. | Move role badge derivation into Presenter/ViewModel and pass { text, badgeClasses } as ViewData. |
6.1.2 Structural drift: “Display Objects” implemented as plain functions/records
The following files live under lib/display-objects/**, but they are not class-based value objects (they are exported maps/functions):
DashboardDisplay.tsProfileDisplay.tsLeagueRoleDisplay.ts(partly mitigated via wrapper class, but still map-backed)
This conflicts with the strict “class-based” rule in DISPLAY_OBJECTS.md. If we keep them as functions, we should either:
Clean-code direction (no exceptions):
- Refactor these modules into small, immutable classes with explicit APIs.
- Keep them deterministic and free of locale APIs.
6.1.3 Where Display Objects should be introduced (replace scattered formatting/mapping)
The repo currently performs formatting/mapping directly in Templates, route modules, components, and ViewModels. Those are prime candidates for Display Objects.
High-signal candidates found:
| Pattern | Evidence | Display Object to introduce | What the Display Object should output |
|---|---|---|---|
Date formatting (toLocaleDateString, toLocaleString) |
Many templates, e.g. RaceDetailTemplate.tsx, and route code e.g. profile/page.tsx |
DeterministicDateDisplay (class) |
{ shortDate, longDate, shortTime, dateTime } as strings based on ISO inputs, deterministic timezone policy. |
Number formatting (toLocaleString) |
Templates like DriverRankingsTemplate.tsx and components like MetricCard.tsx |
DeterministicNumberDisplay (class) |
{ compact, integer, fixed2 } or explicit formatting helpers; avoid runtime locale. |
| Currency formatting | ViewModels like BillingViewModel.ts |
MoneyDisplay (class) |
formatted string(s) given { currencyCode, minorUnits } or { currency, amount }. |
| Role badge mapping duplicated outside display objects | Switch in MembershipStatus.tsx and also display object usage elsewhere (StandingsTable.tsx) |
Consolidate as LeagueRoleBadgeDisplay |
{ text, badgeClasses } (or { text, bg, border, textColor }), deterministic. |
| Sponsorship status + time remaining UI mapping | Derived UI flags in sponsor/campaigns/page.tsx |
SponsorshipStatusDisplay |
{ statusText, statusClasses, isExpiringSoon, daysRemainingLabel } (pure). |
Important boundary rule: Display Objects must be used by Presenters/ViewModels, not by Templates. Templates should only see the primitive outputs.
6.2 Command Models: current state vs intended use
Contract excerpts:
- Command Models are for transient form state; UX validation only; never derived from ViewModels; never reused from read models (
FORM_SUBMISSION.md). - Canonical placement:
apps/website/lib/command-models/**(WEBSITE_CONTRACT.md).
6.2.1 Good usage already present
League wizarduses a command model:LeagueWizardCommandModel(consumed inCreateLeagueWizard.tsx).Authcommand models exist:LoginCommandModelandSignupCommandModel.Protestwrite intent uses a command model:ProtestDecisionCommandModel(constructed inprotests/[protestId]/page.tsx).
6.2.2 Violations / drift: Command-model-like logic living in components
Example: onboarding wizard duplicates field validation inline:
- Validation logic in
OnboardingWizard.tsx
Plan action:
- Create
apps/website/lib/command-models/onboarding/OnboardingCommandModel.ts. - Move field-level validation + errors to the command model.
- Keep the component responsible only for UI state (step navigation, show/hide) and invoking the server action.
Similarly, any other form pages that implement repeated validation strings/constraints should be moved into command models.
6.2.3 Server Actions integration: command models should be consumed at the boundary
Contract requires writes to enter via Server Actions.
Plan action (repeatable pattern):
- UI collects primitives → CommandModel instance (client-only) validates.
- Submit creates Command DTO (plain object) and calls a Server Action.
- Server Action performs UX validation (not business rules), calls API, redirects/revalidates.
6.3 Blockers: current state vs intended use
Contract excerpts:
- Blockers are UX-only, reversible helpers (
BLOCKERS.md). - Client state is UI-only; blockers are not security (
CLIENT_STATE.md). - Canonical placement:
apps/website/lib/blockers/**(BLOCKERS.md).
6.3.1 Violations: Blockers embedded in services (state leakage risk)
LeagueServicestores blockers as instance fields:submitBlockerandthrottle.LeagueWalletServicedoes the same:submitBlockerandthrottle.
Why this matters:
- If any service instance is shared (DI singleton, caching, module singletons), blockers become cross-user/cross-request state.
Plan action:
- Remove blockers from service instance state.
- Reintroduce blockers at the client boundary:
- component local state
- a
useSubmitBlocker()hook underapps/website/lib/hooks/** - or a per-interaction blocker instance created inside a client function.
6.3.2 Where blockers should be used instead of ad-hoc UX prevention
Candidates:
| UX need | Evidence | Recommended blocker |
|---|---|---|
| Prevent multiple logout clicks | Logout actions exist and currently do client POSTs: UserPill.tsx and AdminLayout.tsx |
SubmitBlocker at the UI boundary (button/form) |
| Throttle rapid filter/search updates | Multiple pages render lists with client filtering/sorting, e.g. DriverRankingsTemplate.tsx |
ThrottleBlocker inside a client container or hook (not in service) |
7) Hooks folder split (apps/website/hooks vs apps/website/lib/hooks)
Contract:
- React-only utilities MUST live under
apps/website/lib/hooks/**(WEBSITE_CONTRACT.md,WEBSITE_FILE_STRUCTURE.md).
Observation:
- Repo contains two hooks locations:
- Top-level:
apps/website/hooks/useCapability.ts(and many more) - Canonical:
apps/website/lib/hooks/useEnhancedForm.ts(and others)
- Top-level:
Violation:
| Violation | Evidence | Why it matters | Required remediation |
|---|---|---|---|
Hooks implemented outside canonical lib/hooks |
Many hooks under apps/website/hooks/** despite contract naming apps/website/lib/hooks/** |
Confuses imports/boundaries; increases chance hooks depend on route-level concerns or mix server/client incorrectly. Also violates the strict file-structure contract. | Move apps/website/hooks/** into apps/website/lib/hooks/**, update all imports, remove the top-level folder, and add a guardrail to prevent regression. |
Notes:
- The current
tsconfigexplicitly includes top-levelhooks/(tsconfig.json), which suggests this drift is intentional-but-undocumented.
8) AdminViewModelService violates ViewModel instantiation + Presenter placement rules
Contract:
- ViewModels are client-only classes; they are instantiated only in
'use client'modules (WEBSITE_CONTRACT.md). - Presenters (pure mappings) are colocated with ViewModels in
apps/website/lib/view-models/**(WEBSITE_CONTRACT.md). - Services are for orchestration, not mapping API DTOs into ViewModels (implied by data-flow contract:
WEBSITE_CONTRACT.md).
Evidence:
AdminViewModelServiceis alib/services/**module that performs mapping of API DTOs to ViewModels:- imports API DTO types from
AdminApiClient - instantiates
AdminUserViewModelet al. (AdminViewModelService.mapUser())
- imports API DTO types from
Violations:
| Violation | Evidence | Why it matters | Required remediation |
|---|---|---|---|
| ViewModels instantiated in a non-client module | new AdminUserViewModel(dto) in a service file with no 'use client' boundary |
If imported by server modules (Page Queries, RSC pages), it breaks the rule that ViewModels are client-only, and risks serializing or executing client-only assumptions on the server. | Move mapping into a Presenter colocated with the ViewModel (e.g. AdminUserViewModelPresenter or a sibling file) and ensure ViewModel creation happens only in client modules. |
| Presenter logic misclassified as a Service | All methods are pure mapping functions (AdminViewModelService.mapUsers()) |
Encourages importing “services” in server paths, causing architecture erosion (services become dumping grounds). | Rename/rehome as Presenter. Services should call API clients + return Page DTOs, not construct ViewModels. |
9) Alignment plan (approved direction: align code to contract)
9) Alignment plan (Clean Architecture / SOLID, no gaps)
Non-negotiable target:
app/**/page.tsx: server composition only (no business logic, no formatting, no filtering).lib/page-queries/**: server composition + IO only; returns Page DTO; no ViewModels; no DI singleton.- Server DI policy: no DI at all in PageQueries or
page.tsx(manual wiring only). lib/services/**: server-safe orchestration only; returns API DTO or Page DTO; no ViewModels; no blockers.lib/view-models/**+ Presenters: client-only; pure mapping to ViewData.templates/**: dumb renderer; ViewData only.
Naming enforcement (applies across all refactors):
- API response shapes are
ApiDto/DTOand stored/handled asapiDto. - PageQuery output is always named
pageDtoand typed*PageDto. - Template props are always
*ViewDataand passed asviewData.
Abstractions enforcement (applies across all refactors):
- Every PageQuery implements a common
PageQuery<PageDto>contract (inlib/contracts/page-queries/**). - Every service implements a
Serviceinterface that returnsApiDto/PageDtoonly (inlib/contracts/services/**). - Every Presenter implements
Presenter<Input, Output>withOutputbeingViewDataorViewModel(inlib/contracts/presenters/**). - ViewModels extend a shared base (in
lib/contracts/view-models/**) to discourage accidental server serialization patterns.
9.1 Guardrails first (prevent regression)
Add CI-failing checks (tests or ESLint rules). These come first to enforce Clean Architecture boundaries while refactors are in flight:
app/**/page.tsxMUST NOT import fromapps/website/lib/view-models/**.templates/**MUST NOT import fromapps/website/lib/view-models/**norapps/website/lib/display-objects/**.lib/page-queries/**MUST NOT import fromapps/website/lib/view-models/**.lib/services/**MUST NOT import fromapps/website/lib/view-models/**.- Forbid server imports of
ContainerManager. - Forbid
Intl.*andtoLocale*in all presentation paths. - Forbid
*Template.tsxunderapps/website/app/**.
Order of implementation (guardrails):
- Server DI ban: forbid server imports of
ContainerManagerand forbid use ofPageDataFetcher.fetch()from server modules. - Server/client boundary: forbid any imports of
apps/website/lib/view-models/**fromapps/website/app/**/page.tsxandapps/website/lib/page-queries/**. - Template boundary: forbid imports of view-models and display-objects from
apps/website/templates/**. - Service boundary: forbid imports of view-models from
apps/website/lib/services/**. - Determinism: forbid
Intl.*andtoLocale*in all presentation paths. - Write boundary: forbid client-side
fetchwith write methods in client modules.
These align cleanly with SRP (single reason to change per layer) and remove ambiguity.
9.2 Structural cleanups
- Move all hooks to
apps/website/lib/hooks/**(see §7 and §1.7). - Convert Display Objects to class-based, immutable modules (see §6.1.2).
- Delete
AdminViewModelServiceand replace with a Presenter.
9.3 Service boundary refactor (DTO-only)
-
For each service currently returning ViewModels (examples:
LeagueService,DashboardService,MediaService):- Change outputs to API DTOs or Page DTOs.
- Move any ViewModel instantiation into client Presenters.
- Remove blockers/state from services.
-
Fix the confirmed server/ViewModel leak:
ProfilePageQuerymust not callviewModel.toDTO().- It must call a service returning Page DTO and return that directly.
9.4 Route refactor recipe (repeatable)
For every route currently doing logic in page.tsx:
page.tsx: fetches via PageQuery, switches on result, renders a*PageClient.- PageQuery: calls services + assembles Page DTO only.
*PageClient.tsx: instantiates ViewModels (client-only) and produces ViewData via Presenters.- Template: renders ViewData only.
Write path (per-route):
app/<route>/actions.ts: Server Actions for that route only (mutations, UX validation, redirect/revalidate).*PageClient.tsx(or a client component) uses<form action={...}>to invoke the action.- Services called by actions remain DTO-only and do not contain UI logic.
This is the mechanism that keeps pages free from business logic and leaves no seams for drift.
9.5 ViewModels: allowed, but only behind the client boundary
You chose: keep ViewModels, enforce client-only.
Non-negotiable rules (to avoid gaps):
- No server module imports from
apps/website/lib/view-models/**. - No
apps/website/lib/services/**imports fromapps/website/lib/view-models/**. - A ViewModel instance is never passed into a Template.
- A ViewModel instance is never serialized.
Allowed flows:
- Page DTO → Presenter → ViewData → Template
- Page DTO → Presenter → ViewModel → Presenter → ViewData → Template
This keeps presentation state cohesive (ViewModels) while preserving a clean dependency direction (server depends on DTOs, client depends on ViewModels).
High-signal file sets (pattern-based indicators)
Templates importing ViewModels and or Display Objects (forbidden)
DriverRankingsTemplate.tsxLeagueDetailTemplate.tsxLeagueStandingsTemplate.tsxTeamDetailTemplate.tsx
Server routes importing ViewModels and or doing formatting/filtering (forbidden/discouraged)
Structural drift vs canonical lib/* layout
- API client exists at root
lib/instead oflib/api/:apiClient.ts - Non-canonical
lib/page/exists:PageDataFetcher.ts