Files
gridpilot.gg/plans/nextjs-rsc-viewmodels-concept.md
2026-01-11 13:04:33 +01:00

416 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Next.js RSC + Client ViewModels + Display Objects (STRICT)
This document is FINAL and STRICT. No alternative interpretations.
## 1) System boundary (non-negotiable)
1. `apps/api` is the single source of truth for:
- business rules
- validation
- authorization decisions
- canonical filtering and canonical sorting
2. `apps/website` is presentation infrastructure:
- renders UI using Next.js App Router
- consumes `apps/api` via existing clients/services
- performs routing/session/caching/composition
- MUST NOT replicate business truth
## 2) Layering rules
### 2.1 Server route entry modules are composition-only
All `page.tsx` modules under [apps/website/app](apps/website/app/page.tsx:1) are composition-only.
`page.tsx` modules MAY:
- read `params` / `searchParams`
- call [`redirect()`](apps/website/app/leaderboards/page.tsx:7) or [`notFound()`](apps/website/app/dashboard/page.tsx:1)
- call a server-side query class
- render server and client components
`page.tsx` modules MUST NOT:
- instantiate ViewModels (example forbidden: [`new DriverProfileViewModel()`](apps/website/lib/view-models/DriverProfileViewModel.ts:108))
- implement formatting (dates, localization, percent, currency)
- implement filtering/sorting (canonical or view-only)
- map API payloads into UI-specific shapes
- define reusable helper functions
### 2.2 Website server query classes (presentation queries)
Each route MUST have exactly one server query class:
- `apps/website/lib/page-queries/<RouteName>PageQuery.ts`
The query class MUST:
- call services that call `apps/api` (example current service: [`DashboardService`](apps/website/lib/services/dashboard/DashboardService.ts:10))
- return a Page DTO (defined below)
- contain no formatting/filtering/sorting
The query class MUST NOT:
- contain business rules
- contain canonical ordering decisions
If ordering/filtering is needed, it MUST be implemented in `apps/api`.
### 2.3 Client ViewModels
ViewModels live in [apps/website/lib/view-models](apps/website/lib/view-models/DriverProfileViewModel.ts:1).
ViewModels MUST:
- be instantiated only in client modules (`'use client'`)
- accept DTOs only (plain data)
- expose view-only derived values (never business truth)
ViewModels MUST NOT be passed into Templates.
ViewModels SHOULD be the primary place that *composes* Display Objects.
### 2.4 Display Objects
Display Objects follow [docs/architecture/DISPLAY_OBJECTS.md](docs/architecture/DISPLAY_OBJECTS.md:1).
Display Objects MUST:
- live under `apps/website/lib/display-objects/*` (example existing: [apps/website/lib/display-objects/LeagueRoleDisplay.ts](apps/website/lib/display-objects/LeagueRoleDisplay.ts:1))
- be deterministic and side-effect free
- be the ONLY place where formatting/mapping conventions exist
Pages MUST NOT format. Templates MUST NOT format.
Display Objects lifecycle (strict):
- Display Objects are created in client code.
- Display Objects are typically created by ViewModels (recommended), and their primitive outputs are used to build ViewData.
- Display Object instances MUST NOT cross any serialization boundary (RSC boundary, network, storage).
#### 2.4.1 Display Objects are Frontend Value Objects (strict definition)
Treat Display Objects like Domain Value Objects, but for the **presentation layer**.
Display Objects are:
- **Class-based**
- **Immutable**
- **Small** (one concept per object)
- **Deterministic** (same input -> same output everywhere)
- **Side-effect free**
Display Objects are NOT:
- utility modules of exported functions
- global lookup tables exported for ad hoc access
- a place to hide page logic
Why strict class-based?
- Naming: an object name communicates the concept (example: `MoneyDisplay`, `CountryDisplay`, `MonthYearDisplay`)
- Encapsulation: invariants and normalization live in one constructor path
- Reuse: ViewModels can share them without duplicating logic
#### 2.4.2 Allowed responsibilities
Display Objects MAY:
- format raw values into **display strings** (date, number, money)
- map codes -> labels/icons/style tokens
- expose variants explicitly (for example `asShortLabel()`, `asLongLabel()`)
Display Objects MUST NOT:
- contain business rules (those live in `apps/api`)
- validate domain invariants
- call network or storage
- depend on framework runtime (React, Next.js)
- depend on runtime locale/timezone formatting APIs (see [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:1) “Deterministic formatting requirement for Display Objects”)
#### 2.4.3 Strict API shape
Each Display Object class MUST:
- have a single responsibility
- accept only primitives in its constructor (or static constructor)
- expose only primitive outputs (strings/numbers/booleans)
Recommended pattern:
- `private constructor(...)`
- `static fromX(value: ...)` factory for normalization
- instance methods like `toString()`, `label()`, `cssClassToken()`, `ariaLabel()`
Non-negotiable: no exported free functions as the primary API.
#### 2.4.4 Serialization boundary rule
Display Objects MUST NEVER appear in:
- Page DTOs crossing server -> client
- ViewData passed into Templates
Only the Display Objects primitive outputs may be copied into ViewData.
## 3) ViewData for Templates (strict)
Templates MUST render **ViewData**, not ViewModels.
Definitions:
- **Page DTO**: the serializable data returned by a server query and passed across the RSC boundary.
- **ViewModel**: client-only object that encapsulates view-only derivations and composes Display Objects.
- **ViewData**: a JSON-serializable, template-ready data structure that Templates render.
Rules:
1) ViewData MUST be JSON-serializable (same restrictions as Page DTO in [Section 3](plans/nextjs-rsc-viewmodels-concept.md:83)).
2) ViewData MUST contain only values ready for display. Templates MUST NOT format.
3) ViewData MUST be produced in client code:
- Initial render: from Page DTO (SSR-safe)
- Post-hydration: from ViewModel (client-only)
4) Formatting implementation MUST live in Display Objects in `apps/website/lib/display-objects/*`.
5) ViewData MUST NOT contain Display Object instances. ViewData contains only primitives (mostly strings) that were produced by Display Objects.
Rationale: Display Objects are classes/value objects and are not safe to serialize across the Next.js Client Component boundary. They are used as deterministic formatters/mappers, but only their primitive outputs may enter ViewData.
## 4) DTO boundary (RSC boundary)
### 4.1 Page DTO definition
The ONLY data that may cross from a server component into a client component is a Page DTO.
Page DTOs MUST:
- be JSON-serializable
- contain only primitives, arrays, and plain objects
- use ISO strings for timestamps
- use `null` for missing values (no `undefined`)
Page DTOs MUST NOT contain:
- ViewModels
- Display Objects
- `Date`
- `Map` / `Set`
- functions
### 4.2 DTO types
When a ViewModel already defines its raw data type, that raw data type IS the Page DTO.
Example (profile): [`DriverProfileViewModelData`](apps/website/lib/view-models/DriverProfileViewModel.ts:93).
Dashboard MUST define an equivalent `DashboardOverviewViewModelData` (or analogous) next to the dashboard ViewModel.
## 4.3 Deterministic formatting requirement for Display Objects
Because ViewData is rendered during SSR and re-rendered after hydration, any formatting used to produce ViewData MUST be deterministic across Node and the browser.
Therefore Display Objects MUST NOT use locale-dependent runtime formatting APIs, including:
- `Intl.*`
- `Date.toLocaleString()` / `Date.toLocaleDateString()`
This policy is strict and global for `apps/website`: `Intl.*` and `toLocale*` are forbidden everywhere in rendering codepaths (pages, templates, components, view models, display objects). If formatting is required, it MUST be implemented deterministically via explicit algorithms/lookup tables.
Display Objects MAY use:
- explicit lookup tables (example: month names)
- numeric formatting implemented without locale APIs
This is the only way to guarantee identical SSR and client outputs.
## 4.4 ViewData and Display Objects (serialization rule)
Display Objects are classes/value objects. They are NOT guaranteed to be serializable.
Therefore:
- ViewData MUST NOT contain Display Object instances.
- ViewData contains only primitives (usually strings) produced by Display Objects.
## 5) Query result contract (no `null`)
Rationale: returning `null` from server-side fetch orchestration conflates “not found”, “unauthorized/redirect”, and “unexpected error”. This makes route behavior ambiguous and encourages pages to implement policy via ad hoc checks.
Therefore, this concept forbids `null` as a query outcome.
### 5.1 Mandatory `PageQueryResult` discriminated union
Every server query class (see [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:42)) MUST return a discriminated union:
- `ok` with `{ dto: PageDTO }`
- `notFound`
- `redirect` with `{ to: string }`
- `error` with `{ errorId: string }` (and logging done server-side)
Pages MUST switch on this result and decide:
- `notFound` -> [`notFound()`](apps/website/app/dashboard/page.tsx:1)
- `redirect` -> [`redirect()`](apps/website/app/leaderboards/page.tsx:7)
- `error` -> throw to Next.js error boundary or render route error boundary
### 5.2 `PageDataFetcher` usage rule
The current [`PageDataFetcher.fetch()`](apps/website/lib/page/PageDataFetcher.ts:15) and [`PageDataFetcher.fetchManual()`](apps/website/lib/page/PageDataFetcher.ts:36) return `null` on error.
In the new architecture:
- Server page modules MUST NOT consume `null`-returning APIs for route decisions.
- Server query classes MUST wrap any usage of [`PageDataFetcher`](apps/website/lib/page/PageDataFetcher.ts:9) into `PageQueryResult` and MUST NOT leak `null` upward.
If `PageDataFetcher` is refactored later, its single-fetch methods MUST return a result type (similar to [`FetchResult`](apps/website/lib/page/PageDataFetcher.ts:3)) rather than `null`.
## 5.3 DI usage (strict)
This repo uses Inversify DI under [apps/website/lib/di](apps/website/lib/di/index.ts:1).
Rules:
1) `page.tsx` modules MUST NOT access the DI container directly (no [`ContainerManager.getInstance()`](apps/website/lib/di/container.ts:67)).
2) Server query classes MAY use DI, but only if all resolved services are stateless and safe for concurrent requests.
3) Because [`ContainerManager`](apps/website/lib/di/container.ts:61) holds a singleton container, server query classes SHOULD prefer explicit construction (manual wiring) over using the singleton container.
4) Client components MAY use DI via `ContainerProvider` + hooks like [`useInject`](apps/website/lib/di/hooks/useInject.ts:1).
Non-negotiable: no stateful service instances may be shared across requests via the singleton container.
## 6) Required component shape per route
Every route MUST be structured as:
1) `page.tsx` (Server Component)
2) `*PageClient.tsx` (Client Component)
3) `*Template.tsx` (pure stateless UI)
### 6.1 Server `page.tsx`
Server `page.tsx` MUST:
- call the route query class
- pass only the Page DTO into the client component
Server `page.tsx` MUST NOT:
- import from `apps/website/lib/view-models/*`
- instantiate ViewModels
### 6.2 Client `*PageClient.tsx`
Client `*PageClient.tsx` MUST:
- start with `'use client'`
- accept the Page DTO as prop
- render the Template with **ViewData**
Client `*PageClient.tsx` MUST implement a two-phase render:
1) Initial render (SSR-safe):
- MUST NOT instantiate ViewModels
- MUST create initial ViewData directly from Page DTO
- MUST render Template with initial ViewData
2) Post-hydration (client-only):
- MUST instantiate the ViewModel
- MUST derive enhanced ViewData from the ViewModel (using Display Objects)
- MUST re-render Template with enhanced ViewData
## 6.4 Initial SSR ViewData policy (non-optional)
Initial SSR ViewData MUST be **fully populated**, but only using deterministic formatting as defined in [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:1) under “Deterministic formatting requirement for Display Objects”.
This yields:
- SSR delivers meaningful content (no skeleton-only pages)
- Hydration stays stable because the same deterministic Display Objects run on both SSR and client
### 6.3 `*Template.tsx` (pure UI)
Templates MUST:
- be pure and stateless
- accept `ViewData` only
- contain no formatting logic
- contain no filtering/sorting logic
Templates MAY be imported by server or client modules.
Templates MUST NOT import:
- `apps/website/lib/view-models/*`
- `apps/website/lib/display-objects/*`
## 7) Hydration safety (strict)
Hydration mismatch warnings are treated as build-breaking defects.
Forbidden in any `page.tsx` module under [apps/website/app](apps/website/app/page.tsx:1):
- [`Date.toLocaleDateString()`](apps/website/app/profile/page.tsx:430)
- any other locale/timezone dependent formatting
- any non-determinism (`Math.random`, `Date.now`) during render
All human-readable formatting MUST be done via Display Objects in the client.
Additionally forbidden anywhere Display Objects are executed to produce ViewData:
- `Intl.*`
- `Date.toLocaleString()` / `Date.toLocaleDateString()`
## 8) Guardrails (mandatory)
### 8.1 Boundary tests
Extend [apps/website/lib/services/pagesViewModelsOnly.boundary.test.ts](apps/website/lib/services/pagesViewModelsOnly.boundary.test.ts:1) with tests that FAIL when:
- any `apps/website/app/**/page.tsx` imports from `apps/website/lib/view-models/*`
- any `apps/website/app/**/page.tsx` contains banned formatting calls (including [`Date.toLocaleDateString()`](apps/website/app/profile/page.tsx:430))
- any `apps/website/app/**/page.tsx` contains sorting/filtering logic (`sort`, `filter`, `reduce`) outside trivial null checks
Add template boundary tests that FAIL when:
- any `apps/website/templates/**` imports from `apps/website/lib/view-models/*`
- any `apps/website/templates/**` imports from `apps/website/lib/display-objects/*`
### 8.2 ESLint restrictions
Add ESLint restrictions that enforce the same rules at authoring time.
## 9) Migration steps (dashboard first, then profile)
### 9.1 Dashboard
Starting point: [apps/website/app/dashboard/page.tsx](apps/website/app/dashboard/page.tsx:1).
Steps:
1) Introduce `DashboardPageQuery` under `apps/website/lib/page-queries/*` that returns a Dashboard Page DTO.
2) Change the dashboard server page to call the query and render `DashboardPageClient`.
3) Create `DashboardPageClient` as client module:
- Initial render: builds ViewData from DTO and renders [`DashboardTemplate`](apps/website/templates/DashboardTemplate.tsx:1).
- Post-hydration: instantiates dashboard ViewModel, builds enhanced ViewData, re-renders template.
4) Ensure any display formatting is implemented as Display Objects.
### 9.2 Profile
Starting point: [apps/website/app/profile/page.tsx](apps/website/app/profile/page.tsx:1).
Steps:
1) Move all helper logic out of the page module into a template and Display Objects.
2) Make profile `page.tsx` a server component that calls a query class returning [`DriverProfileViewModelData`](apps/website/lib/view-models/DriverProfileViewModel.ts:93).
3) Create `ProfilePageClient` as client module:
- Initial render: builds ViewData from DTO and renders the template.
- Post-hydration: instantiates [`DriverProfileViewModel`](apps/website/lib/view-models/DriverProfileViewModel.ts:108), builds enhanced ViewData, re-renders template.
4) Remove all formatting in the page module, including [`Date.toLocaleDateString()`](apps/website/app/profile/page.tsx:430).
## 10) Acceptance criteria
1) No hydration mismatch warnings on dashboard and profile.
2) No ViewModel instantiation in server modules.
3) No formatting/sorting/filtering logic in any module under [apps/website/app](apps/website/app/page.tsx:1).
4) All formatting is encapsulated by Display Objects under `apps/website/lib/display-objects/*`.