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

@@ -1,87 +0,0 @@
/**
* ESLint rules for Display Object Guardrails
*
* Enforces display object boundaries and purity
*/
module.exports = {
// Rule 1: No IO in display objects
'no-io-in-display-objects': {
meta: {
type: 'problem',
docs: {
description: 'Forbid IO imports in display objects',
category: 'Display Objects',
},
messages: {
message: 'DisplayObjects cannot import from api, services, page-queries, or view-models - see apps/website/lib/contracts/display-objects/DisplayObject.ts',
},
},
create(context) {
const forbiddenPaths = [
'@/lib/api/',
'@/lib/services/',
'@/lib/page-queries/',
'@/lib/view-models/',
'@/lib/presenters/',
];
return {
ImportDeclaration(node) {
const importPath = node.source.value;
if (forbiddenPaths.some(path => importPath.includes(path)) &&
!isInComment(node)) {
context.report({
node,
messageId: 'message',
});
}
},
};
},
},
// Rule 2: No non-class display exports
'no-non-class-display-exports': {
meta: {
type: 'problem',
docs: {
description: 'Forbid non-class exports in display objects',
category: 'Display Objects',
},
messages: {
message: 'Display Objects must be class-based and export only classes - see apps/website/lib/contracts/display-objects/DisplayObject.ts',
},
},
create(context) {
return {
ExportNamedDeclaration(node) {
if (node.declaration &&
(node.declaration.type === 'FunctionDeclaration' ||
(node.declaration.type === 'VariableDeclaration' &&
!node.declaration.declarations.some(d => d.init && d.init.type === 'ClassExpression')))) {
context.report({
node,
messageId: 'message',
});
}
},
ExportDefaultDeclaration(node) {
if (node.declaration &&
node.declaration.type !== 'ClassDeclaration' &&
node.declaration.type !== 'ClassExpression') {
context.report({
node,
messageId: 'message',
});
}
},
};
},
},
};
// Helper functions
function isInComment(node) {
return false;
}

View File

@@ -0,0 +1,138 @@
/**
* ESLint rules for Formatter/Display Guardrails
*
* Enforces boundaries and purity for Formatters and Display Objects
*/
module.exports = {
// Rule 1: No IO in formatters/displays
'no-io-in-display-objects': {
meta: {
type: 'problem',
docs: {
description: 'Forbid IO imports in formatters and displays',
category: 'Formatters',
},
messages: {
message: 'Formatters/Displays cannot import from api, services, page-queries, or view-models - see apps/website/lib/contracts/formatters/Formatter.ts',
},
},
create(context) {
const forbiddenPaths = [
'@/lib/api/',
'@/lib/services/',
'@/lib/page-queries/',
'@/lib/view-models/',
'@/lib/presenters/',
];
return {
ImportDeclaration(node) {
const importPath = node.source.value;
if (forbiddenPaths.some(path => importPath.includes(path)) &&
!isInComment(node)) {
context.report({
node,
messageId: 'message',
});
}
},
};
},
},
// Rule 2: No non-class display exports
'no-non-class-display-exports': {
meta: {
type: 'problem',
docs: {
description: 'Forbid non-class exports in formatters and displays',
category: 'Formatters',
},
messages: {
message: 'Formatters and Displays must be class-based and export only classes - see apps/website/lib/contracts/formatters/Formatter.ts',
},
},
create(context) {
return {
ExportNamedDeclaration(node) {
if (node.declaration &&
(node.declaration.type === 'FunctionDeclaration' ||
(node.declaration.type === 'VariableDeclaration' &&
!node.declaration.declarations.some(d => d.init && d.init.type === 'ClassExpression')))) {
context.report({
node,
messageId: 'message',
});
}
},
ExportDefaultDeclaration(node) {
if (node.declaration &&
node.declaration.type !== 'ClassDeclaration' &&
node.declaration.type !== 'ClassExpression') {
context.report({
node,
messageId: 'message',
});
}
},
};
},
},
// Rule 3: Formatters must return primitives
'formatters-must-return-primitives': {
meta: {
type: 'problem',
docs: {
description: 'Enforce that Formatters return primitive values for ViewData compatibility',
category: 'Formatters',
},
messages: {
message: 'Formatters used in ViewDataBuilders must return primitive values (string, number, boolean, null) - see apps/website/lib/contracts/formatters/Formatter.ts',
},
},
create(context) {
const filename = context.getFilename();
const isViewDataBuilder = filename.includes('/lib/builders/view-data/');
if (!isViewDataBuilder) return {};
return {
CallExpression(node) {
// Check if calling a Formatter/Display method
if (node.callee.type === 'MemberExpression' &&
node.callee.object.name &&
(node.callee.object.name.endsWith('Formatter') || node.callee.object.name.endsWith('Display'))) {
// If it's inside a ViewData object literal, it must be a primitive return
let parent = node.parent;
while (parent) {
if (parent.type === 'Property' && parent.parent.type === 'ObjectExpression') {
// This is a property in an object literal (likely ViewData)
// We can't easily check the return type of the method at lint time without type info,
// but we can enforce that it's not the whole object being assigned.
if (node.callee.property.name === 'format' || node.callee.property.name.startsWith('format')) {
// Good: calling a format method
return;
}
// If they are assigning the result of a non-format method, warn
context.report({
node,
messageId: 'message',
});
}
parent = parent.parent;
}
}
},
};
},
},
};
// Helper functions
function isInComment(node) {
return false;
}

View File

@@ -17,7 +17,7 @@
const presenterContract = require('./presenter-contract'); const presenterContract = require('./presenter-contract');
const rscBoundaryRules = require('./rsc-boundary-rules'); const rscBoundaryRules = require('./rsc-boundary-rules');
const templatePurityRules = require('./template-purity-rules'); const templatePurityRules = require('./template-purity-rules');
const displayObjectRules = require('./display-object-rules'); const displayObjectRules = require('./formatter-rules');
const pageQueryRules = require('./page-query-rules'); const pageQueryRules = require('./page-query-rules');
const servicesRules = require('./services-rules'); const servicesRules = require('./services-rules');
const clientOnlyRules = require('./client-only-rules'); const clientOnlyRules = require('./client-only-rules');
@@ -30,7 +30,6 @@ const mutationContract = require('./mutation-contract');
const serverActionsMustUseMutations = require('./server-actions-must-use-mutations'); const serverActionsMustUseMutations = require('./server-actions-must-use-mutations');
const viewDataLocation = require('./view-data-location'); const viewDataLocation = require('./view-data-location');
const viewDataBuilderContract = require('./view-data-builder-contract'); const viewDataBuilderContract = require('./view-data-builder-contract');
const viewModelBuilderContract = require('./view-model-builder-contract');
const singleExportPerFile = require('./single-export-per-file'); const singleExportPerFile = require('./single-export-per-file');
const filenameMatchesExport = require('./filename-matches-export'); const filenameMatchesExport = require('./filename-matches-export');
const pageQueryMustUseBuilders = require('./page-query-must-use-builders'); const pageQueryMustUseBuilders = require('./page-query-must-use-builders');
@@ -48,7 +47,6 @@ const serverActionsInterface = require('./server-actions-interface');
const noDisplayObjectsInUi = require('./no-display-objects-in-ui'); const noDisplayObjectsInUi = require('./no-display-objects-in-ui');
const viewDataBuilderImplements = require('./view-data-builder-implements'); const viewDataBuilderImplements = require('./view-data-builder-implements');
const viewDataBuilderImports = require('./view-data-builder-imports'); const viewDataBuilderImports = require('./view-data-builder-imports');
const viewModelBuilderImplements = require('./view-model-builder-implements');
const viewDataImplements = require('./view-data-implements'); const viewDataImplements = require('./view-data-implements');
const viewModelImplements = require('./view-model-implements'); const viewModelImplements = require('./view-model-implements');
const viewModelTaxonomy = require('./view-model-taxonomy'); const viewModelTaxonomy = require('./view-model-taxonomy');
@@ -85,6 +83,7 @@ module.exports = {
// Display Object Rules // Display Object Rules
'display-no-domain-models': displayObjectRules['no-io-in-display-objects'], 'display-no-domain-models': displayObjectRules['no-io-in-display-objects'],
'display-no-business-logic': displayObjectRules['no-non-class-display-exports'], 'display-no-business-logic': displayObjectRules['no-non-class-display-exports'],
'formatters-must-return-primitives': displayObjectRules['formatters-must-return-primitives'],
'no-display-objects-in-ui': noDisplayObjectsInUi, 'no-display-objects-in-ui': noDisplayObjectsInUi,
// Page Query Rules // Page Query Rules
@@ -139,8 +138,6 @@ module.exports = {
'view-data-implements': viewDataImplements, 'view-data-implements': viewDataImplements,
// View Model Rules // View Model Rules
'view-model-builder-contract': viewModelBuilderContract,
'view-model-builder-implements': viewModelBuilderImplements,
'view-model-implements': viewModelImplements, 'view-model-implements': viewModelImplements,
'view-model-taxonomy': viewModelTaxonomy, 'view-model-taxonomy': viewModelTaxonomy,
@@ -222,6 +219,7 @@ module.exports = {
// Display Objects // Display Objects
'gridpilot-rules/display-no-domain-models': 'error', 'gridpilot-rules/display-no-domain-models': 'error',
'gridpilot-rules/display-no-business-logic': 'error', 'gridpilot-rules/display-no-business-logic': 'error',
'gridpilot-rules/formatters-must-return-primitives': 'error',
'gridpilot-rules/no-display-objects-in-ui': 'error', 'gridpilot-rules/no-display-objects-in-ui': 'error',
// Page Queries // Page Queries

View File

@@ -1,105 +0,0 @@
/**
* ESLint rule to enforce View Model Builder contract
*
* View Model Builders must:
* 1. Be classes named *ViewModelBuilder
* 2. Have a static build() method
* 3. Use 'satisfies ViewModelBuilder<...>' for static enforcement
* 4. Accept View Data as parameter
* 5. Return View Model
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Enforce View Model Builder contract',
category: 'Builders',
recommended: true,
},
fixable: null,
schema: [],
messages: {
notAClass: 'View Model Builders must be classes named *ViewModelBuilder',
missingStaticBuild: 'View Model Builders must have a static build() method',
missingSatisfies: 'View Model Builders must use "satisfies ViewModelBuilder<...>" for static type enforcement',
invalidBuildSignature: 'build() method must accept View Data and return View Model',
},
},
create(context) {
const filename = context.getFilename();
const isInViewModelBuilders = filename.includes('/lib/builders/view-models/');
if (!isInViewModelBuilders) return {};
let hasStaticBuild = false;
let hasSatisfies = false;
let hasCorrectSignature = false;
return {
// Check class declaration
ClassDeclaration(node) {
const className = node.id?.name;
if (!className || !className.endsWith('ViewModelBuilder')) {
context.report({
node,
messageId: 'notAClass',
});
}
// Check for static build method
const staticBuild = node.body.body.find(member =>
member.type === 'MethodDefinition' &&
member.key.type === 'Identifier' &&
member.key.name === 'build' &&
member.static === true
);
if (staticBuild) {
hasStaticBuild = true;
// Check signature - should have at least one parameter
if (staticBuild.value &&
staticBuild.value.params &&
staticBuild.value.params.length > 0) {
hasCorrectSignature = true;
}
}
},
// Check for satisfies expression
TSSatisfiesExpression(node) {
if (node.typeAnnotation &&
node.typeAnnotation.type === 'TSTypeReference' &&
node.typeAnnotation.typeName.name === 'ViewModelBuilder') {
hasSatisfies = true;
}
},
'Program:exit'() {
if (!hasStaticBuild) {
context.report({
node: context.getSourceCode().ast,
messageId: 'missingStaticBuild',
});
}
if (!hasSatisfies) {
context.report({
node: context.getSourceCode().ast,
messageId: 'missingSatisfies',
});
}
if (hasStaticBuild && !hasCorrectSignature) {
context.report({
node: context.getSourceCode().ast,
messageId: 'invalidBuildSignature',
});
}
},
};
},
};

View File

@@ -1,96 +0,0 @@
/**
* ESLint rule to enforce View Model Builder contract implementation
*
* View Model Builders in lib/builders/view-models/ must:
* 1. Be classes named *ViewModelBuilder
* 2. Implement the ViewModelBuilder<TInput, TOutput> interface
* 3. Have a static build() method
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Enforce View Model Builder contract implementation',
category: 'Builders',
recommended: true,
},
fixable: null,
schema: [],
messages: {
notAClass: 'View Model Builders must be classes named *ViewModelBuilder',
missingImplements: 'View Model Builders must implement ViewModelBuilder<TInput, TOutput> interface',
missingBuildMethod: 'View Model Builders must have a static build() method',
},
},
create(context) {
const filename = context.getFilename();
const isInViewModelBuilders = filename.includes('/lib/builders/view-models/');
if (!isInViewModelBuilders) return {};
let hasImplements = false;
let hasBuildMethod = false;
return {
// Check class declaration
ClassDeclaration(node) {
const className = node.id?.name;
if (!className || !className.endsWith('ViewModelBuilder')) {
context.report({
node,
messageId: 'notAClass',
});
}
// Check if class implements ViewModelBuilder interface
if (node.implements && node.implements.length > 0) {
for (const impl of node.implements) {
// Handle GenericTypeAnnotation for ViewModelBuilder<TInput, TOutput>
if (impl.expression.type === 'TSInstantiationExpression') {
const expr = impl.expression.expression;
if (expr.type === 'Identifier' && expr.name === 'ViewModelBuilder') {
hasImplements = true;
}
} else if (impl.expression.type === 'Identifier') {
// Handle simple ViewModelBuilder (without generics)
if (impl.expression.name === 'ViewModelBuilder') {
hasImplements = true;
}
}
}
}
// Check for static build method
const buildMethod = node.body.body.find(member =>
member.type === 'MethodDefinition' &&
member.key.type === 'Identifier' &&
member.key.name === 'build' &&
member.static === true
);
if (buildMethod) {
hasBuildMethod = true;
}
},
'Program:exit'() {
if (!hasImplements) {
context.report({
node: context.getSourceCode().ast,
messageId: 'missingImplements',
});
}
if (!hasBuildMethod) {
context.report({
node: context.getSourceCode().ast,
messageId: 'missingBuildMethod',
});
}
},
};
},
};

View File

@@ -5,7 +5,7 @@
* 1. NOT contain the word "DTO" (DTOs are for API/Services) * 1. NOT contain the word "DTO" (DTOs are for API/Services)
* 2. NOT define ViewData interfaces (ViewData must be in lib/view-data/) * 2. NOT define ViewData interfaces (ViewData must be in lib/view-data/)
* 3. NOT import from DTO paths (DTOs belong to lib/types/generated/) * 3. NOT import from DTO paths (DTOs belong to lib/types/generated/)
* 4. ONLY import from allowed paths: lib/contracts/, lib/view-models/, lib/view-data/, lib/display-objects/ * 4. ONLY import from allowed paths: lib/contracts/, lib/view-models/, lib/view-data/, lib/formatters/
*/ */
module.exports = { module.exports = {
@@ -22,7 +22,7 @@ module.exports = {
noDtoInViewModel: 'ViewModels must not use the word "DTO". DTOs belong to the API/Service layer. Use plain logic-rich properties or ViewData types.', noDtoInViewModel: 'ViewModels must not use the word "DTO". DTOs belong to the API/Service layer. Use plain logic-rich properties or ViewData types.',
noDtoImport: 'ViewModels must not import from DTO paths. DTOs belong to lib/types/generated/. Import from lib/view-data/ or use plain properties instead.', noDtoImport: 'ViewModels must not import from DTO paths. DTOs belong to lib/types/generated/. Import from lib/view-data/ or use plain properties instead.',
noViewDataDefinition: 'ViewData must not be defined within ViewModel files. Import them from lib/view-data/ instead.', noViewDataDefinition: 'ViewData must not be defined within ViewModel files. Import them from lib/view-data/ instead.',
strictImport: 'ViewModels can only import from lib/contracts/, lib/view-models/, lib/view-data/, or lib/display-objects/. External imports are allowed. Found: {{importPath}}', strictImport: 'ViewModels can only import from lib/contracts/, lib/view-models/, lib/view-data/, or lib/formatters/. External imports are allowed. Found: {{importPath}}',
}, },
}, },
@@ -70,7 +70,7 @@ module.exports = {
'@/lib/contracts/', '@/lib/contracts/',
'@/lib/view-models/', '@/lib/view-models/',
'@/lib/view-data/', '@/lib/view-data/',
'@/lib/display-objects/', '@/lib/formatters/',
]; ];
const isAllowed = allowedPaths.some(path => importPath.startsWith(path)); const isAllowed = allowedPaths.some(path => importPath.startsWith(path));
@@ -83,12 +83,12 @@ module.exports = {
importPath.includes('/lib/contracts/') || importPath.includes('/lib/contracts/') ||
importPath.includes('/lib/view-models/') || importPath.includes('/lib/view-models/') ||
importPath.includes('/lib/view-data/') || importPath.includes('/lib/view-data/') ||
importPath.includes('/lib/display-objects/') || importPath.includes('/lib/formatters/') ||
// Also check for patterns like ../contracts/... // Also check for patterns like ../contracts/...
importPath.includes('contracts') || importPath.includes('contracts') ||
importPath.includes('view-models') || importPath.includes('view-models') ||
importPath.includes('view-data') || importPath.includes('view-data') ||
importPath.includes('display-objects') || importPath.includes('formatters') ||
// Allow relative imports to view models (e.g., ./InvoiceViewModel, ../ViewModelName) // Allow relative imports to view models (e.g., ./InvoiceViewModel, ../ViewModelName)
// This matches patterns like ./ViewModelName or ../ViewModelName // This matches patterns like ./ViewModelName or ../ViewModelName
/^\.\/[A-Z][a-zA-Z0-9]*ViewModel$/.test(importPath) || /^\.\/[A-Z][a-zA-Z0-9]*ViewModel$/.test(importPath) ||

View File

@@ -1,32 +0,0 @@
/**
* ViewModel Builder Contract
*
* Purpose: Transform ViewData into ViewModels for client-side state management
*
* Rules:
* - Deterministic and side-effect free
* - No HTTP/API calls
* - Input: ViewData (JSON-serializable template-ready data)
* - Output: ViewModel (client-only class)
* - Must be in lib/builders/view-models/
* - Must be named *ViewModelBuilder
* - Must have 'use client' directive
* - Must implement static build() method
* - Must use 'satisfies' for static type enforcement
*/
import { ViewData } from '../view-data/ViewData';
import { ViewModel } from '../view-models/ViewModel';
/**
* ViewModel Builder Contract (Static)
*
* Usage:
* export class MyViewModelBuilder {
* static build(viewData: MyViewData): MyViewModel { ... }
* }
* MyViewModelBuilder satisfies ViewModelBuilder<MyViewData, MyViewModel>;
*/
export interface ViewModelBuilder<TViewData extends ViewData, TViewModel extends ViewModel> {
build(viewData: TViewData): TViewModel;
}

View File

@@ -1,22 +1,40 @@
/** /**
* Formatter contract * Formatter contract
* *
* Deterministic, reusable, UI-only formatting/mapping logic. * Deterministic, reusable, UI-only formatting/mapping logic.
* *
* Based on DISPLAY_OBJECTS.md: * Based on FORMATTERS.md:
* - Class-based * - Stateless (Formatters) or Immutable (Display Objects)
* - Immutable
* - Deterministic * - Deterministic
* - Side-effect free * - Side-effect free
* - No Intl.* or toLocale*
* - No business rules * - No business rules
*
* Uncle Bob says: "Data structures should not have behavior."
* Formatters ensure ViewData remains a dumb container of primitives.
*/ */
export interface Formatter { export interface Formatter {
/** /**
* Format or map the display object * Format or map the input to a primitive value
* *
* @returns Primitive values only (strings, numbers, booleans) * @returns Primitive values only (strings, numbers, booleans, null)
*/ */
format(): unknown; format(): string | number | boolean | null;
}
/**
* Rich Display Object contract (Client-only)
*
* Used by ViewModels to provide a rich API to the UI.
*/
export interface DisplayObject {
/**
* Primary primitive output
*/
format(): string | number | boolean | null;
/**
* Multiple primitive variants
*/
variants?(): Record<string, string | number | boolean | null>;
} }

View File

@@ -1,16 +1,14 @@
import { JsonValue } from "../types/primitives";
/** /**
* Base interface for ViewData objects * Base interface for ViewData objects
* *
* All ViewData must be JSON-serializable for SSR. * All ViewData must be JSON-serializable for SSR.
* This type ensures no class instances or functions are included. * This type ensures no class instances or functions are included.
* *
* Note: We use 'any' here to allow complex DTO structures, but the * Uncle Bob says: "Data structures should not have behavior."
* architectural rule is that these must be plain JSON objects. * ViewData is a dumb container for primitives and nested JSON only.
*/ */
export interface ViewData { export interface ViewData {
[key: string]: JsonValue; [key: string]: any;
} }
/** /**
* Helper type to ensure a type is ViewData-compatible * Helper type to ensure a type is ViewData-compatible

View File

@@ -5,7 +5,6 @@ import { ViewData } from '@/lib/contracts/view-data/ViewData';
* *
* ViewData for category icon media rendering. * ViewData for category icon media rendering.
*/ */
import { ViewData } from "../contracts/view-data/ViewData";
export interface CategoryIconViewData extends ViewData { export interface CategoryIconViewData extends ViewData {
buffer: string; // base64 encoded buffer: string; // base64 encoded

View File

@@ -2,41 +2,25 @@
This document defines the **Builder** pattern for `apps/website`. 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 ## 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 ViewData 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
Transform API DTOs directly into ViewData for templates. 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/**` **Location**: `apps/website/lib/builders/view-data/**`
**Pattern**: **Pattern**:
```typescript ```typescript
import { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
export class LeagueViewDataBuilder { export class LeagueViewDataBuilder {
static build(apiDto: LeagueApiDto): LeagueDetailViewData extends ViewData { static build(apiDto: LeagueApiDto): LeagueDetailViewData {
return { return {
leagueId: apiDto.id, leagueId: apiDto.id,
name: apiDto.name, 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 ## 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 ### ViewData Builders
1. MUST be deterministic 1. MUST be deterministic.
2. MUST be side-effect free 2. MUST be side-effect free.
3. MUST NOT perform HTTP 3. MUST NOT perform HTTP or call API clients.
4. MUST NOT call API clients 4. Input: API DTO.
5. MUST NOT access cookies/headers 5. Output: ViewData (Plain JSON).
6. Input: API DTO 6. MUST live in `lib/builders/view-data/**`.
7. Output: ViewData 7. MUST use `static build()` and `satisfies ViewDataBuilder`.
8. MUST live in `lib/builders/view-data/**` 8. MUST use **Formatters** for primitive output (strings/numbers).
## 3) Why two builder types? ## 3) Why no ViewModel Builders?
**ViewModel Builders** (API → Client State): **ViewModels are self-building.**
- Bridge the API boundary
- Convert transport types to client classes
- Add client-only fields if needed
- Run in client code
**ViewData Builders** (API → Render Data): 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.
- Bridge the presentation boundary
- Transform API data directly for templates **❌ Redundant Pattern (Forbidden):**
- Format values for display ```typescript
- Run in server code (RSC) // 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 ## 4) Relationship to other patterns
``` ```
API Transport DTO
ViewModel Builder (lib/builders/view-models/)
ViewModel (lib/view-models/)
(for client components)
API Transport DTO API Transport DTO
ViewData Builder (lib/builders/view-data/) 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 ## 5) Naming convention
**ViewModel Builders**: `*ViewModelBuilder`
- `AdminViewModelBuilder`
- `RaceViewModelBuilder`
**ViewData Builders**: `*ViewDataBuilder` **ViewData Builders**: `*ViewDataBuilder`
- `LeagueViewDataBuilder` - `LeagueViewDataBuilder`
- `RaceViewDataBuilder` - `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 ```typescript
import { LeagueViewDataBuilder } from '@/lib/builders/view-data/LeagueViewDataBuilder'; import { LeagueViewDataBuilder } from '@/lib/builders/view-data/LeagueViewDataBuilder';
import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery'; import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery';
export default async function LeagueDetailPage({ params }) { export default async function LeagueDetailPage({ params }) {
const apiDto = await LeagueDetailPageQuery.execute(params.id); const apiDto = await LeagueDetailPageQuery.execute(params.id);
// Transform to flat JSON for SSR
const viewData = LeagueViewDataBuilder.build(apiDto); const viewData = LeagueViewDataBuilder.build(apiDto);
return <LeagueDetailTemplate viewData={viewData} />; return <LeagueDetailTemplate viewData={viewData} />;
} }
``` ```
## 8) Common mistakes ## 7) Enforcement
**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
These rules are enforced by ESLint: These rules are enforced by ESLint:
- `gridpilot-rules/view-model-builder-contract`
- `gridpilot-rules/view-data-builder-contract` - `gridpilot-rules/view-data-builder-contract`
- `gridpilot-rules/filename-view-model-builder-match`
- `gridpilot-rules/filename-view-data-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} /> <SearchInput value={searchQuery} onChange={onSearchChange} />
</Header> </Header>
<TeamLeaderboardPreview <TeamLeaderboardPreview
teams={teams} teams={teams}
onTeamClick={onTeamClick} onTeamClick={onTeamClick}
/> />
<TeamGrid 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) { export function TeamLeaderboardPreview({ teams, onTeamClick }: Props) {
// App-specific logic: medal colors, ranking, etc. // App-specific logic: medal colors, ranking, etc.
const getMedalColor = (position: number) => { const getMedalColor = (position: number) => {
if (position === 0) return 'gold'; if (position === 0) return 'gold';
if (position === 1) return 'silver'; if (position === 1) return 'silver';
return 'none'; return 'none';
@@ -133,7 +133,7 @@ export function TeamLeaderboardPreview({ teams, onTeamClick }: Props) {
<Card variant="elevated"> <Card variant="elevated">
<CardHeader title="Top Teams" /> <CardHeader title="Top Teams" />
{teams.map((team, index) => ( {teams.map((team, index) => (
<TeamRow <TeamRow
key={team.id} key={team.id}
name={team.name} name={team.name}
rank={index + 1} rank={index + 1}
@@ -174,7 +174,7 @@ export function Button({ children, variant = 'primary', onClick }: ButtonProps)
}[variant]; }[variant];
return ( return (
<button <button
className={`px-4 py-2 rounded-md transition-colors ${classes}`} className={`px-4 py-2 rounded-md transition-colors ${classes}`}
onClick={onClick} 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/`. - **`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/`. - **`templates/`**: Same as `components/`.
## The Display Object Layer ## The Formatter & Display Object Layer
**Purpose**: Reusable formatting and presentation logic **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 - Value transformations for display
**Rules**: **Rules**:
- **Formatters**: Stateless utilities for server-side primitive output.
- **Display Objects**: Rich Value Objects for client-side interactive APIs.
- Class-based - Class-based
- Immutable - Immutable
- Deterministic - Deterministic
- No side effects - No side effects
- No `Intl.*` or `toLocale*` - No `Intl.*` or `toLocale*` (unless client-only)
**Usage**: **Usage**:
```typescript ```typescript
@@ -256,11 +258,15 @@ export class RatingDisplay {
} }
} }
// In ViewModel Builder // In ViewData Builder (Server)
const viewModel = { const viewData = {
rating: RatingDisplay.format(dto.rating), rating: RatingDisplay.format(dto.rating), // Primitive string
ratingColor: RatingDisplay.getColor(dto.rating)
}; };
// In ViewModel (Client)
get rating() {
return new RatingDisplay(this.data.rating); // Rich API
}
``` ```
## Dependency Flow ## Dependency Flow
@@ -292,7 +298,7 @@ ui/ (generic primitives)
- **Component**: Understands app concepts (teams, races, leagues) - **Component**: Understands app concepts (teams, races, leagues)
- **UI**: Generic building blocks (button, card, input) - **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 formatting is reusable across multiple ViewModels
- When mapping codes to labels - When mapping codes to labels
- When presentation logic needs to be deterministic - 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 - arrays and plain objects
- `null` for missing values - `null` for missing values
**Uncle Bob says**: "Data structures should not have behavior." ViewData is a dumb container.
## 2) What ViewData is NOT ## 2) What ViewData is NOT
ViewData is not: ViewData is not:
- an API Transport DTO (raw transport) - an API Transport DTO (raw transport)
- a ViewModel (client-only class) - a ViewModel (client-only class)
- a Display Object instance - a Display Object instance (rich API)
## 3) Construction rules ## 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). 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. - **Template Simplicity:** Templates should simply render the strings provided in `ViewData` without further processing.
## 5) Determinism rules ## 5) Determinism rules
@@ -75,7 +77,7 @@ Reason: SSR and browser outputs can differ.
Localization MUST NOT depend on runtime locale APIs. 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. 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: ViewData serves as the stable, serializable contract between the server and client. It is:
- The input for Templates (both SSR and Client) - 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. **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 ### The "Redundancy" Question
DTOs, ViewData, and ViewModels might share similar fields, but they are NOT redundant: 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. View Models are **UI-owned** classes.
They do not represent business truth and do not enforce domain rules. 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 ## Responsibilities
@@ -22,14 +24,14 @@ A View Model MAY:
- combine or reshape data for rendering - combine or reshape data for rendering
- perform formatting (dates, numbers, labels), especially for **client-only context** (e.g., local timezone, relative "time ago") - perform formatting (dates, numbers, labels), especially for **client-only context** (e.g., local timezone, relative "time ago")
- handle localization and presentation logic - handle localization and presentation logic
- use Display Objects for reusable UI concerns - use **Display Objects** for reusable UI concerns and rich APIs
### Formatting Responsibility ### 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. - **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. - **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. 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. 1) View Models are created in client code only.
2) View Models are created from **ViewData** (not from DTOs). 2) View Models are created from **ViewData** (not from DTOs).
3) Templates MUST NOT accept View Models; Templates accept ViewData only. 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). 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) - `apps/website/lib/types/**` (transport DTOs consumed by services and page queries)
### 3.2 API Transport DTO ### 3.2 ViewData
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
Definition: the only allowed input type for Templates. Definition: the only allowed input type for Templates.
@@ -73,6 +60,7 @@ Rules:
- JSON-serializable only. - JSON-serializable only.
- Contains only template-ready values (mostly strings/numbers/booleans). - Contains only template-ready values (mostly strings/numbers/booleans).
- MUST NOT contain class instances. - 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). See [`VIEW_DATA.md`](docs/architecture/website/VIEW_DATA.md:1).
@@ -89,6 +77,7 @@ Rules:
- Instantiated only in `'use client'` modules. - Instantiated only in `'use client'` modules.
- Never serialized. - Never serialized.
- Used for client components that need state management. - 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). See [`VIEW_MODELS.md`](docs/architecture/website/VIEW_MODELS.md:1).
@@ -112,6 +101,7 @@ Rules:
- MUST NOT call the API. - MUST NOT call the API.
- Input: `Result<ApiDto, string>` or `ApiDto` - Input: `Result<ApiDto, string>` or `ApiDto`
- Output: ViewModel - Output: ViewModel
- MUST use `static build()` and `satisfies ViewModelBuilder`.
See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md:1). See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md:1).
@@ -133,6 +123,7 @@ Rules:
- MUST NOT call the API. - MUST NOT call the API.
- Input: `Result<ApiDto, string>` or `ApiDto` - Input: `Result<ApiDto, string>` or `ApiDto`
- Output: ViewData - Output: ViewData
- MUST use `static build()` and `satisfies ViewDataBuilder`.
See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md:1). See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md:1).
@@ -161,18 +152,18 @@ Canonical placement in this repo:
- `apps/website/lib/contracts/Result.ts` - `apps/website/lib/contracts/Result.ts`
### 4.3 Display Object ### 4.4 Formatter & Display Object
Definition: deterministic, reusable, UI-only formatting/mapping logic. Definition: deterministic, reusable, UI-only formatting/mapping logic.
Rules: Rules:
- Class-based, immutable, deterministic. - **Formatters**: Stateless utilities for server-side primitive output.
- MUST NOT call `Intl.*`. - **Display Objects**: Rich Value Objects for client-side interactive APIs.
- MUST NOT call any `toLocale*`. - MUST NOT call `Intl.*` or `toLocale*` (unless client-only).
- MUST NOT implement business rules. - 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: Canonical placement in this repo:

View File

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