Interface design
Six months after launch, create needs an optional priority and get needs to read archived
rules. This page answers what shape a service method takes so that growth breaks nobody,
how absence and “no” are reported, and when a service splits in two.
Take at most two parameters: (primary, options?)
Section titled “Take at most two parameters: (primary, options?)”Impact: HIGH new fields land without touching a caller
primaryis one identifier or one inputSchema.Struct, never positional fields.options?is a named object of behaviour modifiers, every field optional. There is never a third parameter: the actor isCurrentPrincipaland the org isCurrentOrg, both inR.- Growth goes to one of two places. Data the operation is about becomes an
optionalKeyfield on the input (priorityoncreate). A behaviour modifier becomes a field onoptions?(includeArchivedonget); addingoptions?where there was none is not breaking. - If
primaryitself must change (a different key, a second required input), add a new method. Retiring the old one is API evolution. A repository is the one layer that takesorgId, as its first argument.
❌ Incorrect — positional arguments; every new one touches every caller:
readonly disable: (slug: Slug, id: RuleId, version: number, identity: Identity) => …readonly readEvents: (from: number, limit?: number) => … // no room for a second optional✅ Correct — one primary, one named options object, context in R:
export type GetLabelingRuleOptions = { readonly includeArchived?: boolean }
export type LabelingRulesShape = { readonly get: (id: RuleId, options?: GetLabelingRuleOptions) => Effect.Effect<LabelingRule, RuleNotFound | PersistenceError, CurrentOrg> readonly create: (input: CreateLabelingRuleInput) // priority added as optionalKey => Effect.Effect<LabelingRule, RuleLabelTaken | PersistenceError, CurrentPrincipal | CurrentOrg>}Source: notes/04-architecture-and-api-design/interface-design.md · Decision 1, amended
get fails with XNotFound; find returns Option where absence is normal
Section titled “get fails with XNotFound; find returns Option where absence is normal”Impact: HIGH absence handled once, not in every handler
- A service lookup fails with a feature-specific, namespaced
XNotFound. Repositories returnOption; the service liftsNoneinto the error. - A service
findreturningOptionexists only for a caller that treats absence as the normal path, such as a uniqueness check beforecreate. Don’t add one for symmetry. - No
X | undefinedorX | nullin a service shape. A vendor client that returnsnullis wrapped at its adapter. Accepted cost: an expectedRuleNotFoundends its span as an Error.
❌ Incorrect — Option at the service, so every caller writes the same check:
readonly get: (id: RuleId) => Effect.Effect<Option.Option<LabelingRule>, PersistenceError>
const rule = yield* rules.get(id)if (Option.isNone(rule)) return yield* new RuleNotFound({ ruleId: id })✅ Correct — get fails; find is used where absence is the normal path:
readonly get: (id: RuleId) => Effect.Effect<LabelingRule, RuleNotFound | PersistenceError, CurrentOrg>readonly find: (label: Label) => Effect.Effect<Option.Option<LabelingRule>, PersistenceError, CurrentOrg>
// in createif (Option.isSome(yield* rules.find(input.label))) { return yield* new RuleLabelTaken({ label: input.label })}Source: notes/04-architecture-and-api-design/interface-design.md · Decision 2, amended
Say “no” with a typed error
Section titled “Say “no” with a typed error”Impact: MEDIUM one rule, no question-vs-command judgement
- The success channel carries what the method’s name promises. “No” fails with a namespaced
domain error, even for question-shaped methods (
validate*,check*,can*). The only exception isfind→Option. - No
Resultin the success channel. Domain failures stay inE, wherecatchTagsand the compile break on a new error rely on them. - Accepted cost: a caller that only wants to know writes
catchTagback into data, and a normal “no” shows as an Error span.
❌ Incorrect — the answer “no” returned as data:
readonly validateLabel: (name: LabelName) => Effect.Effect<{ exists: true; label: GitHubLabel } | { exists: false }, GitHubUpstreamError>✅ Correct — “no” is an error; a caller that only asks catches it:
readonly validateLabel: (name: LabelName) => Effect.Effect<GitHubLabel, LabelMissing | GitHubUpstreamError>
rules.validateLabel(name).pipe( Effect.map((label) => ({ exists: true as const, label })), Effect.catchTag("@app/labeling/LabelMissing", () => Effect.succeed({ exists: false as const })),)Source: notes/04-architecture-and-api-design/interface-design.md · Decision 3
Start single; add an all-or-nothing <verb>Many beside it
Section titled “Start single; add an all-or-nothing <verb>Many beside it”Impact: MEDIUM the only batch contract a caller can reason about
- Every method starts single-item. A
<verb>Manyis added beside it when an operation must be atomic across items, or one caller calls the single 10 or more times in one request. - A batch takes a
NonEmptyReadonlyArray, runs all-or-nothing in one transaction, and fails with one error listing every missing id. It never partially succeeds. A batch never spans orgs. - The single may call the batch with
[id]so the two cannot drift. NoRequestResolverorEffect.requestin service shapes.
❌ Incorrect — the caller loops over singles; item 37 fails after 36 are written:
yield* Effect.forEach(ids, (id) => rules.archive(id))✅ Correct — a documented, atomic batch beside the single:
readonly archive: (id: RuleId) => Effect.Effect<void, RuleNotFound | PersistenceError, CurrentPrincipal | CurrentOrg>
/** All-or-nothing in one transaction. Fails RulesNotFound listing every missing id. */readonly archiveMany: (ids: Array.NonEmptyReadonlyArray<RuleId>) => Effect.Effect<void, RulesNotFound | PersistenceError, CurrentPrincipal | CurrentOrg>Source: notes/04-architecture-and-api-design/interface-design.md · Decision 4, amended
Split a service by its dependencies and callers
Section titled “Split a service by its dependencies and callers”Impact: MEDIUM a caller of get never builds Email
- Each feature has one primary service. A group of methods becomes its own service, in the same
slice, when it brings its own dependencies (an
Emailsender, object storage) or its own set of callers. Method count alone is not a trigger. - The split-off service calls the primary one for existence and policy checks
(
ProjectMembers.invite→Projects.get). No nested groups inside a shape, and no Read / Write / ReadWrite split. - Split a method when its errors sprawl; split a service when its dependencies or callers do. Accepted cost: the trigger is a judgement call, and the services in a slice form a graph to keep acyclic.
❌ Incorrect — nested groups, so one tag carries every dependency:
export type ProjectsShape = { readonly get: (id: ProjectId) => … readonly members: { invite: …; remove: …; changeRole: … } // needs Email readonly export: (id: ProjectId) => … // needs ObjectStorage}✅ Correct — split where the dependencies and callers differ:
projects/ projects.ts get, create, update, archive, restore project-members.ts invite, remove, changeRole (+ Email) project-export.ts export (+ ObjectStorage)Source: notes/04-architecture-and-api-design/interface-design.md · Decision 5
Make only combinators dual, in predicate form
Section titled “Make only combinators dual, in predicate form”Impact: LOW combinators sit in a pipe like Effect.timeout
- A combinator takes an
Effectand returns a wrappedEffect. Only these may be dual. Ordinary methods (get,create) are never dual; they have noself. - Use the predicate form
(args) => Effect.isEffect(args[0]), not arity dispatch, which misfires once anoptions?is added. - Accepted cost: two overloads on the shape, and the test fake must support both call styles.
❌ Incorrect — arity dispatch, which breaks when an optional parameter appears:
const withLock = Function.dual(2, (self, key) => /* … */)✅ Correct — predicate form, used in a pipe:
const withLock: LocksShape["withLock"] = Function.dual( (args) => Effect.isEffect(args[0]), (self, key) => /* … */,)
doWork.pipe(locks.withLock(key), Effect.timeout("5 seconds"))Source: notes/04-architecture-and-api-design/interface-design.md · Decision 6
Document ownership, and only what the type can’t say
Section titled “Document ownership, and only what the type can’t say”Impact: MEDIUM callers go wrong on semantics, not on what get means
- Every shape type has one doc comment saying what the service owns and, explicitly, what it does not own.
- A member gets a comment only for semantics the signature cannot carry: idempotency, ordering, all-or-nothing vs partial, behaviour on empty or default input, a parameter some implementations ignore.
- No comment that restates the name.
❌ Incorrect — comments that restate names, nothing on ownership:
export type LabelingRulesShape = { /** Gets a rule by id. */ readonly get: (id: RuleId) => … /** Archives many rules. */ readonly archiveMany: (ids: Array.NonEmptyReadonlyArray<RuleId>) => …}✅ Correct — ownership on the shape, semantics on the member that needs it:
/** * Labeling rules for one repository. Owns rule CRUD, validation and archiving. * Does not own GitHub label sync (see GitHubLabels) or rule evaluation (see LabelingEngine). */export type LabelingRulesShape = { readonly get: (id: RuleId) => … /** All-or-nothing in one transaction. Fails RulesNotFound listing every missing id. */ readonly archiveMany: (ids: Array.NonEmptyReadonlyArray<RuleId>) => …}Source: notes/04-architecture-and-api-design/interface-design.md · Decision 7
Deferred
Section titled “Deferred”- A
<verb>Manybatch beside a single — trigger: an operation must be atomic across items, or one caller calls the single 10 or more times in one request. - A
RequestResolverbehind a method — trigger: the first caller that fans out concurrently within one fiber graph and needs it. - A split-off service for a group of methods — trigger: the group brings its own dependencies or its own set of callers.
- Read / Write services for one capability — trigger: its implementation is deployed with different credentials per access level.