# 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/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/*`.