Files
gridpilot.gg/plans/website-architecture-violations.md
2026-01-12 01:01:49 +01:00

63 KiB
Raw Blame History

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:

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 contracts model names and enforce naming conventions to prevent category errors.

Authoritative types:

  • API Transport DTO: returned by apps/api over HTTP. In this repo, often generated from OpenAPI.

  • Page DTO: website-owned server-to-client payload assembled by PageQueries.

    • Canonical placement: apps/website/lib/page-queries/** per WEBSITE_CONTRACT.md.
    • Naming rule:
      • Type names end with PageDto.
      • Variable names: pageDto.
  • ViewData: the only allowed input to Templates.

    • Canonical placement: apps/website/templates/** per WEBSITE_CONTRACT.md.
    • Naming rule:
      • Type names end with ViewData.
      • Variable names: viewData.

Other model names we should keep explicit:

  • ViewModel: client-only class instance.

    • Naming rule: *ViewModel types/classes, variables viewModel.
  • Command DTO (write intent): the payload sent to Server Actions / API for mutations.

    • Naming rule: *CommandDto, variables commandDto.

Non-negotiable rule:

  • Never use a bare dto variable name. Use apiDto, pageDto, viewData, or commandDto.

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):

  1. API Client contracts (transport boundary)

    • Interface per API area, returns ApiDto/DTO only.
    • Example: LeaguesApiClient already exists; enforce that it returns *DTO types only.
    • Prohibit returning ViewModels.
  2. Service contracts (orchestration boundary, server-safe)

    • Interface per feature: *Service returns ApiDto or PageDto only.
    • Must be stateless.
    • Must not import lib/view-models/**.
  3. 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/**.
  4. Presenter contract (pure mapping boundary, client-only)

    • Presenter is a pure mapper:
      • PageDto -> ViewData OR PageDto -> ViewModel OR ViewModel -> ViewData
    • No HTTP, no side effects.
  5. ViewModel base (client-only)

    • Optional but useful to enforce non-serializability:
      • e.g. abstract class ViewModel { abstract toViewData(): ViewData }
    • No toDTO() method that resembles server DTOs (it tempts server usage).
  6. 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):

  1. Ban DI from server execution paths entirely.

    • apps/website/app/**/page.tsx and apps/website/lib/page-queries/** MUST NOT use DI.
    • PageQueries MUST do manual wiring (construct API clients + services explicitly).
  2. Delete or rewrite any helper that makes server DI easy.

  3. Enforcement:

    • Add a guardrail test/ESLint rule that forbids importing ContainerManager from:
      • apps/website/app/**/page.tsx
      • apps/website/lib/page-queries/**

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.

  1. Next.js server execution is concurrent; DI containers make it easy to accidentally share state across requests.

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

  3. 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/**.

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:

  1. Define a single PageQueryResult type in a shared place under apps/website/lib/page-queries/**.
  2. Update all PageQueries to return:
    • ok with { dto }
    • notFound
    • redirect with { to }
    • error with { errorId } (do not return raw Error objects)

Example violation:

Deliverable:

  • All pages use switch(result.status) and call notFound() / redirect() only from page.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:

  1. Remove ViewModel props from Templates.

  2. Remove compute from Templates.

    • Example: move drivers.filter() and sort(...) into:
      • a ViewModel (client) OR
      • a Presenter (client) OR
      • the PageClient container (client) if it is driven by UI state.
  3. Remove DisplayObject usage from Templates.

    • Example violation: LeagueRoleDisplay
    • Replace with primitive values in ViewData (badge label, badge classes).

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.tsx
  • apps/website/lib/view-models/**
  • apps/website/lib/display-objects/**
  • shared formatting helpers like time.ts

Actions:

  1. Replace locale formatting in Templates.

    • Example: toLocaleDateString()
    • Replace with: (a) deterministic formatter in a Display Object, or (b) API-provided display labels.
  2. Replace locale formatting in ViewModels.

  3. Remove locale formatting helpers in lib/utilities.

    • Example: formatDate()
    • Replace with deterministic formatters.

Deliverable:

  • The search pattern \\bIntl\\.|toLocale returns 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:

  1. Replace client logout POST with Server Action.

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:

  1. Remove or relocate blockers from LeagueService.
  2. 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:

Actions:

  1. Move apps/website/hooks/**apps/website/lib/hooks/**.
  2. Update imports across apps/website/**.
  3. Remove hooks/ from TS include list in tsconfig.json.
  4. 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 from apps/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:

  1. Refactor any service returning ViewModels to return DTOs/Page DTOs instead.
  2. Move mapping/formatting responsibilities into Presenters colocated with ViewModels.
  3. Add a boundary test: forbid lib/services/** importing lib/view-models/**.

1.9 Remove AdminViewModelService (Presenter misclassified as Service)

Goal: all mapping lives in Presenters, not in services.

Action:

  • Replace AdminViewModelService with a Presenter colocated with its ViewModel(s), then delete the service + test.

1.10 Fix the confirmed “PageQuery constructs ViewModel” violation

Evidence:

Rule violated:

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 SomeClosedUnion on 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: were not enforcing business rules; were 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:

Required changes:

  1. 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.
  2. Replace apps/website/app/profile/leagues/page.tsx with composition:

    • Call PageQuery.
    • Switch on result.
    • Render <ProfileLeaguesPageClient dto={dto} />.
  3. 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.
  4. Create apps/website/templates/ProfileLeaguesTemplate.tsx.

    • Props: ViewData only.
    • No API calls. No useMemo. No filtering/sorting.

2.2 teams routes

Current violations:

Required changes:

  1. Introduce PageQueries:

    • TeamsPageQuery returns Page DTO { teams: Array<{ id; name; rating; ...raw fields }> }.
    • TeamDetailPageQuery returns Page DTO { team; memberships; currentDriverId; } (raw serializable).
  2. Move derived computations out of server route.

    • teamsByLevel, topTeams, etc. belong in client ViewModel or PageClient.
  3. Add TeamsPageClient.tsx and TeamDetailPageClient.tsx.

    • Instantiate ViewModels client-side.
    • Produce ViewData for Templates.
  4. Change Templates to take ViewData.

2.3 dashboard PageQuery

Current violations:

Required changes:

  1. Move the DTO-like shape out of view-models and into lib/page-queries/**.
  2. Replace DI with manual wiring.
  3. Return PageQueryResult as spec.
  4. Ensure the result payload is a Page DTO (raw values only).

2.4 Logout write boundary

Current violation:

Required changes:

  1. Create a server action in an appropriate server-only module.
  2. Replace the demo-user logout handler with a <form action={logoutAction}> submission.
  3. Ensure post-logout navigation uses server-side redirect.

3) What to add to make this enforceable (guardrails)

Minimum guardrails to add:

  1. ESLint or test guard: fail on toLocale* / Intl.* under apps/website/** formatting paths.
  2. ESLint or test guard: fail if apps/website/templates/** imports ViewModels or Display Objects.
  3. ESLint or test guard: fail if apps/website/app/**/page.tsx imports from lib/view-models/**.
  4. 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)

  1. Home SSR data fetch uses container directly

  2. Profile page query dynamically imports container and uses singleton

Deliverable:

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.

  1. Leaderboards entry route

  2. Driver leaderboards route

    • Evidence: ViewModel import in leaderboards/drivers/page.tsx
    • Action: same as above; rename wrapper to DriverRankingsPageClient.tsx and pass ViewData to Templates.
  3. Team leaderboard route

  4. Leagues routes

  5. 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.
  6. Races + stewarding routes

  7. Profile routes (beyond profile/leagues)

    • Evidence: ViewModel import and locale formatting in profile/page.tsx and toLocaleDateString usage (e.g. profile/page.tsx)
    • Action: convert to PageQuery + ProfilePageClient + ViewData.
  8. Races listing route doing server-side filtering

    • Evidence: races/all/page.tsx uses .filter in 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.

4.3 “Templates” living under app/** (structural and guardrail gap)

The architecture assumes Templates are under apps/website/templates/**.

Actions:

  1. Move this file to apps/website/templates/LeagueWalletTemplate.tsx.
  2. Ensure it takes ViewData only.
  3. Add a guardrail: forbid *Template.tsx under apps/website/app/**.

4.4 Remaining write-boundary violations (client-side POST)

Additional client-side logout write:

Actions:

  1. Replace with Server Action flow.
  2. 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:

Actions (make it explicit):

  1. Introduce deterministic formatting primitives under apps/website/lib/display-objects/** (or a dedicated deterministic formatter module) and route all formatting through them.
  2. Replace locale formatting calls in ViewModels first (largest fan-out).
  3. Replace locale formatting in components by ensuring components consume ViewData strings/numbers that are already formatted deterministically.
  4. 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:

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:

  1. Templates import ViewModels and compute.

  2. Templates exist under app/**.

  3. Services construct ViewModels.

    • Example: return new RaceDetailViewModel(...) in RaceService
    • Example: return ...map(... => new TeamSummaryViewModel(...)) in TeamService

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:

Remediation:

  1. Add guardrail: forbid server imports of ContainerManager.
  2. 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:

Remediation:

  1. Implement a Server Action for logout.
  2. Replace all client-side POSTs with <form action={logoutAction}>.
  3. Add guardrail to forbid fetch write 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 repos 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:

  1. Are Display Objects used as intended?
  2. Where should we introduce Display Objects instead of ad-hoc formatting/mapping?
  3. Are Command Models used as intended?
  4. Where should we introduce Command Models instead of ad-hoc form state/validation?
  5. Are Blockers used as intended?
  6. Where should we introduce Blockers instead of ad-hoc UX prevention?

6.1 Display Objects: current state vs contract

Contract excerpts:

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):

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

6.2.2 Violations / drift: Command-model-like logic living in components

Example: onboarding wizard duplicates field validation inline:

Plan action:

  1. Create apps/website/lib/command-models/onboarding/OnboardingCommandModel.ts.
  2. Move field-level validation + errors to the command model.
  3. 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):

  1. UI collects primitives → CommandModel instance (client-only) validates.
  2. Submit creates Command DTO (plain object) and calls a Server Action.
  3. 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)

Why this matters:

  • If any service instance is shared (DI singleton, caching, module singletons), blockers become cross-user/cross-request state.

Plan action:

  1. Remove blockers from service instance state.
  2. Reintroduce blockers at the client boundary:
    • component local state
    • a useSubmitBlocker() hook under apps/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:

Observation:

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 tsconfig explicitly includes top-level hooks/ (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:

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/DTO and stored/handled as apiDto.
  • PageQuery output is always named pageDto and typed *PageDto.
  • Template props are always *ViewData and passed as viewData.

Abstractions enforcement (applies across all refactors):

  • Every PageQuery implements a common PageQuery<PageDto> contract (in lib/contracts/page-queries/**).
  • Every service implements a Service interface that returns ApiDto/PageDto only (in lib/contracts/services/**).
  • Every Presenter implements Presenter<Input, Output> with Output being ViewData or ViewModel (in lib/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:

  1. app/**/page.tsx MUST NOT import from apps/website/lib/view-models/**.
  2. templates/** MUST NOT import from apps/website/lib/view-models/** nor apps/website/lib/display-objects/**.
  3. lib/page-queries/** MUST NOT import from apps/website/lib/view-models/**.
  4. lib/services/** MUST NOT import from apps/website/lib/view-models/**.
  5. Forbid server imports of ContainerManager.
  6. Forbid Intl.* and toLocale* in all presentation paths.
  7. Forbid *Template.tsx under apps/website/app/**.

Order of implementation (guardrails):

  1. Server DI ban: forbid server imports of ContainerManager and forbid use of PageDataFetcher.fetch() from server modules.
  2. Server/client boundary: forbid any imports of apps/website/lib/view-models/** from apps/website/app/**/page.tsx and apps/website/lib/page-queries/**.
  3. Template boundary: forbid imports of view-models and display-objects from apps/website/templates/**.
  4. Service boundary: forbid imports of view-models from apps/website/lib/services/**.
  5. Determinism: forbid Intl.* and toLocale* in all presentation paths.
  6. Write boundary: forbid client-side fetch with write methods in client modules.

These align cleanly with SRP (single reason to change per layer) and remove ambiguity.

9.2 Structural cleanups

  1. Move all hooks to apps/website/lib/hooks/** (see §7 and §1.7).
  2. Convert Display Objects to class-based, immutable modules (see §6.1.2).
  3. Delete AdminViewModelService and replace with a Presenter.

9.3 Service boundary refactor (DTO-only)

  1. 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.
  2. Fix the confirmed server/ViewModel leak:

    • ProfilePageQuery must not call viewModel.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:

  1. page.tsx: fetches via PageQuery, switches on result, renders a *PageClient.
  2. PageQuery: calls services + assembles Page DTO only.
  3. *PageClient.tsx: instantiates ViewModels (client-only) and produces ViewData via Presenters.
  4. Template: renders ViewData only.

Write path (per-route):

  1. app/<route>/actions.ts: Server Actions for that route only (mutations, UX validation, redirect/revalidate).
  2. *PageClient.tsx (or a client component) uses <form action={...}> to invoke the action.
  3. 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):

  1. No server module imports from apps/website/lib/view-models/**.
  2. No apps/website/lib/services/** imports from apps/website/lib/view-models/**.
  3. A ViewModel instance is never passed into a Template.
  4. 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)

Server routes importing ViewModels and or doing formatting/filtering (forbidden/discouraged)

Structural drift vs canonical lib/* layout