Appendix B - 21. User-journey model (product-goal → implementation bridge)
The Structure of User-journey model (product-goal → implementation bridge) — its shape at a glance:
Each journey is an actor pursuing a goal through ordered steps, each step naming the endpoint it crosses a boundary to reach. A call-site drift lint holds the derived dependency list to the real calls in both directions.
flowchart LR
Actor([Actor]) --> J[Journey: goal]
J --> S1[Step 1] --> S2[Step 2]
S1 -->|calls| E1[/endpoint/]
S2 -->|calls| E2[/endpoint/]
E1 -.->|declared ↔ real| Drift{{Call-site drift lint}}
Accessible description: an actor pursues a goal through ordered steps, each step calling an endpoint. A call-site drift lint reconciles the journey's declared dependencies against the endpoints its code actually calls, both directions.
Projected from the catalogue entry system-models / User-journey model (product-goal → implementation bridge).
On this page: Intent · Motivation · Applicability · Structure · Sample Code · Consequences · Example use within DocAble · Related Patterns
Intent
Intent — Model the product's user journeys as first-class typed entities: each an actor pursuing a goal through ordered steps, every boundary-crossing step joined to the endpoint it calls. The path from what the product is for to what the code must provide becomes a queryable model. Graft it into the service-architecture dialect you already lint and query rather than standing up a parallel model (our instance: a UML-use-case-flavored Journey entity kind added to the existing Backstage service-flow dialect).
Motivation
The path from a product goal (a user opens a document in the editor, applies fixes, re-checks conformance) to the code that serves it lives in people's heads. Nothing ties a journey to the endpoints it hits, the tests that cover it, or the services it needs. So journeys go untested without anyone noticing; endpoints accrete that no journey needs and no one dares delete; a component's declared dependencies quietly fall out of date with the calls its code actually makes; and the backend is sized for a blurred average of all traffic rather than the journey underway. Each is a quiet failure: a coverage hole, dead surface, a stale dependency map, mis-sized capacity. None is visible from the code alone, because the journey is the one thing the code never names.
Applicability
- The semantic fields are human-authored. Actor, goal, ordered steps carry intent a grep cannot derive; only the deps and call-site anchors are reconciled against code. So the model is a linted document, not codegen.
- Endpoints already addressable as stable entities the journey can reference. The join needs no new endpoint registry invented if one already exists.
- The reconciliation machinery pointed at the model. A journey model is only trustworthy because a mechanism keeps it fresh; without the drift lint it rots exactly as an unmaintained dependency list does.
Structure
The Structure diagram appears at the top of this page.
Sample Code
A journey's dependency list is derived from its real call sites, so a drift lint checks it both ways: every declared dep has a real call, and every call is declared. This makes the model a checked cache of the call-site truth, not a hand-authoritative document that rots.
import sys
def call_site_drift(declared: set[str], actual_calls: set[str]) -> list[str]:
"""Both directions: no stale declaration, no silent under-declaration."""
findings = [f"declared dep '{d}' has no call site (stale)" for d in sorted(declared - actual_calls)]
findings += [f"call to '{c}' not in declared deps (under-declared)" for c in sorted(actual_calls - declared)]
return findings
def dead_endpoint_audit(all_endpoints: set[str], reached: set[str]) -> list[str]:
"""Every exposed endpoint must be reached by some journey; the rest is dead surface."""
return [f"endpoint '{e}' reached by no journey" for e in sorted(all_endpoints - reached)]
if __name__ == "__main__":
# `declared_deps` reads the journey's model; `scan_calls` reads its code's service-client calls.
findings = call_site_drift(declared_deps(), scan_calls())
for f in findings:
print(f"JOURNEY-DRIFT: {f}")
sys.exit(1 if findings else 0)
Consequences
- A new journey, or a new service call in a journey's code, ⇒ a model edit, or the drift lint fails at that PR. Deliberately; that is the freshness gate working.
- The mechanisms land audit-only first, and promote to blocking once the drift they surface is drained. A blocking model-reconciliation lint dropped in red would break every in-flight change and flood a no-baseline deploy gate; so the honest landing is audit-only → fix-wave → promote.
- Journey granularity is a modeling choice. Drawn too coarse the audits are toothless, too fine the model is noise. The model makes the choice explicit rather than leaving it implicit, which is the point and the cost.
Example use within DocAble
- The typed journey entities (actor, goal, ordered steps, each step's endpoint calls, and the call-site anchors) carried as a new kind in the existing service-architecture dialect.
- The call-site drift lint (declared deps ↔ real call sites, both directions) that keeps the map fresh; the two-way endpoint-coverage audits (undertested-journey + dead-endpoint).
- A journey-aware wake/scale capability keyed to the active journey's declared deps, gated on the drift lint being blocking.
- A migration completeness-critic sweep: when re-platforming, walk every journey and ask at each touchpoint "does the substrate change reach here?" A component-deep migration plan is blind to everything a user touches that is not the core pipeline; the journey model is the entry point that surfaces the missed threads (billing, invoicing, admin views) before the build wave starts.
Related Patterns
- Bridge — agents query it to reason about which paths are real and what each needs (agent side) ◀──▶ it governs the codebase, driving coverage and endpoint-retirement, and, once the drift lint is blocking, live journey-aware scaling (product side). One model, both faces.
- Enabler — service-flow-model: the structural service model whose dialect, loader, query surface, and lint this journey model reuses as a new goal-anchored
kind, not a parallel model. What separates the two? One traverses a product goal, the other maps service topology; that named axis is the only thing that differs, and the shared substrate is the uniformity win. - See also — drift-parity-gates: the call-site drift lint and the two coverage audits are that parity mechanism applied to this model.
- See also — executable-source-of-truth: the pattern this instantiates, data-not-code, read every run, held equal to reality.