[Deep Dive #7] API Surface Discipline - discussion
How strict are you about API surface discipline in day-to-day work (route docs, props, vault entries)?
My read: treat APIs the way you treat routes-stable entry points, explicit surfaces, and render-visible proof. The discipline part is mostly refusing to let hidden coupling creep in without an obvious contract story.
What rule do you use for introducing new surface keys (route docs, component props, vault entries)? Do you keep a surface catalog anywhere, or do types + docs carry the source of truth? When you deprecate a key, how do you avoid breaking older route flows? And when do you accept duplicate reads (the same surface key in multiple places) vs centralize via a vault?
Comments (14)
Back to latestWhat do you mean by surface key?
Is it just state keys? Props? Both?
Both. It's any named value the UI depends on as a contract: route doc keys, prop shapes, vault entries.
The goal is to keep them explicit and render evidence so drift is obvious.
Ok, that helps. I was thinking this was only about HTTP/API stuff.
In a larger org, the discipline is mostly about stability.
We don't mind adding keys; we mind keys that change meaning without a version bump or a doc update that explains the new meaning.
Surface discipline also reduces "accidental expensive compute" because derived surfaces are named.
If derivedTotals is a key, you can choose to store it and render it once instead of deriving in multiple places.
I like surfacing contracts as types and keeping a small, stable contract summary render-visible.
Types prevent some drift; the UI-side contract story prevents the "type says it" but runtime does something else problem.
Exactly. Types are a compile-time story; render-visible audit is a runtime story.
You need both to keep the surface honest across routers and release cadence.
Catalogs rot.
Types + docs + a couple tests beat a giant spreadsheet for us.
We hit this in prod: we broke an older route flow by renaming a "derived" key that a downstream panel treated as stable.
Once we adopted the discipline of "render a tiny contract summary near the key owner" we caught it in QA immediately.
This is where the docs posture helps: separate source vs derived vs evidence keys and name them explicitly.
Renaming evidence is a contract change, not a refactor.
Small snippet we use for deprecation:
tsx
doc.write('surfaceVersion', 'v3');
doc.write('deprecatedKeys', ['oldTotal', 'legacyEvidence']);Question: does anyone allow duplicate reads by default? The post frames idempotency as a stability mechanism, but we still feel guilty about duplication in prod.
We allow duplicates when it makes slot composition simpler.
The key is to keep things render-visible so you can prove whether you're actually paying twice.
Surface discipline is mostly social: if you change meaning, you also update the docs and the contract story.
Otherwise it turns into "why did this break" whack-a-mole.
We got stricter about this after getting burned by "just add one prop" becoming a long-term contract we couldn't change. A pattern that helped was treating new keys like they need an owner and a doc entry. It's not heavy weight, it's just explicit.
ts
type SurfaceKeyMeta = {
owner: string;
stability: 'source' | 'derived' | 'evidence';
since: string;
deprecates?: string[];
};
function defineSurfaceKey<T>(name: string, meta: SurfaceKeyMeta) {
return { name, meta } as const;
}
const filteredIds = defineSurfaceKey<string[]>('filteredIds', {
owner: 'OrdersRoute',
stability: 'derived',
since: 'v3',
deprecates: ['visibleIds'],
});
Then we render a tiny deprecation marker (or render-log line) when deprecated keys are still used, so you catch it in QA screenshots instead of a month later.
Counterpoint: I think people over-apply discipline to prototypes and end up with "surface committees".
We do a two-phase approach: allow experimental keys (but render them as experimentalKeys=[...]), then graduate only the ones that survived a release cycle.
The rule we use for introducing a new surface key is hilariously simple: you must be able to *render the key name* in the UI. If you can't render it, you probably didn't name it as a contract.
We keep a surfaceCatalog in the shell and append any new keys as they appear.
ts
type KeyMeta = { key: string; owner: string; stability: 'source' | 'derived' | 'evidence'; since: string };
function announceKey(shell: any, meta: KeyMeta) {
const prev = (shell.read('surfaceCatalog') as KeyMeta[]) ?? [];
shell.write('surfaceCatalog', [...prev, meta]);
}
announceKey(shell, { key: 'filteredIds', owner: 'OrdersRoute', stability: 'derived', since: 'v3' });
Yes, it duplicates docs/types, but the catalog being render-visible means it can't quietly drift.
We made deprecations painless by allowing keys to be "polyfilled" in the shell. If a panel still reads visibleIds, the shell synthesizes it from filteredIds and writes evidence that the deprecated path ran.
ts
function polyfillKeys(doc: any) {
const filteredIds = doc.read('filteredIds') as string[] | undefined;
const visibleIds = doc.read('visibleIds') as string[] | undefined;
if (!visibleIds && filteredIds) {
doc.write('visibleIds', filteredIds);
doc.write('deprecationEvidence', [...((doc.read('deprecationEvidence') as string[]) ?? []), 'polyfilled:visibleIds']);
}
}
This is basically letting the shell be a compatibility layer like a router. It keeps old flows alive while still nudging teams toward the new key (because the evidence line shows up everywhere).
The most 'discipline' we've ever gotten is by generating surface keys automatically from docs text. We literally parse the docs page for backticked key names and treat those as the allowed catalog.
ts
function allowedKeysFromDocs(markdown: string) {
const keys = (markdown.match(/`([a-zA-Z0-9_]+)`/g) ?? []).map((s) => s.replaceAll('`', ''));
return Array.from(new Set(keys));
}
function guardWrites(doc: any, allowed: string[], key: string) {
if (!allowed.includes(key)) {
doc.write('surfaceViolations', [...((doc.read('surfaceViolations') as string[]) ?? []), `wrote:${key}`]);
}
}
It is delightfully circular: docs define the keys, the shell enforces the keys, and the UI renders violations as evidence.
OP follow-up: the 'surface catalog' that didn't rot for us ended up living in localStorage. It's not a build artifact, it's a runtime artifact. That means older tabs keep their old catalog (cadence-safe), and newer tabs adopt the new one.
tsx
function useSurfaceCatalog() {
const shell = useGlobalRoute('app', { surfaceCatalog: [] as string[] });
useEffect(() => {
const raw = localStorage.getItem('surfaceCatalog') ?? '[]';
shell.write('surfaceCatalog', JSON.parse(raw));
}, []);
useEffect(() => {
localStorage.setItem('surfaceCatalog', JSON.stringify(shell.read('surfaceCatalog')));
});
return shell.read('surfaceCatalog');
}
If a key shows up that isn't in the catalog, we still allow it, but we render surface=unknown as an evidence line until the docs catch up.