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

16 KiB
Raw Blame History

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
  1. 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 are composition-only.

page.tsx modules MAY:

  • read params / searchParams
  • call redirect() or notFound()
  • 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:

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

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:

  • 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()
  • 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 PageDataFetcher 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) rather than null.

5.3 DI usage (strict)

This repo uses Inversify DI under apps/website/lib/di.

Rules:

  1. page.tsx modules MUST NOT access the DI container directly (no ContainerManager.getInstance()).
  2. Server query classes MAY use DI, but only if all resolved services are stateless and safe for concurrent requests.
  3. Because ContainerManager 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.

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

  • 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.tsx imports from apps/website/lib/view-models/*
  • any apps/website/app/**/page.tsx contains banned formatting calls (including Date.toLocaleDateString())
  • 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.

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

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.
  3. Create ProfilePageClient as client module:
    • Initial render: builds ViewData from DTO and renders the template.
    • Post-hydration: instantiates DriverProfileViewModel, builds enhanced ViewData, re-renders template.
  4. Remove all formatting in the page module, including Date.toLocaleDateString().

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.
  4. All formatting is encapsulated by Display Objects under apps/website/lib/display-objects/*.