416 lines
16 KiB
Markdown
416 lines
16 KiB
Markdown
# 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 Object’s 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/*`.
|