16 KiB
Next.js RSC + Client ViewModels + Display Objects (STRICT)
This document is FINAL and STRICT. No alternative interpretations.
1) System boundary (non-negotiable)
apps/apiis the single source of truth for:
- business rules
- validation
- authorization decisions
- canonical filtering and canonical sorting
apps/websiteis presentation infrastructure:
- renders UI using Next.js App Router
- consumes
apps/apivia 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 are composition-only.
page.tsx modules MAY:
- read
params/searchParams - call
redirect()ornotFound() - call a server-side query class
- render server and client components
page.tsx modules MUST NOT:
- instantiate ViewModels (example forbidden:
new DriverProfileViewModel()) - 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) - 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.
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.
Display Objects MUST:
- live under
apps/website/lib/display-objects/*(example existing: apps/website/lib/display-objects/LeagueRoleDisplay.ts) - 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 “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:
- ViewData MUST be JSON-serializable (same restrictions as Page DTO in Section 3).
- ViewData MUST contain only values ready for display. Templates MUST NOT format.
- ViewData MUST be produced in client code:
- Initial render: from Page DTO (SSR-safe)
- Post-hydration: from ViewModel (client-only)
- Formatting implementation MUST live in Display Objects in
apps/website/lib/display-objects/*. - 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
nullfor missing values (noundefined)
Page DTOs MUST NOT contain:
- ViewModels
- Display Objects
DateMap/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.
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) MUST return a discriminated union:
okwith{ dto: PageDTO }notFoundredirectwith{ to: string }errorwith{ errorId: string }(and logging done server-side)
Pages MUST switch on this result and decide:
notFound->notFound()redirect->redirect()error-> throw to Next.js error boundary or render route error boundary
5.2 PageDataFetcher usage rule
The current PageDataFetcher.fetch() and PageDataFetcher.fetchManual() 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
PageDataFetcherintoPageQueryResultand MUST NOT leaknullupward.
If PageDataFetcher is refactored later, its single-fetch methods MUST return a result type (similar to FetchResult) rather than null.
5.3 DI usage (strict)
This repo uses Inversify DI under apps/website/lib/di.
Rules:
page.tsxmodules MUST NOT access the DI container directly (noContainerManager.getInstance()).- Server query classes MAY use DI, but only if all resolved services are stateless and safe for concurrent requests.
- Because
ContainerManagerholds a singleton container, server query classes SHOULD prefer explicit construction (manual wiring) over using the singleton container. - Client components MAY use DI via
ContainerProvider+ hooks likeuseInject.
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:
page.tsx(Server Component)*PageClient.tsx(Client Component)*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:
-
Initial render (SSR-safe):
- MUST NOT instantiate ViewModels
- MUST create initial ViewData directly from Page DTO
- MUST render Template with initial ViewData
-
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 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
ViewDataonly - 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:
Date.toLocaleDateString()- 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 with tests that FAIL when:
- any
apps/website/app/**/page.tsximports fromapps/website/lib/view-models/* - any
apps/website/app/**/page.tsxcontains banned formatting calls (includingDate.toLocaleDateString()) - any
apps/website/app/**/page.tsxcontains sorting/filtering logic (sort,filter,reduce) outside trivial null checks
Add template boundary tests that FAIL when:
- any
apps/website/templates/**imports fromapps/website/lib/view-models/* - any
apps/website/templates/**imports fromapps/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.
Steps:
- Introduce
DashboardPageQueryunderapps/website/lib/page-queries/*that returns a Dashboard Page DTO. - Change the dashboard server page to call the query and render
DashboardPageClient. - Create
DashboardPageClientas client module:- Initial render: builds ViewData from DTO and renders
DashboardTemplate. - Post-hydration: instantiates dashboard ViewModel, builds enhanced ViewData, re-renders template.
- Initial render: builds ViewData from DTO and renders
- Ensure any display formatting is implemented as Display Objects.
9.2 Profile
Starting point: apps/website/app/profile/page.tsx.
Steps:
- Move all helper logic out of the page module into a template and Display Objects.
- Make profile
page.tsxa server component that calls a query class returningDriverProfileViewModelData. - Create
ProfilePageClientas client module:- Initial render: builds ViewData from DTO and renders the template.
- Post-hydration: instantiates
DriverProfileViewModel, builds enhanced ViewData, re-renders template.
- Remove all formatting in the page module, including
Date.toLocaleDateString().
10) Acceptance criteria
- No hydration mismatch warnings on dashboard and profile.
- No ViewModel instantiation in server modules.
- No formatting/sorting/filtering logic in any module under apps/website/app.
- All formatting is encapsulated by Display Objects under
apps/website/lib/display-objects/*.