Craft of code
Interfaces that age well
Design the interface for the caller's job. Take the smallest inputs, make illegal states hard, and keep one obvious way to do each thing.
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 wholeUsergraph) 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.
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_42The 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
Usergraph when you needed an id. - Three near-synonym methods. Callers pick wrong.
- Boolean soup: the fourth
truesilently 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.
Related
Writing code others can maintain
Managing complexity with deep modules, clear naming, honest comments, consistent style, and error strategies that shrink the blast radius of change.
Deep modules
A deep module hides a lot of complexity behind a small interface. Shallow pass-through layers add names without adding leverage.
Error handling and illegal states
Fail fast, make errors explicit, and redesign so some failures cannot happen. Swallowing errors turns production into archaeology.