Skip to content

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

  • primary is one identifier or one input Schema.Struct, never positional fields. options? is a named object of behaviour modifiers, every field optional. There is never a third parameter: the actor is CurrentPrincipal and the org is CurrentOrg, both in R.
  • Growth goes to one of two places. Data the operation is about becomes an optionalKey field on the input (priority on create). A behaviour modifier becomes a field on options? (includeArchived on get); adding options? where there was none is not breaking.
  • If primary itself 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 takes orgId, 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 return Option; the service lifts None into the error.
  • A service find returning Option exists only for a caller that treats absence as the normal path, such as a uniqueness check before create. Don’t add one for symmetry.
  • No X | undefined or X | null in a service shape. A vendor client that returns null is wrapped at its adapter. Accepted cost: an expected RuleNotFound ends 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 create
if (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

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 is find → Option.
  • No Result in the success channel. Domain failures stay in E, where catchTags and the compile break on a new error rely on them.
  • Accepted cost: a caller that only wants to know writes catchTag back 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>Many is 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. No RequestResolver or Effect.request in 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 Email sender, 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 Effect and returns a wrapped Effect. Only these may be dual. Ordinary methods (get, create) are never dual; they have no self.
  • Use the predicate form (args) => Effect.isEffect(args[0]), not arity dispatch, which misfires once an options? 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

  • A <verb>Many batch 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 RequestResolver behind 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.