consumer status projection

situation

a process panel shows lifecycle state, active phase, sub-phase, and accumulated units of an active run. facts live in three durable tables: lifecycle, runs, units. early design let the interface fetch each independently. three problems:

  • partial data flashes: lifecycle shown before run loads
  • one unit-fetch error blocks the whole panel even if lifecycle is readable
  • interface infers closed-value meaning from raw rows — storage semantics leak into presentation

design

the contract models the exact display projection:

  • active-run field nullable
  • sub-phase valid only inside its owning parent phase
  • unit list bounded

the service resolves identity, reads lifecycle + active run in parallel, reads units only when a run exists, returns the validated projection. one generated-client operation and one shared query-key factory identify the projection everywhere.

decision process

three designs considered:

optionresultwhy
return the active-run row directly.rejected.the interface would need to infer lifecycle meaning, load related units, and decide null semantics.
make the interface call lifecycle, run, and unit endpoints independently.rejected.it creates partial loading/error states and repeats authoritative composition in every consumer.
expose one bounded status projection.chosen.the service can read authoritative facts together, define activeRun: null, and keep interface state coherent.

the chosen projection separates stable navigation state from the small live part of the screen. only accumulated output is event-driven; the rest stays server-composed. this stops a broadly polling interface from turning a mostly static page into an always-refreshing one.

why

the interface gets one answer to “what should I show now?” instead of several records whose semantics it must rediscover. real-time invalidation targets the same key, so server composition and client refresh cannot drift into competing views of the active run.

transferable rule

when an interface needs a coherent product state assembled from authoritative sources, give it a named read-model operation. persistence composition stays in the service, cache identity in the shared base package, rendering in the interface.

evidence pattern

  • a shared schema defines closed lifecycle and nullability invariants.
  • the live route owns the named operation and response envelope.
  • a domain service composes the projection from authoritative reads.
  • one query hook calls the generated client and exposes the projection to UI.
  • a real-time event invalidates the same shared query key.

verification reasoning

prove five cases, not just a successful request:

  • no active run
  • active run in the parent phase
  • active run in the phase with a required sub-phase
  • an event that refreshes only this projection
  • an invalid response rejected at the typed boundary

each case protects one decision made above.

good and bad

bad

// interface makes three independent calls
const lifecycle = await api.getLifecycle(channelId);
const run = await api.getActiveRun(channelId); // might error, blocks entire panel
const units = run ? await api.getUnits(run.id) : [];
// interface decides what "active" means
const isRunActive = run?.status === 'in_progress' && !run?.completedAt;
// partial state renders (lifecycle loads first, run loads later)
if (lifecycle && !run) {
// what should the interface show? the panel briefly shows lifecycle without run state
return <PartialView lifecycle={lifecycle} />;
}

the interface discovers storage meaning independently. a transient run-fetch error blocks the entire panel. a unit failure renders the panel incomplete. every call point needing lifecycle meaning rewrites the same state machine.

good

const status = await api.readActiveWorkflowStatus(channelId);
// returns { lifecycle, activeRun: RunRecord | null, units: Unit[] }
// interface receives one complete, typed answer
if (status.activeRun === null) {
return <NoActiveRun lifecycle={status.lifecycle} />;
}
// every piece of information came from the same composed read
return (
<ActiveRunPanel
lifecycle={status.lifecycle}
run={status.activeRun}
units={status.units}
/>
);

the service reads authoritative facts in parallel but composes them once before returning. the API owner decides what "active" means. no partial state: all three facts arrive together or the request fails as a whole. every consumer uses the same status shape.

search pages

go to any page