56 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).
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.
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
DriverService.getDriverProfile()(and any similar methods) to return Page DTO only when used from server paths.
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.
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