Guide

The interface is the part you cannot change cheaply once callers exist. Design it for the caller's job, not for your current file layout.

Good interface habits:

  • Accept less, return more (carefully). Take the smallest inputs you need (userId, not a whole User graph) when that avoids coupling callers to your storage model. Return types rich enough that callers do not immediately re-fetch.
  • Make illegal states hard. Prefer enums and branded types over stringly-typed status fields everyone misspells. See error handling.
  • Version at boundaries that escape the process. Internal helpers can rename freely; public HTTP/events need expand/contract (see refactoring and legacy).
  • One obvious way. If there are three methods that almost do the same thing (get, fetch, load), callers will pick wrong and bugs will look random.

When you feel tempted to add a boolean flag that changes the meaning of three other parameters (includeDeleted, asAdmin, skipCache), stop — that is usually a second function or a small options object with explicit validation, not another true in position four.

Walking through a notifications API

A notifications module exposed send(user, template, data, channel?, priority?, dedupeKey?, dryRun?). Every caller passed a different subset; defaults disagreed. After: send(NotificationRequest) with required template + recipient, optional structured options. One type to read, one place to validate, fewer unknown unknowns when someone omits dedupeKey and double-emails.

TypeScript
type NotificationRequest = {
  template: string
  recipient: string
  data: Record<string, string>
  options?: { channel?: "email" | "push"; dedupeKey?: string; dryRun?: boolean }
}

function send(req: NotificationRequest): string {
  if (!req.template || !req.recipient) throw new RangeError("template and recipient are required")
  const key = req.options?.dedupeKey ?? `${req.template}:${req.recipient}`
  return key
}

console.log(send({ template: "refund", recipient: "u_42", data: { cents: "499" } }))

Output:

refund:u_42

The default dedupeKey is now visible in one place. Callers no longer invent a different subset of positional flags.

SDE2-level craft means your PRs reduce the team's future load, not just close the ticket. Reviewers should be able to understand your change without a meeting. When you leave a mess "for later," later is usually another team's on-call.

Common mistakes

  • Taking a whole User graph when you needed an id.
  • Three near-synonym methods. Callers pick wrong.
  • Boolean soup: the fourth true silently changes three other arguments.

Check yourself

Your function takes includeDeleted, asAdmin, and skipCache as three booleans. What instead?

A second function, or a small options object with validation — not another true in position four.

get, fetch, and load all retrieve a user. What goes wrong?

Callers pick at random. Bugs look unrelated. Keep one obvious way.

Key takeaways

  • Design the interface for the caller, not your file tree.
  • Small inputs, rich enough returns, illegal states hard to represent.
  • Version at process boundaries; rename freely inside.
  • One obvious way. Options objects beat boolean soup.

Further learning

Back to the map: writing code others can maintain.