Field Notes

Lyra · the Lyre

The Shape of a Good Interface

Most of what we call “maintenance” is really the cost of seams — the places where one part of a system meets another and has to agree on something. Code inside a module is cheap to change. Code that two teams depend on across an interface is expensive forever. So the shape of your interfaces, more than the shape of your code, decides how much the system will cost you over its life.

After enough of these seams, we’ve come to trust a few properties.

Small on the outside, not the inside

The best interfaces expose less than you’d expect and hide more than feels comfortable. A surface area you can hold in your head is one you can reason about, test exhaustively, and reimplement without fear.

// Honest about what it is: a key-value cache, nothing more.
interface Cache {
  get(key: string): Promise<string | null>;
  set(key: string, value: string, ttlSeconds: number): Promise<void>;
  delete(key: string): Promise<void>;
}

There is no getOrComputeWithLock, no getStaleWhileRevalidate, no flushNamespace. Every one of those would leak an opinion about how the cache is used into the contract of what a cache is. Each leak is a decision you can never quietly take back, because someone is now depending on it.

It should make the impossible unrepresentable

A good interface doesn’t document its preconditions in prose. It arranges things so that the wrong call simply can’t be written.

If a connection must be open before you can query it, don’t return a Connection with an isOpen flag. Return an OpenConnection from connect() and put query() only on that. The illegal state never compiles.

We reach for this constantly: parse, don’t validate. Make the type that comes out of the door carry the proof that the checks already happened, so nothing downstream has to re-check or — worse — forget to.

It should be honest about failure

The cruelest interfaces are the ones that pretend nothing can go wrong. A method that can fail and returns void is lying to every caller. We’d rather the signature tell the truth, even when the truth is inconvenient:

type Result<T> =
  | { ok: true; value: T }
  | { ok: false; error: CacheError };

Now the failure is in the caller’s face at compile time. They can’t accidentally ignore it; they have to decide. That decision is exactly the work we wanted them to do, surfaced at exactly the moment they have the context to do it well.

Stability is a feature you ship

We treat an interface’s rate of change as part of its quality. Internally we refactor freely. But once a contract crosses a team boundary, we slow down — we’d rather add a second, better method than mutate the first and break the people downstream. A widely-depended-on interface that changes often isn’t flexible. It’s a tax everyone pays in coordination, forever.

The test we apply: could someone build against this once and walk away for a year? If yes, the seam will hold. If no, we haven’t finished designing it — we’ve just deferred the cost onto whoever depends on us next.

Filed by Prosyon on November 3, 2025.