Domain Status Vs Derived View
Separate persisted lifecycle status from derived business views, UI chrome, and capability gates — so frontend labels may differ from backend enums without inventing a second source of truth.
Use this page when frontend “status” and backend enums look inconsistent, or when you are about to add a new *Status / *Phase / canX flag. Mature products (Salesforce formulas, SAP CDS computed fields, CQRS read models, React state rules) treat that gap as normal when the UI is a projection — and as a bug when the client becomes a competing authority.
One-Line Rule
Derive display and capability from canonical facts; never invent a parallel status authority on the client.
When To Use
- You need a badge, filter label, or “effective / overdue / expired” view that is not a workflow transition.
- List, detail, and export must agree on what a label means.
- You are tempted to
useStateadisplayStatusupdated from the API in an Effect. - Approve / Auto / AI / persona language is about to be folded into a lifecycle enum (usually wrong — keep facets orthogonal).
Use Approval And Case Patterns for multi-party surfaces on one instance. Use Status Tag only as the presentation of Kind A/B values. Use Form Layout — Derived Values for money totals inside forms (form density, not Statistic KPIs).
Taxonomy (Kinds A–D)
| Kind | Name | Who owns it | Persist? | Examples |
|---|---|---|---|---|
| A | Domain lifecycle | Backend write model / workflow | Yes — change via commands | draft → pending_* → approved → returned; order open → paid |
| B | Derived business view | Prefer server formula / CDS / read DTO; client pure function only as documented B-lite | Usually no (recomputed) or materialized projection | Offer / contract effective / expired from date window; Salesforce “Overdue”; SAP criticality CASE |
| C | Presentation / chrome | Frontend only | No | Modal open, active tab, StatusTag tone mapping |
| D | Capability / gate | Shared pure function + server re-check | No | canSubmit, persona × status → edit vs read |
“Inconsistent” FE vs BE is fine when the UI shows B/C/D projections of A.
It is a bug when the UI implies an action the API would reject, or two clients derive different labels from the same facts.
Naming
| Prefer | Avoid |
|---|---|
status / headerStatus for Kind A | Writable displayStatus / feStatus / uiStatus |
resolvedStatus, isOverdue, derived.* for Kind B | Reusing status for a client-only CASE |
StatusTag tone for Kind C | Calling color mapping a domain status |
canSubmit, capabilities.* for Kind D | Persisting gate flags on the aggregate |
Decision Matrix
Is this a workflow transition the system must enforce?
├─ YES → Kind A (persist + commands). UI never invents transitions.
└─ NO → Deterministically computable from Kind A (+ clock / config / related)?
├─ YES → Used for filter / report / other clients / jobs / authz / money?
│ ├─ YES → Kind B on the server (formula / CDS / query DTO)
│ └─ NO → Kind B-lite: one pure deriveX(facts) + tests + docs
└─ NO → Kind C chrome — do not call it domain “status”Gates (Kind D): hide or disable buttons from a shared function over wire facts; every command must re-validate on the server. Optimistic UI is allowed; the server remains authority.
Promote To Server (Required)
Move derivation off the SPA when any of these hold:
| Trigger | Why |
|---|---|
| List filter / sort by the label | Query must use one definition |
| Report / export / batch job | Second consumer must match the badge |
| Second client (mobile, partner API) | No SPA monopoly on meaning |
| Authz or money / compliance depends on it | Client cannot be sole oracle |
| Two FE copies already disagree | Fix by centralizing on the API |
Industry anchors: Salesforce formula fields (recomputed on read, platform-owned); SAP CDS computed fields + Object Status criticality; CQRS read models as projections of the write model.
Keep On The Client (Allowed)
| Case | Rule |
|---|---|
| Pure format | Money / date display from facts already on the wire |
| Kind C chrome | Local UI only; never drives ACL |
| Kind B-lite | Cheap, deterministic, single deriveX; inject asOf when time-dependent; document that production may promote |
| Kind D preflight | Same helper the tests use; server still enforces |
React: Avoid redundant state — if you can calculate it during render, do not put it in state. Do not sync derived status with an Effect (You Might Not Need an Effect).
API Contract Shape
Resource (query / view)
├─ status ← Kind A (lifecycle)
├─ …facts ← dates, amounts, flags Kind B needs
├─ resolvedStatus? ← Kind B (read-only; server-computed preferred)
├─ isOverdue? ← Kind B
└─ capabilities? ← optional Kind D hints (never sole ACL)
Commands
└─ accept only Kind A transitions + business facts
reject illegal transitions regardless of what the UI showedDo not accept a client-mapped “display enum” as the write value for lifecycle.
Frontend Authoring
- One mapper per feature: Kind A/B → Status Tag label + tone.
- One
deriveX(or one server field) per derived concept — no copy-pasted CASE in list, detail, and Excel. - Time: pass
asOfinto derivations; do not scatternew Date()with different timezones. - Gates:
canX(facts)shared; button disabled when false; submit path still hits the API. - Role surfaces: viewer × node × instance matrices (Approval And Case Patterns) are projections over Kind A + policy — not a FE-only status machine.
Forbidden
| Smell | Why |
|---|---|
useState(displayStatus) + Effect from API | Drift between tabs / refresh |
| FE-only statuses used for routing or ACL | Competing authority |
Write-back of mapped FE enum to status | Dual write / lost in translation |
| Auto / AI / persona values inside lifecycle enum | Combinatorial explosion — keep outcome ⊥ provenance |
| Business rules inside StatusTag | Tag is presentation only |
Orthogonal Facets (Approvals)
When product language says “Approved Auto”, do not add a fourth lifecycle literal. Split:
| Facet | Holds | Table chrome |
|---|---|---|
| Outcome | pending / approved / rejected (Kind A or line decision) | Primary Status Tag |
| Provenance | system vs human (and rule id / comment) | Optional secondary Label Tag Auto in the same Status cell — see Tag Selection — Multiple Pills |
| Attention | inbox defaults (“needs human”) | Filter / LO — not a third StatusTag |
See Approval And Case Patterns and Tag Selection — Multiple Pills.
Worked Picture
Backend write model ──status (A)──► Query DTO (+ optional resolved* B)
│ │
│ TanStack Query cache
│ │
└──────── facts ─────────────────┴──► deriveX? (B-lite)
│
StatusTag ←─────┤
canSubmit ←─────┤ (D; server re-checks)
modalOpen ←─────┘ (C only)Authoring Checklist
- New
*Status/*Phaseclassified as Kind A / B / C / D. - Kind A: backend enum + command transitions listed.
- Kind B: server read field or single pure
deriveXwith inputs /asOf/ tests documented. - Kind D: server re-validation explicitly required.
- No redundant React state for calculable values.
- List + detail (+ export) share one definition for any badge users filter on.
- StatusTag mapper has no domain transition logic.
Testing
| Kind | Minimum |
|---|---|
| A | Legal / illegal transition tests on the API or domain |
| B / B-lite | Golden tests: fixed facts (+ asOf) → fixed view |
| D | UI disabled when false; API rejects when the gate is false |
| Cross-surface | List and detail that show the same label import the same helper or field |
Examples
Wrong Display Status State
Left side syncs a client displayStatus from the API in an Effect — a second authority that drifts across tabs. Right side keeps Kind A on the wire and shows Kind B as a separate derived field (no parallel status state).
Wrong
Purchase request
api.status = approved
useState(displayStatus) → effective
Effect maps API → displayStatus on every fetch
Client invents a second status authority — tabs / refresh can drift
Right
Purchase request
status = approved (Kind A)
resolvedStatus = effective (Kind B)
No displayStatus state — render from wire facts
Kind A lifecycle + Kind B derived view — one source of truth
import { StatusTag } from "@/components/f-ui/status-tag";
import { DesignCompare } from "@/demos/_design/design-compare";
export function DomainStatusWrongDisplayStatusDemo() {
return (
<DesignCompare
wrong={
<div className="space-y-3">
<p className="text-sm font-medium text-muted-foreground">
Purchase request
</p>
<div className="space-y-2 rounded-lg border bg-muted/30 p-3 font-mono text-xs">
<p>
<span className="text-muted-foreground">api.status</span>
{" = "}
<span className="text-foreground">approved</span>
</p>
<p>
<span className="text-muted-foreground">useState(displayStatus)</span>
{" → "}
<span className="text-destructive">effective</span>
</p>
<p className="text-muted-foreground">
Effect maps API → displayStatus on every fetch
</p>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<StatusTag tone="success">Effective</StatusTag>
</div>
<p className="text-xs text-muted-foreground">
Client invents a second status authority — tabs / refresh can drift
</p>
</div>
}
right={
<div className="space-y-3">
<p className="text-sm font-medium text-muted-foreground">
Purchase request
</p>
<div className="space-y-2 rounded-lg border bg-muted/30 p-3 font-mono text-xs">
<p>
<span className="text-muted-foreground">status</span>
{" = "}
<span className="text-foreground">approved</span>
<span className="text-muted-foreground"> (Kind A)</span>
</p>
<p>
<span className="text-muted-foreground">resolvedStatus</span>
{" = "}
<span className="text-foreground">effective</span>
<span className="text-muted-foreground"> (Kind B)</span>
</p>
<p className="text-muted-foreground">
No displayStatus state — render from wire facts
</p>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<StatusTag tone="success">Approved</StatusTag>
<StatusTag tone="info">Effective</StatusTag>
</div>
<p className="text-xs text-muted-foreground">
Kind A lifecycle + Kind B derived view — one source of truth
</p>
</div>
}
/>
);
}Kinds Gallery
Four panels for Kind A lifecycle, Kind B derived view, Kind C chrome, and Kind D capability gates — so “status” language always maps to one owner.
Domain lifecycle
Persisted write model — changes only via commands
Order: open → paid
Derived business view
Recomputed from Kind A + dates — prefer server read field
Offer window / Salesforce overdue — not a workflow step
Presentation / chrome
Frontend only — never ACL or filter authority
Modal open, tab, StatusTag tone mapping
Capability / gate
Shared canX(facts) — server re-checks every command
Hide or disable from wire facts; never persist gate flags
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { LabelTag } from "@/components/f-ui/label-tag";
import { StatusTag } from "@/components/f-ui/status-tag";
function KindPanel({
kind,
title,
children,
}: {
kind: string;
title: string;
children: ReactNode;
}) {
return (
<div className="rounded-xl border bg-card p-4 space-y-3">
<div className="flex items-center gap-2">
<LabelTag accent="neutral">{kind}</LabelTag>
<p className="text-sm font-medium text-foreground">{title}</p>
</div>
{children}
</div>
);
}
export function DomainStatusKindsGalleryDemo() {
return (
<div className="grid gap-4 sm:grid-cols-2">
<KindPanel kind="A" title="Domain lifecycle">
<p className="text-xs text-muted-foreground">
Persisted write model — changes only via commands
</p>
<div className="flex flex-wrap items-center gap-1.5">
<StatusTag tone="neutral">Draft</StatusTag>
<StatusTag tone="warning">Pending approval</StatusTag>
<StatusTag tone="success">Approved</StatusTag>
</div>
<p className="text-xs text-muted-foreground">Order: open → paid</p>
</KindPanel>
<KindPanel kind="B" title="Derived business view">
<p className="text-xs text-muted-foreground">
Recomputed from Kind A + dates — prefer server read field
</p>
<div className="flex flex-wrap items-center gap-1.5">
<StatusTag tone="info">Effective</StatusTag>
<StatusTag tone="destructive">Expired</StatusTag>
<StatusTag tone="warning">Overdue</StatusTag>
</div>
<p className="text-xs text-muted-foreground">
Offer window / Salesforce overdue — not a workflow step
</p>
</KindPanel>
<KindPanel kind="C" title="Presentation / chrome">
<p className="text-xs text-muted-foreground">
Frontend only — never ACL or filter authority
</p>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">Active tab:</span>
<Button variant="secondary">
Overview
</Button>
<Button variant="ghost">
History
</Button>
</div>
<p className="text-xs text-muted-foreground">
Modal open, tab, StatusTag tone mapping
</p>
</KindPanel>
<KindPanel kind="D" title="Capability / gate">
<p className="text-xs text-muted-foreground">
Shared canX(facts) — server re-checks every command
</p>
<div className="flex flex-wrap items-center gap-2">
<Button disabled>Submit for approval</Button>
<span className="text-xs text-muted-foreground">
canSubmit = false (draft incomplete)
</span>
</div>
<p className="text-xs text-muted-foreground">
Hide or disable from wire facts; never persist gate flags
</p>
</KindPanel>
</div>
);
}Orthogonal Facets
Left side folds “Approved Auto” into one lifecycle literal. Right side splits outcome (StatusTag), provenance (LabelTag), and the submit gate — facets stay independent.
Wrong
Purchase line decision
enum: draft | pending | approved | approved_auto | …
Provenance folded into lifecycle → combinatorial explosion
One mega-status drives badge, filter, and gate language
Right
Purchase line decision
Outcome — Kind A: approved
Provenance — LabelTag Auto (same status cell)
Gate — Kind D: canSubmit already satisfied
Lifecycle ⊥ provenance ⊥ gate — facets stay orthogonal
import { Button } from "@/components/ui/button";
import { LabelTag } from "@/components/f-ui/label-tag";
import { StatusTag } from "@/components/f-ui/status-tag";
import { DesignCompare } from "@/demos/_design/design-compare";
export function DomainStatusOrthogonalFacetsDemo() {
return (
<DesignCompare
wrong={
<div className="space-y-3">
<p className="text-sm font-medium text-muted-foreground">
Purchase line decision
</p>
<div className="flex flex-wrap items-center gap-1.5">
<StatusTag tone="success">Approved auto</StatusTag>
</div>
<div className="space-y-1 rounded-lg border bg-muted/30 p-3 font-mono text-xs text-muted-foreground">
<p>enum: draft | pending | approved | approved_auto | …</p>
<p className="text-destructive">
Provenance folded into lifecycle → combinatorial explosion
</p>
</div>
<Button>Submit for approval</Button>
<p className="text-xs text-muted-foreground">
One mega-status drives badge, filter, and gate language
</p>
</div>
}
right={
<div className="space-y-3">
<p className="text-sm font-medium text-muted-foreground">
Purchase line decision
</p>
<div className="flex flex-wrap items-center gap-1.5">
<StatusTag tone="success">Approved</StatusTag>
<LabelTag accent="neutral">Auto</LabelTag>
</div>
<div className="space-y-1.5 text-xs">
<p>
<span className="font-medium text-foreground">Outcome</span>
<span className="text-muted-foreground">
{" "}
— Kind A: approved
</span>
</p>
<p>
<span className="font-medium text-foreground">Provenance</span>
<span className="text-muted-foreground">
{" "}
— LabelTag Auto (same status cell)
</span>
</p>
<p>
<span className="font-medium text-foreground">Gate</span>
<span className="text-muted-foreground">
{" "}
— Kind D: canSubmit already satisfied
</span>
</p>
</div>
<Button variant="outline" disabled>
Submit for approval
</Button>
<p className="text-xs text-muted-foreground">
Lifecycle ⊥ provenance ⊥ gate — facets stay orthogonal
</p>
</div>
}
/>
);
}Overdue As Derived View
Left side promotes overdue into a write-model enum when the due date passes. Right side keeps Kind A open and derives isOverdue from the date window with an explicit asOf.
Wrong
Shipping label
status = overdue
New lifecycle literal when dueDate < today
Clock movement becomes a write-model transition — wrong Kind
Right
Shipping label
status = open (Kind A)
dueDate = 2026-08-01
isOverdue = true (Kind B, asOf=today)
Date-window view derived from facts — not a new enum
import { StatusTag } from "@/components/f-ui/status-tag";
import { DesignCompare } from "@/demos/_design/design-compare";
export function DomainStatusOverdueDerivedDemo() {
return (
<DesignCompare
wrong={
<div className="space-y-3">
<p className="text-sm font-medium text-muted-foreground">
Shipping label
</p>
<div className="space-y-2 rounded-lg border bg-muted/30 p-3 font-mono text-xs">
<p>
<span className="text-muted-foreground">status</span>
{" = "}
<span className="text-destructive">overdue</span>
</p>
<p className="text-muted-foreground">
New lifecycle literal when dueDate < today
</p>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<StatusTag tone="destructive">Overdue</StatusTag>
</div>
<p className="text-xs text-muted-foreground">
Clock movement becomes a write-model transition — wrong Kind
</p>
</div>
}
right={
<div className="space-y-3">
<p className="text-sm font-medium text-muted-foreground">
Shipping label
</p>
<div className="space-y-2 rounded-lg border bg-muted/30 p-3 font-mono text-xs">
<p>
<span className="text-muted-foreground">status</span>
{" = "}
<span className="text-foreground">open</span>
<span className="text-muted-foreground"> (Kind A)</span>
</p>
<p>
<span className="text-muted-foreground">dueDate</span>
{" = "}
<span className="text-foreground">2026-08-01</span>
</p>
<p>
<span className="text-muted-foreground">isOverdue</span>
{" = "}
<span className="text-foreground">true</span>
<span className="text-muted-foreground">
{" "}
(Kind B, asOf=today)
</span>
</p>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<StatusTag tone="info">Open</StatusTag>
<StatusTag tone="warning">Overdue</StatusTag>
</div>
<p className="text-xs text-muted-foreground">
Date-window view derived from facts — not a new enum
</p>
</div>
}
/>
);
}API Contract Shape
Left side writes a client-mapped display enum back as lifecycle status. Right side shows the preferred read DTO: Kind A status, facts, derived.*, and optional capability hints — commands accept Kind A only.
Wrong
Order read → write
// Client maps then writes back
GET status: "open"
FE maps → displayStatus: "overdue"
PATCH { status: "overdue" }
Display enum accepted as lifecycle write — dual authority
Right
Order query DTO
status: "open" ← Kind A
dueDate: "2026-08-01"
derived: {
isOverdue: true,
resolvedStatus: "overdue"
}
capabilities: { canMarkPaid: true } ← Kind D hint
Commands accept Kind A transitions only — derived.* is read-only
import { Button } from "@/components/ui/button";
import { StatusTag } from "@/components/f-ui/status-tag";
import { DesignCompare } from "@/demos/_design/design-compare";
export function DomainStatusApiContractDemo() {
return (
<DesignCompare
wrong={
<div className="space-y-3">
<p className="text-sm font-medium text-muted-foreground">
Order read → write
</p>
<div className="space-y-2 rounded-lg border bg-muted/30 p-3 font-mono text-xs leading-relaxed">
<p className="text-muted-foreground">// Client maps then writes back</p>
<p>
<span className="text-muted-foreground">GET</span>
{" status: "}
<span className="text-foreground">"open"</span>
</p>
<p>
<span className="text-muted-foreground">FE maps →</span>
{" displayStatus: "}
<span className="text-destructive">"overdue"</span>
</p>
<p>
<span className="text-muted-foreground">PATCH</span>
{" { status: "}
<span className="text-destructive">"overdue"</span>
{" }"}
</p>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<StatusTag tone="destructive">Overdue</StatusTag>
</div>
<p className="text-xs text-muted-foreground">
Display enum accepted as lifecycle write — dual authority
</p>
</div>
}
right={
<div className="space-y-3">
<p className="text-sm font-medium text-muted-foreground">
Order query DTO
</p>
<div className="space-y-2 rounded-lg border bg-muted/30 p-3 font-mono text-xs leading-relaxed">
<p>
<span className="text-foreground">status</span>
<span className="text-muted-foreground">: "open"</span>
<span className="text-muted-foreground"> ← Kind A</span>
</p>
<p>
<span className="text-foreground">dueDate</span>
<span className="text-muted-foreground">: "2026-08-01"</span>
</p>
<p>
<span className="text-foreground">derived</span>
<span className="text-muted-foreground">: {"{"}</span>
</p>
<p className="pl-3">
<span className="text-foreground">isOverdue</span>
<span className="text-muted-foreground">: true,</span>
</p>
<p className="pl-3">
<span className="text-foreground">resolvedStatus</span>
<span className="text-muted-foreground">: "overdue"</span>
</p>
<p>
<span className="text-muted-foreground">{"}"}</span>
</p>
<p>
<span className="text-foreground">capabilities</span>
<span className="text-muted-foreground">
: {"{ canMarkPaid: true }"} ← Kind D hint
</span>
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<StatusTag tone="info">Open</StatusTag>
<StatusTag tone="warning">Overdue</StatusTag>
<Button>Mark paid</Button>
</div>
<p className="text-xs text-muted-foreground">
Commands accept Kind A transitions only — derived.* is read-only
</p>
</div>
}
/>
);
}See Also
- Status Tag
- Approval And Case Patterns
- Form Layout (derived money vs Statistic)
- List Page Statistics (KPI placement ≠ form derived amounts)
- CRUD Page Patterns
Button And Action Emphasis
Global locked rules for every in-app button — primary vs outline vs ghost, icons vs icon-only, destructive and warning — Ant-backed, mapped to shadcn Button.
List Page Statistics
Norms for Ant exclusive-area KPIs — page order, filter scope, backend summary contracts, TanStack Query wiring, and async states.