API Design, Least Surprise & Deprecation
Designing an interface that is hard to misuse, why every observable behaviour becomes part of the contract whether you documented it or not, what semantic versioning can and cannot promise, and how to remove something without breaking the people using it.
Bloch's framing from OOPSLA 2006 is still the best starting point: an API is a permanent commitment, so you can add but you can never remove, and the design goal is not "easy to use" but easy to use correctly and hard to use incorrectly. Everything below follows from taking those two sentences literally, including the parts that are uncomfortable — most notably that the contract is not what you wrote down.
The contract is everything observable #
This is not cynicism, it is an operational fact with consequences you can act on. Things that become part of your contract without being in it: iteration order of an "unordered" map, the exact text of an error message, timing, the number of queries issued, whether a returned list is mutable, thread-safety you never claimed, and the precise null-versus-empty distinction.
The actionable responses:
- Reduce what is observable. Return an unmodifiable collection so nobody can depend on mutating it. Do not expose internal ids or hashes that will change.
- Randomize what you refuse to guarantee. Go randomizes map iteration order deliberately, so no program can accidentally come to depend on it. HashSet ordering changing between JDK releases broke real code precisely because it looked stable.
- Accept the ones that got away. Once ten thousand callers depend on an accident, it is cheaper to promote it to a documented guarantee than to break them.
Making misuse hard #
The mechanisms, roughly in order of strength:
// weak: documented, unchecked
void transfer(long from, long to, long amount) // "amount must be positive"
// strong: unrepresentable
void transfer(AccountId from, AccountId to, PositiveAmount amount)- Types over documentation. A parameter that cannot be wrong beats a comment saying it must not be (value objects, parse don't validate).
- No boolean parameters that select behaviour.
save(order, true)is unreadable at the call site and control-couples the caller to the implementation. Two named methods, or an enum. - Do not overload on subtype-related parameters — resolution happens on static types and the surprise is total (dispatch).
- Make the common case the default and the dangerous case explicit. Defaults are the highest-leverage decision in an API, because most users never change them: a default timeout of "infinite", a default retry policy of "none", TLS verification defaulting to off — each of those is an outage generator that no amount of documentation fixes.
- Fail at construction, not at first use. An object built with bad configuration should not exist.
Naming, briefly #
Consistency beats cleverness, and a name that requires the documentation has failed. Two rules with disproportionate payoff: use one word per concept across the whole surface (never fetch/get/retrieve for the same operation), and make a name that reads at the call site, not in the class body — order.total() over order.getCalculatedTotalAmount().
Versioning, and what semver actually promises #
Semantic versioning says MAJOR for incompatible changes, MINOR for backward-compatible additions, PATCH for backward-compatible fixes. It is a useful communication protocol and it inherits Hyrum's law entirely: "incompatible" is defined by observable behaviour, so a bug fix is a breaking change for anyone who depended on the bug. Semver tells you what the author intended, not what will break.
Language-level surprises worth knowing, because they are not intuitive:
- Adding a method to an interface breaks every implementer — unless it has a default implementation (interfaces). This is the reason to think hard before publishing an interface for others to implement, and the reason
sealedexists. - Adding an overload can break source compatibility even when it is binary compatible, by making an existing call ambiguous.
- Widening a return type breaks callers that assigned it to the narrower type; narrowing it is safe. Parameters are the reverse.
- Adding an enum constant breaks any consumer whose
switchwas exhaustive without a default. - Go's import compatibility rule takes the strictest position: a v2 module has a different import path (
example.com/m/v2), so incompatible versions can coexist in one build rather than forcing a diamond conflict.
Removing something #
The lifecycle that works, and the part everyone skips:
1. ANNOUNCE mark deprecated, in the same release as a working replacement.
Say what to use instead, in the deprecation message itself.
2. WARN compiler warning, runtime log on first use, lint rule.
Measure actual usage — telemetry, code search, registry dependents.
3. WAIT long enough for the slowest consumer: their release cycle, not yours.
4. REMOVE in a major version, with the migration note in the changelog.
A deprecation without a named replacement is not a deprecation; it is a complaint. And step 3 is the one that is skipped — the correct wait is set by the slowest consumer that matters (a mobile app in the field, a partner integration, an internal service nobody owns), and it is usually much longer than a sprint.
Errors are part of the API #
The failure surface is as public as the success surface, and it is the half that gets designed by accident. Callers will branch on error types and — this is Hyrum's law again — on error messages, because a stable machine-readable code was not provided.
Give errors a stable, documented code separate from the human-readable message; distinguish transient from permanent so callers know whether to retry (error handling); and never leak internal detail — a stack trace or SQL fragment in an error response is both an information disclosure and a contract you did not intend to sign.