| name | taste |
| description | Domain-first store/port design for any codebase: product-shaped APIs, swappable backends, schema at boundaries, one vocabulary catalog (derive don’t re-author), no infrastructure names on the domain surface. Use when adding or refactoring persistence, cache, policy resolve, registries, or anything that might leak Redis/SQL/HTTP/SDK names into shared contracts. Taste: one fact one place, HOF as shared ritual not twin wrappers, call-site clarity, dumb engines, challenge early / cut ruthlessly, DX (cascading IntelliSense) as correctness, policy resolve → push-down query plan (not load-all-then-filter), no hardcodes when dims/rows already name the filter. |
Domain-first stores
Portable agent law for persistence, cache, policy documents, and similar ports.
If the repo has its own AGENTS.md overlay, read that after this file and
prefer project overrides for paths and house libraries.
When this applies
Designing or changing shared domain surfaces: stores, registries, policy
resolve, CAS/publish, desired-set membership, or multi-service clients that
must not fork wire APIs.
Taste (abstract)
-
One fact, one place. Vocabulary and policy live in small catalogs
(dimensions, fields, families). Everything else is derived. If adding
one concept means editing three sibling types (FooConfig + FooContext +
FooDims), delete the twins.
-
Indexed ideas over parallel nouns. Prefer catalog row + allowed dims →
context/result over re-authored per-family Structs. Types should cascade
from the catalog.
-
HOF means shared ritual, not twin wrappers. Three helpers that only
inject the same default are copy. Lift once when compose actually repeats;
otherwise inline. Prefer decodeFamily(family, …) over decodeFoo /
decodeBar / decodeBaz.
-
Call sites stay obvious. Pass raw fields; schema owns null/empty. Don’t
hide a one-liner behind a named helper. One context type: scalar or
non-empty array per dim; match with equality or ∈. Closed dims declare
enums on the registry; open dims stay free strings.
-
Engine stays dumb; boundaries stay sharp. Cascade/match with a simple
priority rule — no expand/desugar pass. Allowlists and IntelliSense live at
catalog / publish / family resolve — not a second “pass a Policy bag” API.
Resolve on use (ports); never freeze knobs at create* / make* boot.
-
Challenge early, cut ruthlessly. Prefer deleting a layer to polishing a
wrong one. Redundant Structs and family-specific context exports are smell,
not thoroughness.
-
DX is part of correctness. If IntelliSense doesn’t follow a new dim or
field, the type model failed — even when runtime works. Adding a dim =
registry row; adding a field = one catalog row (+ family key list if needed).
Taste (call sites & policy)
-
Call site stays clean. Decode at the family/resolve boundary — not
scattered ?? / === "" ? null / dual APIs for the same policy.
-
Invariants at the boundary. Decode once; the rest of the system only
sees a clean shape. Schema bounds are the ceiling — don’t re-clamp the same
knobs via env at boot.
-
Resolve from real context. Hot path uses identity dims that exist
(fooId / bar / region / …) against defaults + overrides — not a bare
env-only context when richer identity is available, and not a parallel
Policy injection. Example:
resolve.foo({ env: "published", fooId, bar }).
-
Pass the whole port, not plucked fields. Wire resolve.foo (or the
whole resolve bag) into consumers — don’t extract fooLimit at main /
server and pass a number.
-
One concern, one return. Boot/open helpers return the store (or one
domain value) — not { store, resolveEverything }. Callers compose resolve
ports at the edge.
-
Share the ritual, don’t twin it. One module per compose; no
service-local copies of the same decode/match/apply.
-
Defaults everywhere, scoped when stated. Defaults are the base case;
dimensions/overrides only matter when specified.
-
Ugly in adapters/scripts, not the server. Seeds and fixtures are
scripts; empty required config → fail loud.
-
Explain by flow, not jargon. Show a concrete resolve example
(defaults + matching override → result).
-
Live day one, minimal now. Production-minded (version/revert, HA) but
ruthlessly cut scope. Ship the flexible core; defer authoring UI / extra
domains until needed.
-
House schema library owns Encoded/Type. Prefer derived
FamilyContextEncoded<"foo"> over hand-rolled { fooId: string; … } bags.
-
Policy resolve drives the query — don’t filter the world in JS.
Derive a conservative DB/search plan from the same resolve port
(probe closed dims like bar from the registry), push
WHERE / / (or equivalent), then re-resolve with full row
context (, …) only on the small result for open-dim overrides.
Example: → SQL filters + → JS finish for
per- keep/drop.
Hard rules
-
Domain API first. Public names describe product concepts
(getFields, writeAll, publish, retain), never wire commands
(hgetall, INSERT, GetItem) or vendor products in types, factories,
errors, or domain comments.
-
Backends are swappable. Runtime adapters (cache, SQL, HTTP, SDK) vs
in-process backends for unit tests. Do not bake wire commands into the Tag /
port interface.
-
Service shape. Prefer an explicit service interface + tagged errors.
Factories return the service / Layer — not createVendorX(client).
-
Wire only at the edge. Adapters map infrastructure → domain. Scripts,
pipelines, and SDK calls stay inside adapters — not on public contract
exports’ names.
-
One shared module per concern. Do not fork the same store into two
services. Put it in a shared package (packages/domain, packages/contracts, …).
-
No duplicate client interfaces. If the domain service already defines
ops, don’t redefine the same Promise surface under another name in a
service package.
-
Side channels stay adapters. Pub/sub channel strings, queue names, and
publish transport calls are adapter details. Domain may expose
publish → boolean and an optional sink — not transport identifiers on the
Tag.
-
Comments stay product-shaped. Don’t document “Redis-backed” /
“Dynamo-backed” on domain constructors. Say what the backend does
(in-process cache, scripted CAS).