Skip to main content

api-design

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.

Source facts

Repository
the-open-agent/oss-skills
Last source activity
August 8, 2026 at 08:15
Detected SKILL.md language
English
Stars
5
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
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. ```bash # TypeScript: see the real public surface npx api-extractor run --local # Python: what does `from pkg import *` actually give? python -c "import pkg; print([s for s in dir(pkg) if not s.startswith('_')])" # Rust cargo public-api # Go 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. ```ts // Bad — unextendable, unreadable at call site connect('db.local', 5432, true, false, 30) // Good — self-documenting, additive 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. ```markdown ## 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: 1. **Mark it** — `@deprecated` / `#[deprecated]` / `warnings.warn(DeprecationWarning)`, with the replacement named in the message. 2. **Warn at runtime**, once per process, not per call. A warning in a hot loop gets silenced globally and then nobody sees any warnings. 3. **Document the removal version** in the message and the changelog. 4. **Provide the migration path** — ideally a codemod (`jscodeshift`, `libcst`, `comby`). 5. **Wait at least one minor release**, ideally two, and honor the stated timeline. 6. **Remove in a major release**, listed in the migration guide. ```ts /** @deprecated Use `createClient()` instead. Removed in v4.0.0. */ 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: 1. What is the smallest version of this that solves the reported problem? 2. Can a user already do this by composing existing pieces? (If yes, document that.) 3. What does the call site look like? Write it before writing the implementation. 4. How does this fail, and what does the user see? 5. What is the migration path when we get this wrong? 6. Does it match the naming and shape of everything nearby? 7. 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.
View on GitHub