Code
This is the binding text, shown here straight from the org repository on GitHub, so this page and the repository can never differ. See the file and its history.
Design a system as a graph of narrowing scopes: a broad capability leads through scopes that each expose less, until only precise single-word actions remain, and every effect routes through one conduit. The path supplies the meaning, so names stay minimal, and the system teaches itself: you discover it by following chains, not by scanning docs. It is domain-agnostic; a library is the most detailed case, but these are all the same shape:
| Abstract | Library | CLI | REST | Config | UI Menu |
|---|---|---|---|---|---|
| Capability | root action | program | base URL | root namespace | top menu |
| Scope | category / item | subcommand group | resource segment | section | submenu |
| Action | single-word method | leaf command | method on path | leaf key | menu item |
| Conduit | call function | shared exec | request handler | loader / parser | dispatch |
| Context | context object | flags / env | headers / auth | environment | session state |
Three laws
- Context is structural, not lexical. The path gives meaning, so each step names only what it adds; descriptive names (
getTradesList) are a smell, because the prefix duplicates context the path already carries. This scales up:PaymentsContextbesideMessagingContextin one namespace is the same smell at module level; make it structural (a package) so the name collapses back toContext. - Every scope is complete. A scope that leads to a single child isn’t a scope. Either complete it (add the reader/writer/remover that genuinely belong) or collapse it (expose the lone action one level up). A scope kept permanently around one action charges a step of ceremony for zero narrowing.
- One context per boundary. A graph carries exactly one context, threaded from the root. A second, independent context (different identity, credentials, or lifecycle) is a new system: split it into its own package/module, where it again carries one
Context. A mere narrowing of the same identity (a tenant, a sub-account) is not a new context but a scope within the graph (session.tenant(id)…). Compose by containment, never by merging contexts. (This is a DDD bounded context: one context, one ubiquitous language.)
Naming
- Roots are verb-then-noun; the verb is a lifecycle contract.
create/defineestablish a capability with nothing to release (default).acquire/openhand back a resource to release, so use them only when bound to a scoped-disposal construct (using,with,defer). The noun out-weighs the verb and is a real noun, not a nominalized verb (createSession, notstartRun). - Categories are plural/collective nouns (
accounts,trades); a natural singleton takes a singular noun (profile,settings) and is entered directly. - Factories are single words mirroring their category. Actions are single words (
balance,deposit,status,list); the path supplies the rest. The single-word rule governs identifiers; a human label (a menu item) may be a short phrase (Clear history). - Selecting an item is
get(id), which narrows to an item scope, as opposed tolist(), which reads the collection. Promote an identifier to an item scope only when that item has its own actions or sub-scopes; if all you ever do with it is one keyed operation, keep the identifier as an argument on the collection action (objects().download(key)), because a one-action item scope would violate Law 2. - Test: speak the chain aloud. “session, trades, get one, status” reads as instruction; “session, get trades list” does not.
The shape (TypeScript)
The root validates options at the boundary into one frozen, immutable context, then returns the top scope. Scopes are frozen objects of closures over that context; every action delegates to one conduit; require fails fast and names the field. The root is a plain factory, not a constructor, so it may be async or fallible, and never reads globals. The conduit owns transport end to end: a non-JSON or binary body or response is a flag on the operation, never a second I/O path.
type SessionOptions = { userId: string; credentials: Credentials; ttl?: number };
type Context = Readonly<{ userId: string; ttl: number; credentials: Credentials }>;
function createSession(o: SessionOptions) {
const ctx: Context = Object.freeze({
userId: require(o.userId, "userId"),
ttl: o.ttl ?? 300,
credentials: require(o.credentials, "credentials"), // explicit, never ambient
});
return Object.freeze({ accounts: () => accounts(ctx), trades: () => trades(ctx) });
}
function accounts(ctx: Context) { // a scope: frozen object of closures
return Object.freeze({
get: (id: string) => account(ctx, id),
list: () => call(ctx, { path: "accounts" }),
});
}
function account(ctx: Context, id: string) {
return Object.freeze({ balance: () => call(ctx, { path: `accounts/${id}/balance` }) });
}
function call<T>(ctx: Context, op: Operation): Promise<T> { // the one conduit
require(op.path, "op.path");
const req = build(op);
req.headers["X-User"] = ctx.userId; // credentials from context, never global
return send(req).then(wrapErrors);
}
function require<T>(v: T, name: string): T { // fail fast, name the field
if (v == null || v === "") throw new Error(`${name} is required`);
return v;
}
Chain: await createSession({ userId: "u1", credentials }).trades().get(1).status(). Parallelism needs no helper, Promise.all([a.balance(), b.balance()]), because each action is independent and bound only to its context.
Across languages
The shape is fixed; the mechanism is idiomatic. Closures (above) suit TS/JS, with no classes or this. Immutable value + methods suit Go, Rust, C#, and Python (frozen dataclasses): a scope is an immutable struct whose methods return narrower structs.
func NewSession(o Options) (*Session, error) { /* validate, build ctx; unexported fields = immutable */ }
func (s *Session) Accounts() Accounts { return Accounts{s.ctx} }
func (a Accounts) Get(id string) Account { return Account{a.ctx, id} }
func (a Account) Balance() (Money, error) { return call[Money](a.ctx, op{Path: "accounts/" + a.id + "/balance"}) }
Only two things adapt: where the noun lives (createSession, or Session.Create() / Session::create() / trading.NewSession() where concatenation goes heavy) and how construction reports failure (a factory that may be async or return Result/error, never a throwing new).
Applicability
Use when the domain has natural hierarchy, you expect additive growth, you want a uniform surface, and effects should centralize through one conduit. Avoid or adapt when the domain is flat, narrowing is multi-dimensional (you want a query, not a path), steps are order-dependent (wizards, transactions), or intermediate scope objects are too costly in a hot path.