do to formatters

This commit is contained in:
2026-01-24 01:22:43 +01:00
parent 891b3cf0ee
commit 705f9685b5
18 changed files with 361 additions and 760 deletions

View File

@@ -2,41 +2,25 @@
This document defines the **Builder** pattern for `apps/website`.
Builders exist to transform data between presentation model types.
Builders exist to transform raw API data into flat, serializable **ViewData**.
## 1) Definition
A **Builder** is a deterministic, side-effect free transformation between website presentation models.
A **Builder** is a deterministic, side-effect free transformation that bridges the boundary between the API (DTOs) and the Template (ViewData).
There are two types of builders:
### 1.1 ViewModel Builders
Transform API Transport DTOs into ViewModels.
**Purpose**: Prepare raw API data for client-side state management.
**Location**: `apps/website/lib/builders/view-models/**`
**Pattern**:
```typescript
export class AdminViewModelBuilder {
static build(dto: UserDto): AdminUserViewModel {
return new AdminUserViewModel(dto);
}
}
```
### 1.2 ViewData Builders
### 1.1 ViewData Builders
Transform API DTOs directly into ViewData for templates.
**Purpose**: Prepare API data for server-side rendering without ViewModels.
**Purpose**: Prepare API data for server-side rendering. They ensure that logic-rich behavior is stripped away, leaving only a "dumb" JSON structure safe for SSR and hydration.
**Location**: `apps/website/lib/builders/view-data/**`
**Pattern**:
```typescript
import { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
export class LeagueViewDataBuilder {
static build(apiDto: LeagueApiDto): LeagueDetailViewData extends ViewData {
static build(apiDto: LeagueApiDto): LeagueDetailViewData {
return {
leagueId: apiDto.id,
name: apiDto.name,
@@ -44,164 +28,90 @@ export class LeagueViewDataBuilder {
};
}
}
// Enforce static compliance without dummy instances
LeagueViewDataBuilder satisfies ViewDataBuilder<LeagueApiDto, LeagueDetailViewData>;
```
## 2) Non-negotiable rules
### ViewModel Builders
1. MUST be deterministic
2. MUST be side-effect free
3. MUST NOT perform HTTP
4. MUST NOT call API clients
5. MUST NOT access cookies/headers
6. Input: API Transport DTO
7. Output: ViewModel
8. MUST live in `lib/builders/view-models/**`
### ViewData Builders
1. MUST be deterministic
2. MUST be side-effect free
3. MUST NOT perform HTTP
4. MUST NOT call API clients
5. MUST NOT access cookies/headers
6. Input: API DTO
7. Output: ViewData
8. MUST live in `lib/builders/view-data/**`
1. MUST be deterministic.
2. MUST be side-effect free.
3. MUST NOT perform HTTP or call API clients.
4. Input: API DTO.
5. Output: ViewData (Plain JSON).
6. MUST live in `lib/builders/view-data/**`.
7. MUST use `static build()` and `satisfies ViewDataBuilder`.
8. MUST use **Formatters** for primitive output (strings/numbers).
## 3) Why two builder types?
## 3) Why no ViewModel Builders?
**ViewModel Builders** (API → Client State):
- Bridge the API boundary
- Convert transport types to client classes
- Add client-only fields if needed
- Run in client code
**ViewModels are self-building.**
**ViewData Builders** (API → Render Data):
- Bridge the presentation boundary
- Transform API data directly for templates
- Format values for display
- Run in server code (RSC)
A ViewModel is a class that wraps data to provide behavior. Instead of a separate builder class, ViewModels are instantiated directly from ViewData using their **Constructor**. This removes unnecessary "ceremony" and keeps the API unambiguous.
**❌ Redundant Pattern (Forbidden):**
```typescript
// Why have this extra class?
export class TeamViewModelBuilder {
static build(data: TeamViewData): TeamViewModel {
return new TeamViewModel(data);
}
}
```
**✅ Clean Pattern (Required):**
```typescript
// Just use the class itself in the ClientWrapper
const vm = new TeamViewModel(viewData);
```
## 4) Relationship to other patterns
```
API Transport DTO
ViewModel Builder (lib/builders/view-models/)
ViewModel (lib/view-models/)
(for client components)
API Transport DTO
ViewData Builder (lib/builders/view-data/)
ViewData (lib/templates/)
Formatters (lib/display-objects/) -- [primitive output]
Template (lib/templates/)
ViewData (Plain JSON)
Template (SSR)
ViewModel (lib/view-models/) -- [new ViewModel(viewData)]
Display Objects (lib/display-objects/) -- [rich API]
```
## 5) Naming convention
**ViewModel Builders**: `*ViewModelBuilder`
- `AdminViewModelBuilder`
- `RaceViewModelBuilder`
**ViewData Builders**: `*ViewDataBuilder`
- `LeagueViewDataBuilder`
- `RaceViewDataBuilder`
## 6) File structure
## 6) Usage example (Server Component)
```
lib/
builders/
view-models/
AdminViewModelBuilder.ts
RaceViewModelBuilder.ts
index.ts
view-data/
LeagueViewDataBuilder.ts
RaceViewDataBuilder.ts
index.ts
```
## 7) Usage examples
### ViewModel Builder (Client Component)
```typescript
'use client';
import { AdminViewModelBuilder } from '@/lib/builders/view-models/AdminViewModelBuilder';
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
export function AdminPage() {
const [users, setUsers] = useState<AdminUserViewModel[]>([]);
useEffect(() => {
const apiClient = new AdminApiClient();
const dto = await apiClient.getUsers();
const viewModels = dto.map(d => AdminViewModelBuilder.build(d));
setUsers(viewModels);
}, []);
// ... render with viewModels
}
```
### ViewData Builder (Server Component)
```typescript
import { LeagueViewDataBuilder } from '@/lib/builders/view-data/LeagueViewDataBuilder';
import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery';
export default async function LeagueDetailPage({ params }) {
const apiDto = await LeagueDetailPageQuery.execute(params.id);
// Transform to flat JSON for SSR
const viewData = LeagueViewDataBuilder.build(apiDto);
return <LeagueDetailTemplate viewData={viewData} />;
}
```
## 8) Common mistakes
**Wrong**: Using "Presenter" for DTO → ViewModel
```typescript
// DON'T
export class AdminPresenter {
static createViewModel(dto: UserDto): AdminUserViewModel { ... }
}
```
**Correct**: Use ViewModelBuilder
```typescript
export class AdminViewModelBuilder {
static build(dto: UserDto): AdminUserViewModel { ... }
}
```
**Wrong**: Using "Transformer" for ViewModel → ViewData
```typescript
// DON'T
export class RaceResultsDataTransformer {
static transform(...): TransformedData { ... }
}
```
**Correct**: Use ViewDataBuilder
```typescript
export class RaceResultsViewDataBuilder {
static build(...): RaceResultsViewData extends ViewData { ... }
}
```
## 9) Enforcement
## 7) Enforcement
These rules are enforced by ESLint:
- `gridpilot-rules/view-model-builder-contract`
- `gridpilot-rules/view-data-builder-contract`
- `gridpilot-rules/filename-view-model-builder-match`
- `gridpilot-rules/filename-view-data-builder-match`
- `gridpilot-rules/formatters-must-return-primitives`
See [`docs/architecture/website/WEBSITE_GUARDRAILS.md`](WEBSITE_GUARDRAILS.md) for details.
See [`docs/architecture/website/WEBSITE_CONTRACT.md`](WEBSITE_CONTRACT.md) for the authoritative contract.

View File

@@ -1,230 +0,0 @@
# Displays
## Definition
A **Display** encapsulates **reusable, UI-only display logic**.
In this codebase, a Display is a **Frontend Value Object**:
- class-based
- immutable
- deterministic
- side-effect free
### Distinction from Domain Value Objects
While both are "Value Objects", they serve different layers:
1. **Domain Value Objects (Core):** Encapsulate business truth and invariants (e.g., `Money`, `EmailAddress`). They are pure and never contain formatting logic.
2. **Display Objects (Website):** Encapsulate presentation truth and formatting (e.g., `PriceDisplay`, `DateDisplay`). They are used to transform raw data or Domain Value Objects into user-ready strings.
It answers the question:
> “How should this specific piece of information be shown?”
Displays are **not screen-specific**.
They exist to avoid duplicating presentation logic across View Models.
**Naming Convention:**
- Displays MUST end with `Display` suffix
- Displays MUST be reusable across multiple screens
- Valid examples: `PriceDisplay`, `EmailDisplay`, `RatingDisplay`
- Invalid examples: `DashboardRatingDisplay`, `UserProfileDisplay`
---
## Responsibilities
A Display MAY:
- format values (money, dates, durations)
- handle localization only when localization inputs are deterministic (for example: mapping stable codes to stable labels)
- map codes to labels
- encapsulate UI display conventions
- be reused across multiple View Models
In addition, a Display MAY:
- normalize presentation inputs (for example trimming/casing)
- expose multiple explicit display variants (for example `shortLabel`, `longLabel`)
A Display MUST:
- be deterministic
- be side-effect free
- operate only on presentation data
A Display MUST:
- be implemented as a **class** with a small, explicit API
- accept only primitives/plain data in its constructor (or static factory)
- expose only primitive outputs (strings/numbers/booleans)
---
## Restrictions
A Display MUST NOT:
- contain business logic
- enforce domain invariants
- perform validation
- influence system behavior
- be sent back to the server
- depend on backend or infrastructure concerns
- **depend on environment-specific APIs** (e.g., `window`, `document`, `navigator`)
- **be serialized** (they are classes; only their primitive outputs are stored in `ViewData`)
In this repository, a Display MUST NOT:
- call `Intl.*`
- call `Date.toLocaleString()` / `Date.toLocaleDateString()` / `Date.toLocaleTimeString()`
Reason: these are runtime-locale/timezone dependent and cause SSR/hydration mismatches.
### Handling Client-Only Formatting
If a formatting requirement **strictly requires** client-only APIs (e.g., browser-native relative time or local timezone detection):
1. It MUST NOT live in a `Display Object`.
2. It SHOULD live in a **View Model** (which is client-only).
3. The Template should handle the transition from server-provided `ViewData` to client-updated `ViewData`.
### Best Practices for Time/Date Formatting
To avoid hydration mismatches while still providing good SEO and UX:
1. **Use UTC methods for Determinism:** In `Display Objects`, prefer `getUTCDate()`, `getUTCMonth()`, etc., over their local counterparts. This ensures the server and client produce the exact same string regardless of their local timezones.
2. **Hardcoded Arrays:** Use hardcoded arrays for month/day names instead of `Intl` to ensure consistency across environments.
3. **Pass "Now" as an Argument:** For relative time (e.g., "time ago"), pass the reference "now" timestamp as an argument to the `Display Object` instead of calling `Date.now()` inside it.
4. **The "Upgrade" Pattern:**
- **Server:** `ViewData Builder` uses a `Display Object` to produce a deterministic UTC-based string (e.g., "2024-01-18 15:00 UTC").
- **Client:** `View Model` uses client APIs (`Intl`, `toLocale*`) to produce a localized string (e.g., "3:00 PM") and updates the `ViewData`.
## Localization rule (strict)
Localization MUST NOT depend on runtime locale APIs.
Allowed approaches:
- API returns the exact labels/strings for the current user context.
- Website maps stable codes to stable labels using a deterministic table.
Forbidden approaches:
- any usage of `Intl.*`
- any usage of `toLocale*`
If a rule affects system correctness or persistence,
it does not belong in a Display.
---
## Ownership & Placement
- Displays belong to the **presentation layer**
- They are frontend-only
- They are not shared with the backend or core
Placement rule (strict):
- Displays live under `apps/website/lib/display-objects/*`.
- Filenames MUST match the class name with `.tsx` extension (e.g., `RatingDisplay.tsx` contains `class RatingDisplay`)
---
## Relationship to View Models and ViewData Builders
Displays are the **shared source of truth** for formatting logic across the website:
- **ViewData Builders (Server):** Use Displays to produce deterministic, formatted strings for SEO and initial SSR.
- **View Models (Client):** Use Displays to produce formatted strings for interactive UI and client-specific context.
Additional strict rules:
- View Models SHOULD compose Displays.
- ViewData Builders SHOULD use Displays for all formatting.
- **Templates and Components MUST NOT use Displays directly.** They must receive already-formatted primitive outputs (strings, numbers) via their props.
Reason: This keeps the rendering layer "dumb" and ensures that the `ViewData` remains the single source of truth for what is displayed on the screen.
- Displays MUST NOT be serialized or passed across boundaries.
---
## Testing
Displays SHOULD be tested because they often contain:
- locale-specific behavior
- formatting rules
- edge cases visible to users
Additionally:
- test determinism by running the same inputs under Node and browser contexts (where applicable)
- test boundary rules (no `Intl.*`, no `toLocale*`)
---
## Common Candidates (Found in Components)
The following patterns were identified in `apps/website/components` and SHOULD be migrated to Display Objects:
### 1. Date & Time
- **Month/Year:** `new Date().toLocaleDateString('en-US', { month: 'short', year: 'numeric' })``DateDisplay.formatMonthYear()`
- **Time only:** `new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })``DateDisplay.formatTime()`
- **Full Date:** `new Date().toLocaleDateString()``DateDisplay.formatShort()` (ensure UTC)
- **Relative Time:** `timeAgo(timestamp)` logic → `RelativeTimeDisplay.format(timestamp, now)`
### 2. Currency & Prices
- **Price with Symbol:** `$` + `amount.toFixed(2)``CurrencyDisplay.format(amount, 'USD')`
- **Compact Price:** `$` + `amount.toLocaleString()``CurrencyDisplay.formatCompact(amount)`
### 3. Numbers & Stats
- **Ratings:** `Math.round(rating).toLocaleString()``RatingDisplay.format(rating)`
- **Percentages:** `(val * 100).toFixed(1) + '%'``PercentDisplay.format(val)`
- **Consistency:** `${stats.consistency}%``ConsistencyDisplay.format(stats.consistency)`
- **Average Finish:** `avgFinish.toFixed(1)``FinishDisplay.format(avgFinish)`
- **Durations:** `duration.toFixed(2) + 'ms'` or `minutes:seconds``DurationDisplay.format(ms)`
- **Memory:** `(bytes / 1024 / 1024).toFixed(1) + 'MB'``MemoryDisplay.format(bytes)`
### 4. Status & Labels
- **Race Status:** Mapping `scheduled | running | completed` to labels → `RaceStatusDisplay`
- **Protest Status:** Mapping `pending | under_review | resolved` to labels → `ProtestStatusDisplay`
- **Action Status:** Mapping `PENDING | COMPLETED | FAILED` to labels → `ActionStatusDisplay`
### 5. Pluralization
- **Member Count:** `${count} ${count === 1 ? 'member' : 'members'}``MemberDisplay.formatCount(count)`
- **League Count:** `${count} ${count === 1 ? 'league' : 'leagues'}``LeagueDisplay.formatCount(count)`
---
## Existing Display Objects
- **[`DateDisplay`](apps/website/lib/display-objects/DateDisplay.ts)**: UTC-based date and time formatting.
- **[`CurrencyDisplay`](apps/website/lib/display-objects/CurrencyDisplay.ts)**: Deterministic currency formatting.
- **[`RaceStatusDisplay`](apps/website/lib/display-objects/RaceStatusDisplay.ts)**: Race status labels, variants, and icons.
- **[`RatingDisplay`](apps/website/lib/display-objects/RatingDisplay.ts)**: Rounded rating formatting with thousands separators.
- **[`RelativeTimeDisplay`](apps/website/lib/display-objects/RelativeTimeDisplay.ts)**: Deterministic relative time (requires "now" argument).
- **[`MemberDisplay`](apps/website/lib/display-objects/MemberDisplay.ts)**: Member count pluralization.
- **[`LeagueDisplay`](apps/website/lib/display-objects/LeagueDisplay.ts)**: League count pluralization.
---
## Summary
- Displays encapsulate **how something looks** (the single source of truth for formatting logic).
- View Models encapsulate **what a screen needs** (including client-specific "last mile" formatting).
- Both are presentation concerns.
- Neither contains business truth.
In one sentence: **Displays are the shared source of truth for deterministic formatting logic, used by both the server and the client.**
---
## Final Rule: Where does formatting go?
1. **Is it deterministic?** (e.g., currency symbols, fixed date formats, labels) → **Display Object**.
2. **Is it client-only?** (e.g., `Intl.*`, `toLocale*`, browser timezone) → **View Model**.
3. **Is it for SEO?****ViewData Builder** (using a Display Object).

View File

@@ -0,0 +1,87 @@
# Displays & Formatters
## Definition
A **Display** encapsulates **reusable, UI-only display logic**.
In this codebase, we distinguish between **Formatters** (Stateless Logic) and **Display Objects** (Rich Value Objects).
### 1. Formatters (The "Mouth")
Formatters are pure, stateless utilities. They are the "Experts" on how to transform a raw value into a primitive string/number.
- **Usage:** Used by `ViewDataBuilders` (Server) and `ViewModels` (Client).
- **Output:** MUST return primitive values only (`string`, `number`, `boolean`, `null`).
- **Uncle Bob says:** "Data structures (ViewData) should not have behavior. Keep logic in stateless utilities."
### 2. Display Objects (The "Rich API")
Display Objects are logic-rich **Value Objects** that live only on the client. They wrap data and provide multiple ways to look at it.
- **Usage:** Used by `ViewModels` (Client) to provide a rich API to the UI.
- **Output:** Can return complex objects or variants.
- **Uncle Bob says:** "Objects expose behavior, not data. Use them to hide the complexity of the UI."
---
## Responsibilities
A Display/Formatter MAY:
- format values (money, dates, durations)
- handle deterministic localization (mapping stable codes to labels)
- encapsulate UI display conventions
A Display/Formatter MUST:
- be deterministic
- be side-effect free
- be implemented as a **class** with static methods (Formatters) or as immutable classes (Display Objects)
---
## Restrictions
A Display/Formatter MUST NOT:
- contain business logic (e.g., "Team is full if count > 10")
- enforce domain invariants
- perform validation
- **be serialized** (only their primitive outputs are stored in `ViewData`)
- call `Intl.*` or `toLocale*` (unless explicitly marked for client-only ViewModels)
---
## Relationship to ViewData and ViewModels
### The "Primitive Compact" (Server-Side)
`ViewDataBuilders` MUST use **Formatters** to produce flat, serializable `ViewData`.
- **Rule:** `ViewData` properties assigned from a Display/Formatter MUST be primitives.
- **Reason:** Ensures `ViewData` remains a "dumb" JSON structure for SSR.
### The "Rich API" (Client-Side)
`ViewModels` MAY use **Display Objects** to provide interactive formatting.
- **Rule:** `ViewModels` can return `Display Object` instances to the UI.
- **Reason:** Allows the UI to access multiple variants (e.g., `date.short`, `date.relative`) without re-fetching data.
---
## Summary of the Flow
```mermaid
graph TD
DTO[Raw DTO] -->|ViewDataBuilder| VD[ViewData]
subgraph "Server: The Formatter Compact"
VD -->|Uses| F[Stateless Formatter]
F -->|Returns| S[Primitive string/number]
end
VD -->|SSR Boundary| T[Template]
subgraph "Client: The DisplayObject Richness"
T -->|Props| CW[ClientWrapper]
CW -->|new| VM[ViewModel]
VM -->|Wraps| DO[Rich Display Object]
DO -->|Provides| R[Rich API: .time, .relative, .date]
end
```
## Final Rule: Where does logic live?
1. **Is it a business rule?** (e.g., "Can join?") → **ViewModel**.
2. **Is it a formatting rule?** (e.g., "How to show date?") → **Formatter/Display**.
3. **Is it for SEO/SSR?****ViewDataBuilder** (using a Formatter).
4. **Is it for interaction?****ViewModel** (using a Display Object).

View File

@@ -85,9 +85,9 @@ export function TeamsTemplate({ teams, searchQuery, onSearchChange, onTeamClick
<SearchInput value={searchQuery} onChange={onSearchChange} />
</Header>
<TeamLeaderboardPreview
teams={teams}
onTeamClick={onTeamClick}
<TeamLeaderboardPreview
teams={teams}
onTeamClick={onTeamClick}
/>
<TeamGrid teams={teams} onTeamClick={onTeamClick} />
@@ -123,7 +123,7 @@ import { Card, CardHeader, TeamRow } from '@/ui';
export function TeamLeaderboardPreview({ teams, onTeamClick }: Props) {
// App-specific logic: medal colors, ranking, etc.
const getMedalColor = (position: number) => {
const getMedalColor = (position: number) => {
if (position === 0) return 'gold';
if (position === 1) return 'silver';
return 'none';
@@ -133,7 +133,7 @@ export function TeamLeaderboardPreview({ teams, onTeamClick }: Props) {
<Card variant="elevated">
<CardHeader title="Top Teams" />
{teams.map((team, index) => (
<TeamRow
<TeamRow
key={team.id}
name={team.name}
rank={index + 1}
@@ -174,7 +174,7 @@ export function Button({ children, variant = 'primary', onClick }: ButtonProps)
}[variant];
return (
<button
<button
className={`px-4 py-2 rounded-md transition-colors ${classes}`}
onClick={onClick}
>
@@ -224,7 +224,7 @@ Components must only expose props that describe **what** the component is or **h
- **`components/` components**: **MUST NOT** use `className`, `style`, or any prop that accepts raw styling values. They must only use the semantic APIs provided by `ui/`.
- **`templates/`**: Same as `components/`.
## The Display Object Layer
## The Formatter & Display Object Layer
**Purpose**: Reusable formatting and presentation logic
@@ -235,11 +235,13 @@ Components must only expose props that describe **what** the component is or **h
- Value transformations for display
**Rules**:
- **Formatters**: Stateless utilities for server-side primitive output.
- **Display Objects**: Rich Value Objects for client-side interactive APIs.
- Class-based
- Immutable
- Deterministic
- No side effects
- No `Intl.*` or `toLocale*`
- No `Intl.*` or `toLocale*` (unless client-only)
**Usage**:
```typescript
@@ -256,11 +258,15 @@ export class RatingDisplay {
}
}
// In ViewModel Builder
const viewModel = {
rating: RatingDisplay.format(dto.rating),
ratingColor: RatingDisplay.getColor(dto.rating)
// In ViewData Builder (Server)
const viewData = {
rating: RatingDisplay.format(dto.rating), // Primitive string
};
// In ViewModel (Client)
get rating() {
return new RatingDisplay(this.data.rating); // Rich API
}
```
## Dependency Flow
@@ -292,7 +298,7 @@ ui/ (generic primitives)
- **Component**: Understands app concepts (teams, races, leagues)
- **UI**: Generic building blocks (button, card, input)
### When to use Display Objects?
### When to use Formatters/Display Objects?
- When formatting is reusable across multiple ViewModels
- When mapping codes to labels
- When presentation logic needs to be deterministic

View File

@@ -12,13 +12,15 @@ ViewData is a JSON-serializable, template-ready data structure:
- arrays and plain objects
- `null` for missing values
**Uncle Bob says**: "Data structures should not have behavior." ViewData is a dumb container.
## 2) What ViewData is NOT
ViewData is not:
- an API Transport DTO (raw transport)
- a ViewModel (client-only class)
- a Display Object instance
- a Display Object instance (rich API)
## 3) Construction rules
@@ -58,7 +60,7 @@ ViewData Builders MUST NOT call the API.
ViewData is responsible for providing **fully formatted strings** to Templates for Server-Side Rendering (SSR).
- **SEO Requirement:** All data required for search engines (prices, dates, counts, labels) MUST be formatted in the `ViewData Builder` on the server.
- **SEO Requirement:** All data required for search engines (prices, dates, counts, labels) MUST be formatted in the `ViewData Builder` on the server using **Formatters**.
- **Template Simplicity:** Templates should simply render the strings provided in `ViewData` without further processing.
## 5) Determinism rules
@@ -75,7 +77,7 @@ Reason: SSR and browser outputs can differ.
Localization MUST NOT depend on runtime locale APIs.
If localized strings are required, they MUST be provided as deterministic inputs (for example via API-provided labels or a deterministic code-to-label map) and passed through ViewData Builders into ViewData.
## 5) Relationship to ViewModels
## 6) Relationship to ViewModels
ViewData serves as the stable, serializable contract between the server and client. It is:
- The input for Templates (both SSR and Client)
@@ -88,11 +90,11 @@ ViewModels are built from ViewData, not from DTOs. This ensures:
**Important:** ViewData MUST NOT contain ViewModel instances. ViewModels are classes with logic; ViewData is plain JSON.
## 6) Relationship to Display Objects
## 7) Relationship to Formatters & Display Objects
Display Objects are used to implement formatting/mapping, but their instances MUST NOT be stored inside ViewData.
Formatters are used to implement formatting/mapping, but their instances MUST NOT be stored inside ViewData.
Only primitive outputs produced by Display Objects may appear in ViewData.
Only primitive outputs produced by Formatters may appear in ViewData.
### The "Redundancy" Question
DTOs, ViewData, and ViewModels might share similar fields, but they are NOT redundant:

View File

@@ -11,6 +11,8 @@ It answers the question:
View Models are **UI-owned** classes.
They do not represent business truth and do not enforce domain rules.
**Uncle Bob says**: "Objects expose behavior, not data." ViewModels encapsulate the behavior of the UI.
---
## Responsibilities
@@ -22,14 +24,14 @@ A View Model MAY:
- combine or reshape data for rendering
- perform formatting (dates, numbers, labels), especially for **client-only context** (e.g., local timezone, relative "time ago")
- handle localization and presentation logic
- use Display Objects for reusable UI concerns
- use **Display Objects** for reusable UI concerns and rich APIs
### Formatting Responsibility
While `ViewData Builders` handle formatting for SEO and initial render, `View Models` are responsible for:
While `ViewData Builders` handle formatting for SEO and initial render using **Formatters**, `View Models` are responsible for:
- **Client-specific formatting:** Data that depends on the browser's locale, timezone, or precise location.
- **Interactive formatting:** Updating display values in response to user input or state changes.
- **Consistency:** Using the same `Display Objects` as the server to ensure a consistent look and feel.
- **Rich APIs:** Providing **Display Objects** to the UI for multi-variant formatting (e.g., `date.short`, `date.relative`).
In the website SSR/RSC architecture, View Models MAY compute view-only derived values, but MUST NOT be the type passed into Templates.
@@ -78,7 +80,7 @@ Rules (website):
1) View Models are created in client code only.
2) View Models are created from **ViewData** (not from DTOs).
3) Templates MUST NOT accept View Models; Templates accept ViewData only.
4) View Models MUST compose Display Objects and produce ViewData (primitive outputs only).
4) View Models MUST compose Display Objects and produce rich UI APIs.
Authoritative reference: [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:1).

View File

@@ -51,20 +51,7 @@ Canonical placement in this repo:
- `apps/website/lib/types/**` (transport DTOs consumed by services and page queries)
### 3.2 API Transport DTO
Definition: the shape returned by the backend API over HTTP.
Rules:
- API Transport DTOs MUST be contained inside infrastructure.
- API Transport DTOs MUST NOT be imported by Templates.
Canonical placement in this repo:
- `apps/website/lib/types/**` (transport DTOs consumed by services and page queries)
### 3.3 ViewData
### 3.2 ViewData
Definition: the only allowed input type for Templates.
@@ -73,6 +60,7 @@ Rules:
- JSON-serializable only.
- Contains only template-ready values (mostly strings/numbers/booleans).
- MUST NOT contain class instances.
- **Uncle Bob says**: "Data structures should not have behavior."
See [`VIEW_DATA.md`](docs/architecture/website/VIEW_DATA.md:1).
@@ -89,6 +77,7 @@ Rules:
- Instantiated only in `'use client'` modules.
- Never serialized.
- Used for client components that need state management.
- **Uncle Bob says**: "Objects expose behavior, not data."
See [`VIEW_MODELS.md`](docs/architecture/website/VIEW_MODELS.md:1).
@@ -112,6 +101,7 @@ Rules:
- MUST NOT call the API.
- Input: `Result<ApiDto, string>` or `ApiDto`
- Output: ViewModel
- MUST use `static build()` and `satisfies ViewModelBuilder`.
See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md:1).
@@ -133,6 +123,7 @@ Rules:
- MUST NOT call the API.
- Input: `Result<ApiDto, string>` or `ApiDto`
- Output: ViewData
- MUST use `static build()` and `satisfies ViewDataBuilder`.
See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md:1).
@@ -161,18 +152,18 @@ Canonical placement in this repo:
- `apps/website/lib/contracts/Result.ts`
### 4.3 Display Object
### 4.4 Formatter & Display Object
Definition: deterministic, reusable, UI-only formatting/mapping logic.
Rules:
- Class-based, immutable, deterministic.
- MUST NOT call `Intl.*`.
- MUST NOT call any `toLocale*`.
- **Formatters**: Stateless utilities for server-side primitive output.
- **Display Objects**: Rich Value Objects for client-side interactive APIs.
- MUST NOT call `Intl.*` or `toLocale*` (unless client-only).
- MUST NOT implement business rules.
See [`DISPLAY_OBJECTS.md`](docs/architecture/website/DISPLAY_OBJECTS.md:1).
See [`FORMATTERS.md`](docs/architecture/website/FORMATTERS.md:1).
Canonical placement in this repo:

View File

@@ -28,6 +28,8 @@ API Transport DTO
ViewData Builder (lib/builders/view-data/)
Formatters (lib/display-objects/) -- [primitive output]
ViewData (Plain JSON)
Template
@@ -41,7 +43,7 @@ ViewData (from Props)
ViewModel (lib/view-models/) -- [new ViewModel(viewData)]
Display Objects (lib/display-objects/) -- [formatting]
Display Objects (lib/display-objects/) -- [rich API]
Interactive UI State
```