| name | api-design |
| description | Design public interfaces for libraries and tools that other people depend on. Use when designing or reviewing a public API surface, deciding what to export, naming functions and CLI flags, adding configuration options, planning deprecations, or when the user asks "is this a good API" or "how do I change this without breaking users". Covers minimal surface area, error design, async and extension patterns, CLI ergonomics, config precedence, and stability guarantees. Also use before a 1.0 release to freeze the interface. |
API Design
The difference between an internal API and a public one is that you can fix an
internal one on a Tuesday. Every public symbol is a promise you will be asked to keep
for years.
The core discipline: export less
Everything exported is a maintenance obligation. Everything private is free.
- Start with the smallest surface that solves the problem. Adding an export later
is a minor release; removing one is a major release plus a migration guide.
- Explicit export lists.
export { a, b } from './x' — never export *, which
silently promotes every future internal symbol into your public API.
- Mark internals unmistakably.
_private, internal/ directories, #[doc(hidden)],
@internal. Users will still reach in; the marking is what makes it their problem
when it breaks.
- Audit before 1.0. List every exported symbol and justify each one out loud. In
most codebases this deletes 30–50% of the surface, and none of the deletions are
ever missed.
npx api-extractor run --local
python -c "import pkg; print([s for s in dir(pkg) if not s.startswith('_')])"
cargo public-api
go list -f '{{.Name}}: {{.GoFiles}}' ./...
Wire a public-API snapshot into CI. A diff in that snapshot forces a conscious
decision at review time instead of an accidental breaking change at release time.
Function signatures
Arguments. More than three positional parameters, or any boolean parameter, means
switch to an options object / keyword arguments. connect(host, 5432, true, false) is
unreadable at the call site and impossible to extend without breaking.
connect('db.local', 5432, true, false, 30)
connect({ host: 'db.local', port: 5432, tls: true, timeoutMs: 30_000 })
Defaults. Every option gets a sensible default. The zero-config path must work for
the common case; configuration is for the exceptions. If your quickstart needs six
options set, the defaults are wrong.
Return types. Consistent shapes. A function that returns T | undefined | T[]
depending on input forces every caller to write a type guard. Pick one.
Naming. Boring and predictable beats clever:
- Match ecosystem convention (
camelCase in JS, snake_case in Python/Rust, MixedCaps in Go)
- Verb-first for actions (
parseConfig), noun for accessors (config)
- Say what it returns, not how it works:
getUserById not queryUserTable
- Same concept, same word, everywhere. Not
remove here and delete there.
- No abbreviations except universal ones (
id, url, db). cfgMgr costs every
reader a lookup, forever.
Errors are API
Error behavior is part of your contract, and it is the part users hit under stress.
- Typed, distinguishable errors. Users must branch on failure mode without regex
on message strings — because if you leave them no choice, they will regex on
message strings, and then your messages are frozen too.
- Actionable messages. State what failed, what was expected, and what to do:
Config error at config.yml:12 — 'timeout' must be a number, got "30s". Use 30000.
- Never swallow errors. No empty
catch. No returning null for "something broke".
- Distinguish programmer errors from operational errors. A bad argument is a bug
to be thrown loudly; a network timeout is a condition to be returned and handled.
- Include a cause chain.
throw new Error('msg', { cause }), raise ... from e,
fmt.Errorf("...: %w", err). Stripping the cause destroys debuggability.
Extension points
Users will need behavior you did not anticipate. Decide deliberately how they get it,
because if you provide no seam they will monkey-patch your internals and you will
break them accidentally.
| Mechanism | Good for | Cost |
|---|
| Callbacks / hooks | Single well-known extension point | Ordering and error semantics get subtle |
| Plugin interface | Many independent extensions | Versioning the plugin contract |
| Middleware chain | Composable request/response pipelines | Debuggability suffers |
| Events | Observation, logging, metrics | Not suitable for control flow |
| Subclassing | Rarely the right answer | Every internal method becomes public API |
Prefer composition over inheritance in public APIs. Inheritance makes every protected
member a compatibility obligation.
Async and concurrency
- Do not mix paradigms. Callback and promise variants of the same function double
the surface and the bug surface. Pick one; if you must support both, generate one
from the other.
- Make cancellation possible from day one —
AbortSignal, context.Context,
CancellationToken. Retrofitting cancellation is a breaking change.
- Never block the main thread in a library. Users cannot work around it.
- Do not create global state or start background work at import time. A library
that opens a connection pool on
import is unusable in a serverless function and
untestable everywhere.
CLI design
A CLI is an API where the users are humans and shell scripts, simultaneously.
<tool> <noun> <verb> for multi-command tools (git remote add, docker image ls).
Consistent, discoverable, extensible.
- Long flags always; short flags for the top five only.
--verbose with -v.
--help at every level, with examples. Most users never open the docs site.
- Respect
--json. Machine-readable output turns your tool into a building block.
Also: detect whether stdout is a TTY and disable colors/spinners when piped.
- Exit codes matter.
0 success, 1 general failure, 2 usage error. Scripts
depend on these.
- stdout is data, stderr is chatter. Progress bars and logs on stderr, always.
- Confirm destructive actions, with
--yes/--force to skip in automation.
- Never require interactivity in a non-TTY. A prompt in CI is a hang.
Config precedence, highest to lowest — deviate from this and users will be confused
forever: CLI flag → environment variable → project config file → user config file →
built-in default. Document it, and provide a way to show the resolved config
(tool config show --explain).
Stability guarantees
State the contract explicitly in the README or docs. Users cannot infer it.
## Stability
- Everything exported from the package root is public API under semver.
- Anything under `internal/` or prefixed `_` may change in any release.
- `experimental_*` exports may change in minor releases.
- Node 20+ and Python 3.10+ supported; dropping a runtime is a major release.
Decide and document whether these count as breaking: adding a required config field
(yes), changing an error message (no) vs an error type (yes), tightening input
validation (usually yes in practice), changing output ordering (yes if unsorted output
was documented as stable), and raising the minimum runtime version (yes).
Deprecation
Never delete without a deprecation cycle. The full sequence:
- Mark it —
@deprecated / #[deprecated] / warnings.warn(DeprecationWarning),
with the replacement named in the message.
- Warn at runtime, once per process, not per call. A warning in a hot loop gets
silenced globally and then nobody sees any warnings.
- Document the removal version in the message and the changelog.
- Provide the migration path — ideally a codemod (
jscodeshift, libcst, comby).
- Wait at least one minor release, ideally two, and honor the stated timeline.
- Remove in a major release, listed in the migration guide.
export function makeClient(opts: Options): Client {
warnOnce('makeClient() is deprecated; use createClient(). Removed in v4.0.0.');
return createClient(opts);
}
Reviewing an API proposal
Questions worth asking on every new public symbol:
- What is the smallest version of this that solves the reported problem?
- Can a user already do this by composing existing pieces? (If yes, document that.)
- What does the call site look like? Write it before writing the implementation.
- How does this fail, and what does the user see?
- What is the migration path when we get this wrong?
- Does it match the naming and shape of everything nearby?
- Does it force a dependency on users who will never call it?
The best answer to a feature request is often a documentation page. The second best is
an extension point. A new public function is third.
Anti-patterns
export * from the package root.
- Boolean parameters.
render(true) — nobody knows what that does.
- Config objects with 40 options. Symptom of unmade decisions; split the API.
- Stringly-typed inputs.
mode: 'fast' | 'safe' beats mode: string.
- Leaking dependency types in your public signatures. Now their major version is
your major version.
- Different names for the same concept across the surface.
- Silent behavior change in a patch release. This is the trust-destroying one.
- Designing for hypothetical users. Build for the requests you actually have.