API & Library Design — Practice Tasks¶
12 hands-on exercises on designing public surfaces that are "easy to use right and hard to use wrong." Every task: a scenario, a misuse-prone public API (Go / Java / Python — varied), an instruction, and a full solution with a redesigned API, a caller example proving it now reads well, and the reasoning. Ordered easy → hard.
Table of Contents¶
- Task 1 — Kill the Boolean Trap (Go) — Easy
- Task 2 — Give the Common Case a Default (Python) — Easy
- Task 3 — Fix Inconsistent Naming & Argument Order (Java) — Easy
- Task 4 — Stop Leaking an Internal Type (Go) — Medium
- Task 5 — Telescoping Constructors → Builder (Java) — Medium
- Task 6 — Shrink a Sprawling Public Surface (Python) — Medium
- Task 7 — Boolean Trap → Functional Options (Go) — Medium
- Task 8 — Design a Clear Error Contract (Go) — Medium
- Task 9 — Typed Error Hierarchy (Python) — Medium
- Task 10 — Make a Breaking Change Additively (Java) — Hard
- Task 11 — Design From the Caller In (Python) — Hard
- Task 12 — Full API Review (Go — open-ended) — Hard
How to Use¶
Read the scenario, then try the redesign before opening the solution. Write the caller code first — if your imagined call site reads cleanly, the signature is probably right. Each solution explains not just what changed but why the original invited mistakes.
Pair this with the sibling files in this folder: junior.md for the foundational rules, find-bug.md for spotting misuse in existing APIs, and optimize.md for tightening surfaces you already have. The chapter overview lives in ../README.md.
The map below shows the design pressure every task pushes back on.
Task 1 — Kill the Boolean Trap (Go)¶
Difficulty: Easy
Scenario: A logging library exposes one constructor. Reviewers keep approving call sites that nobody can read six months later.
package log
// NewLogger(toStdout, withColor, jsonFormat, includeCaller)
func NewLogger(toStdout, withColor, jsonFormat, includeCaller bool) *Logger { ... }
A typical call site:
Instruction: Redesign the signature so the call site is self-documenting. You do not need functional options yet — even an enum and a small config can fix the boolean trap (a sequence of unlabeled bools where no reader can tell which flag is which, and any two are trivially swapped).
Solution
Replace the positional booleans with a struct of **named, zero-value-friendly** fields and an enum for the genuinely-mutually-exclusive choice (format).package log
type Format int
const (
FormatText Format = iota // zero value = the common default
FormatJSON
)
type Config struct {
Output io.Writer // nil → os.Stdout
Color bool
Format Format
IncludeCaller bool
}
func New(cfg Config) *Logger {
if cfg.Output == nil {
cfg.Output = os.Stdout
}
// ...
return &Logger{out: cfg.Output, color: cfg.Color, format: cfg.Format, caller: cfg.IncludeCaller}
}
Task 2 — Give the Common Case a Default (Python)¶
Difficulty: Easy
Scenario: A retry helper forces every caller to spell out all five parameters, even though 95% of them want the same values.
def retry(func, attempts, base_delay, max_delay, jitter, exceptions):
...
# Every call site, even the trivial one:
result = retry(fetch, 3, 0.5, 30.0, True, (ConnectionError, TimeoutError))
Instruction: Redesign so the common case is a one-liner and the rare case is still expressible. Decide which parameters deserve defaults and which must stay explicit.
Solution
Keep `func` positional (it has no sensible default), give every tuning knob a default, and use keyword-only arguments so nobody passes them positionally by accident.from typing import Callable, TypeVar, Iterable
T = TypeVar("T")
DEFAULT_RETRYABLE = (ConnectionError, TimeoutError)
def retry(
func: Callable[[], T],
*, # everything after is keyword-only
attempts: int = 3,
base_delay: float = 0.5,
max_delay: float = 30.0,
jitter: bool = True,
exceptions: Iterable[type[Exception]] = DEFAULT_RETRYABLE,
) -> T:
...
Task 3 — Fix Inconsistent Naming & Argument Order (Java)¶
Difficulty: Easy
Scenario: A CacheClient grew method by method, each added by a different person. The naming and parameter order are now a minefield.
public interface CacheClient {
String get(String key);
void put(String value, String key); // value, then key
void setExpiry(int seconds, String theKey); // different param name, order flipped again
boolean exists(String k); // abbreviated param
void deleteKey(String key); // verb redundancy
void removeAll(); // synonym for delete
}
Instruction: Redesign the interface for consistency. Fix the argument-order inconsistency, the naming inconsistency, and the redundant verbs. State the conventions you chose.
Solution
Pick one convention and apply it everywhere: **key always comes first**, the parameter is always named `key`, methods are named after the *operation* without redundant nouns, and synonyms collapse to one verb.public interface CacheClient {
String get(String key);
void put(String key, String value); // key first, consistently
void expire(String key, Duration ttl); // key first; Duration > int seconds
boolean contains(String key); // no abbreviation
void delete(String key); // one verb for removal
void clear(); // distinct intent: remove everything
}
Task 4 — Stop Leaking an Internal Type (Go)¶
Difficulty: Medium
Scenario: A query package returns results. The return type drags the library's internal storage representation into every caller's code.
package query
import "github.com/acme/db/internal/rowbuf"
// Run returns the raw internal buffer used by the storage engine.
func Run(sql string) (*rowbuf.Buffer, error) { ... }
Callers are now forced to write import "github.com/acme/db/internal/rowbuf" and call methods like buf.RawColumn(2) and buf.UnsafeBytes(). The moment the storage engine changes its buffer layout, every caller breaks.
Instruction: Redesign the public API so callers depend only on a stable, intention-revealing type that you own and control. Keep rowbuf an implementation detail.
Solution
Introduce a small public `Result` type that the caller can use, and convert from the internal buffer at the boundary. The internal type never crosses the package line.package query
import "github.com/acme/db/internal/rowbuf"
// Result is the stable, public view of a query result.
type Result struct {
rows []Row // owned by this package, free to evolve internally
}
type Row struct{ values []any }
func (r Result) Rows() []Row { return r.rows }
func (row Row) Value(col int) any { return row.values[col] }
func (row Row) Len() int { return len(row.values) }
func Run(sql string) (Result, error) {
buf, err := engine.exec(sql) // returns *rowbuf.Buffer internally
if err != nil {
return Result{}, err
}
return fromBuffer(buf), nil // adapt internal → public at the seam
}
func fromBuffer(buf *rowbuf.Buffer) Result { ... }
Task 5 — Telescoping Constructors → Builder (Java)¶
Difficulty: Medium
Scenario: An HttpRequest class accreted constructors until there were six overloads, several with the same arity and incompatible meanings.
public class HttpRequest {
public HttpRequest(String url) { ... }
public HttpRequest(String url, String method) { ... }
public HttpRequest(String url, String method, Map<String,String> headers) { ... }
public HttpRequest(String url, String method, Map<String,String> headers, byte[] body) { ... }
public HttpRequest(String url, int timeoutMs) { ... } // arity clash with (url, method)
public HttpRequest(String url, String method, int timeoutMs, boolean followRedirects) { ... }
}
The two-arg (String, String) and (String, int) overloads are an accident waiting to happen, and there's no way to set headers and a timeout without also passing a body.
Instruction: Replace the telescoping constructors with a builder. Make required fields mandatory, optional fields fluent, and the result immutable.
Solution
A builder turns "which overload do I need?" into "set the fields I care about." `url` is required (it's the builder's entry point); everything else is fluent with a default.public final class HttpRequest {
private final String url;
private final String method;
private final Map<String, String> headers;
private final byte[] body;
private final Duration timeout;
private final boolean followRedirects;
private HttpRequest(Builder b) {
this.url = b.url;
this.method = b.method;
this.headers = Map.copyOf(b.headers);
this.body = b.body;
this.timeout = b.timeout;
this.followRedirects = b.followRedirects;
}
public static Builder to(String url) { // required field is the entry point
return new Builder(url);
}
public static final class Builder {
private final String url;
private String method = "GET"; // sensible defaults
private Map<String, String> headers = new HashMap<>();
private byte[] body = new byte[0];
private Duration timeout = Duration.ofSeconds(30);
private boolean followRedirects = true;
private Builder(String url) {
this.url = Objects.requireNonNull(url, "url");
}
public Builder method(String m) { this.method = m; return this; }
public Builder header(String k, String v) { this.headers.put(k, v); return this; }
public Builder body(byte[] b) { this.body = b; return this; }
public Builder timeout(Duration d) { this.timeout = d; return this; }
public Builder followRedirects(boolean f) { this.followRedirects = f; return this; }
public HttpRequest build() { return new HttpRequest(this); }
}
}
Task 6 — Shrink a Sprawling Public Surface (Python)¶
Difficulty: Medium
Scenario: A pricing module exposes 14 names. Twelve of them are helpers nobody outside the module should call, but they're all importable, so users have started depending on them — and bug reports now reference _round_half_even as if it were a feature.
# pricing.py — everything is public by default in Python
def calculate_order_total(order): ...
def apply_discount(subtotal, code): ...
def lookup_tax_rate(state): ...
def round_half_even(value): ... # internal helper
def normalize_currency(value): ... # internal helper
def fetch_rate_table(): ... # internal, hits a DB
def parse_discount_code(code): ... # internal
def validate_state_code(state): ... # internal
def cents_to_dollars(cents): ... # internal
def dollars_to_cents(dollars): ... # internal
def _legacy_total(order): ... # dead code, still importable
def memo_cache(): ... # internal
def debug_dump(order): ... # internal, dev-only
def clear_caches(): ... # internal
Instruction: Define the minimal public core and hide the rest. Use Python's conventions (__all__, leading underscores) and explain how you decided what's public.
Solution
Decide the public surface from the *caller's* needs: outsiders compute a total, apply a discount, and look up a tax rate. Everything else is mechanism. Mark intent with `__all__` (controls `from pricing import *` and signals to tooling/readers) and rename true internals with a leading underscore.# pricing.py
__all__ = ["calculate_order_total", "apply_discount", "lookup_tax_rate"]
# ---- Public API ---------------------------------------------------------
def calculate_order_total(order: Order) -> Money:
"""Compute the all-in total for an order. The one entry point most callers need."""
...
def apply_discount(subtotal: Money, code: str) -> Money: ...
def lookup_tax_rate(state: str) -> Decimal: ...
# ---- Internal helpers (underscore-prefixed; excluded from __all__) -------
def _round_half_even(value: Decimal) -> Decimal: ...
def _normalize_currency(value: Decimal) -> Money: ...
def _fetch_rate_table() -> dict[str, Decimal]: ...
def _parse_discount_code(code: str) -> Discount: ...
def _validate_state_code(state: str) -> str: ...
def _cents_to_dollars(cents: int) -> Decimal: ...
def _dollars_to_cents(dollars: Decimal) -> int: ...
def _memo_cache(): ...
def _debug_dump(order: Order) -> str: ...
def _clear_caches() -> None: ...
# _legacy_total deleted — dead code is not an API.
Task 7 — Boolean Trap → Functional Options (Go)¶
Difficulty: Medium
Scenario: A database Connect function has six parameters, four of them boolean, and adding a seventh option would break every caller.
package db
func Connect(
dsn string,
poolSize int,
readOnly bool,
autoReconnect bool,
tls bool,
verbose bool,
) (*Conn, error) { ... }
// Caller:
conn, err := db.Connect("postgres://...", 20, false, true, true, false)
Instruction: Convert this to Go's functional-options pattern. Only dsn is required. Each option must be self-naming, the default behavior must be sensible, and adding a future option must be backward-compatible.
Solution
package db
import "time"
type config struct {
poolSize int
readOnly bool
autoReconnect bool
tls bool
verbose bool
}
type Option func(*config)
func WithPoolSize(n int) Option { return func(c *config) { c.poolSize = n } }
func ReadOnly() Option { return func(c *config) { c.readOnly = true } }
func WithAutoReconnect() Option { return func(c *config) { c.autoReconnect = true } }
func WithTLS() Option { return func(c *config) { c.tls = true } }
func Verbose() Option { return func(c *config) { c.verbose = true } }
func Connect(dsn string, opts ...Option) (*Conn, error) {
cfg := config{ // defaults live in exactly one place
poolSize: 10,
autoReconnect: true,
tls: true, // secure by default
}
for _, opt := range opts {
opt(&cfg)
}
// ... use cfg to dial ...
}
Task 8 — Design a Clear Error Contract (Go)¶
Difficulty: Medium
Scenario: A vault client returns errors as bare strings. Callers can't distinguish "not found" (retry elsewhere) from "permission denied" (give up) from "network blip" (retry), so they resort to string matching.
package vault
func Get(key string) (string, error) {
if !exists(key) {
return "", fmt.Errorf("key %q not found", key)
}
if !authorized() {
return "", fmt.Errorf("not allowed to read %q", key)
}
// ...
}
// Caller, forced into fragile string matching:
val, err := vault.Get("db-password")
if err != nil && strings.Contains(err.Error(), "not found") { ... }
Instruction: Design a typed, documented error contract so callers can branch on the kind of failure programmatically, without parsing strings. Keep helpful messages too.
Solution
Expose **sentinel errors** for the cases callers branch on, wrap them with context, and document the contract. Callers use `errors.Is`.package vault
import (
"errors"
"fmt"
)
// Documented error contract. Callers may test these with errors.Is.
var (
// ErrNotFound is returned when the key does not exist.
ErrNotFound = errors.New("vault: key not found")
// ErrPermission is returned when the caller is not authorized.
ErrPermission = errors.New("vault: permission denied")
// ErrUnavailable is returned for transient backend failures; safe to retry.
ErrUnavailable = errors.New("vault: backend unavailable")
)
func Get(key string) (string, error) {
if !exists(key) {
return "", fmt.Errorf("get %q: %w", key, ErrNotFound) // %w wraps, keeps context
}
if !authorized() {
return "", fmt.Errorf("get %q: %w", key, ErrPermission)
}
val, err := backend.read(key)
if err != nil {
return "", fmt.Errorf("get %q: %w", key, ErrUnavailable)
}
return val, nil
}
val, err := vault.Get("db-password")
switch {
case errors.Is(err, vault.ErrNotFound):
val = fallbackSecret()
case errors.Is(err, vault.ErrPermission):
return fmt.Errorf("startup aborted: %w", err) // unrecoverable
case errors.Is(err, vault.ErrUnavailable):
return retryWithBackoff(...) // transient
case err != nil:
return err
}
Task 9 — Typed Error Hierarchy (Python)¶
Difficulty: Medium
Scenario: A payments SDK raises bare built-in exceptions, so callers can't tell your failures from incidental ones, and can't catch a category.
class PaymentClient:
def charge(self, amount, card):
if amount <= 0:
raise ValueError("amount must be positive")
if not self._valid(card):
raise ValueError("bad card")
resp = self._http.post("/charge", ...)
if resp.status == 402:
raise Exception("declined") # bare Exception!
if resp.status >= 500:
raise Exception("gateway error")
A caller cannot write except PaymentDeclined — there is no such type — and except ValueError would also swallow unrelated ValueErrors from elsewhere.
Instruction: Design an exception hierarchy that lets callers catch all SDK errors, a category, or a specific failure — and carry structured data, not just a message.
Solution
Root every SDK exception at a single base class, group by category, and attach fields callers may need (no string parsing).class PaymentError(Exception):
"""Base class for every error raised by this SDK. Catch this to catch them all."""
class InvalidRequest(PaymentError):
"""Caller supplied bad input. Not retryable. Fix the call and try again."""
class PaymentDeclined(PaymentError):
"""The charge was declined by the issuer. Not retryable as-is."""
def __init__(self, code: str, message: str):
super().__init__(message)
self.code = code # structured: "insufficient_funds", "expired_card", ...
class GatewayError(PaymentError):
"""Transient upstream failure. Safe to retry with backoff."""
def __init__(self, status: int, message: str):
super().__init__(message)
self.status = status
class PaymentClient:
def charge(self, amount: Decimal, card: Card) -> Charge:
if amount <= 0:
raise InvalidRequest("amount must be positive")
if not self._valid(card):
raise InvalidRequest("card failed validation")
resp = self._http.post("/charge", ...)
if resp.status == 402:
raise PaymentDeclined(code=resp.json()["decline_code"], message="charge declined")
if resp.status >= 500:
raise GatewayError(status=resp.status, message="gateway error")
return Charge.from_response(resp)
try:
charge = client.charge(amount, card)
except PaymentDeclined as e:
if e.code == "insufficient_funds":
prompt_for_another_card()
else:
show_decline(e.code)
except GatewayError:
retry_with_backoff(lambda: client.charge(amount, card)) # transient
except InvalidRequest:
raise # programmer error, bubble up
except PaymentError:
log.exception("unexpected payment failure") # net for anything new
Task 10 — Make a Breaking Change Additively (Java)¶
Difficulty: Hard
Scenario: A widely-used library has shipped this method for two years. You now need search to support pagination and to return richer results — but thousands of external callers depend on the current signature.
public class SearchService {
// v1.x — public, used everywhere:
public List<Document> search(String query) { ... }
}
You want to reach a clean target API:
Instruction: Plan the migration as an expand-contract (parallel-change) sequence with proper deprecation, so no minor release ever breaks a caller and SemVer is respected. Show the intermediate state.
Solution
You cannot change the existing method's return type or parameter type in place — that is a binary- and source-breaking change and may only land in a new major version. Instead: **expand** (add the new API alongside the old), **migrate** (deprecate the old, point callers at the new), then **contract** (remove the old, only at a major bump). **Phase 1 — Expand (ships in a minor, e.g. 1.5.0).** Add the new methods; keep the old one working by delegating.public class SearchService {
/** @deprecated since 1.5; use {@link #search(SearchQuery)}. Removed in 2.0. */
@Deprecated(since = "1.5", forRemoval = true)
public List<Document> search(String query) {
// Old behavior preserved by delegating to the new path.
return search(SearchQuery.of(query)).documents();
}
// New, richer API — purely additive, so 1.5 stays backward compatible.
public SearchResult search(SearchQuery query) { ... }
}
public record SearchQuery(String text, int page, int pageSize) {
public static SearchQuery of(String text) {
return new SearchQuery(text, 0, 20); // defaults match old behavior
}
}
public record SearchResult(List<Document> documents, int totalHits, int page) {}
Task 11 — Design From the Caller In (Python)¶
Difficulty: Hard
Scenario: You're building a feature-flag library from scratch. Instead of designing the implementation first, you're given the call site you wish you could write. Design the API to make exactly that snippet work.
Desired usage (this is the spec):
flags = FeatureFlags.from_file("flags.yaml")
if flags.enabled("new-checkout", user=current_user):
render_new_checkout()
else:
render_old_checkout()
# Percentage rollout and per-user overrides should "just work":
with flags.override("new-checkout", enabled=True): # for tests
assert flags.enabled("new-checkout", user=anyone)
Instruction: Work backward from the snippet to the smallest API that satisfies it. Define the public types/methods and a default behavior for the unknown-flag case. Justify each public name by the call site that demands it.
Solution
Read the snippet and list the public surface it *forces* into existence: `FeatureFlags.from_file`, `.enabled(name, user=...)`, and `.override(name, enabled=...)` as a context manager. Nothing else is public. Then design the minimum that backs them.from contextlib import contextmanager
from dataclasses import dataclass
from typing import Protocol
class User(Protocol):
id: str # the snippet calls enabled(..., user=...), so we need a stable id
@dataclass(frozen=True)
class _Flag: # internal: not part of the surface
name: str
default: bool = False
rollout_percent: int = 0
overrides: dict[str, bool] = None # per-user id -> bool
class FeatureFlags:
def __init__(self, flags: dict[str, "_Flag"]):
self._flags = flags
self._forced: dict[str, bool] = {} # set by override()
@classmethod
def from_file(cls, path: str) -> "FeatureFlags":
"""Load flags from YAML. The snippet's entry point."""
raw = _load_yaml(path)
return cls({name: _parse_flag(name, cfg) for name, cfg in raw.items()})
def enabled(self, name: str, *, user: User | None = None) -> bool:
"""The one question callers ask. Unknown flag -> False (safe default: off)."""
if name in self._forced: # test override wins
return self._forced[name]
flag = self._flags.get(name)
if flag is None:
return False # fail closed: unknown feature stays off
if user is not None and flag.overrides and user.id in flag.overrides:
return flag.overrides[user.id]
if flag.rollout_percent:
return _bucket(user.id if user else "") < flag.rollout_percent
return flag.default
@contextmanager
def override(self, name: str, *, enabled: bool):
"""Force a flag on/off within a block. Designed for tests."""
previous = self._forced.get(name, _MISSING)
self._forced[name] = enabled
try:
yield
finally:
if previous is _MISSING:
self._forced.pop(name, None)
else:
self._forced[name] = previous
Task 12 — Full API Review (Go — open-ended)¶
Difficulty: Hard
Scenario: A teammate posts this brand-new public package for review. It compiles and the tests pass, but it violates nearly every rule in this chapter. Conduct the review.
package mailer
import "github.com/acme/mailer/internal/smtpconn"
type Mailer struct {
Conn *smtpconn.RawConn // exported internal type
Retries int
Debug bool
}
// Send(to, cc, html, attach, urgent, retry)
func (m *Mailer) Send(to string, cc string, html bool, attach bool, urgent bool, retry bool) error {
if to == "" {
return errors.New("no recipient") // bare string error
}
// ...
}
func (m *Mailer) SendEmail(to string) error { ... } // near-duplicate of Send
func (m *Mailer) DispatchMessage(to string) error { ... } // third synonym
func (m *Mailer) GetConn() *smtpconn.RawConn { ... } // leaks internal type
func (m *Mailer) DebugDumpConn() string { ... } // dev-only, public
Instruction: Produce a review: list every API-design violation, then sketch the redesigned package. Reference the relevant rule for each finding.
Solution
**Findings** | # | Violation | Where | Fix | |---|-----------|-------|-----| | 1 | Boolean trap | `Send(... html, attach, urgent, retry)` — four positional bools | Functional options or a typed `Message` struct; `urgent`/`retry` become named options. | | 2 | Leaking internal type | `Conn *smtpconn.RawConn` (exported field) and `GetConn()` | Unexport the field; never return the internal type. Callers have no business touching the connection. | | 3 | Sprawling surface | `Send` + `SendEmail` + `DispatchMessage` are three names for one operation | Collapse to a single `Send`. | | 4 | Inconsistent naming | `Send` / `SendEmail` / `DispatchMessage` mix verbs and nouns | One verb (`Send`); drop the redundant `Email`/`Message` suffixes (package is already `mailer`). | | 5 | Mutable public config | `Retries`, `Debug` exported and mutable mid-flight | Set once at construction via options; don't expose mutable knobs. | | 6 | Dev-only method is public | `DebugDumpConn()` | Remove from the public API; gate behind an internal/test build or a logger. | | 7 | Untyped error contract | `errors.New("no recipient")` | Exported sentinel(s) callers can test with `errors.Is`. | | 8 | Bad parameter type | `to string`, `cc string` for what are really lists of addresses | `[]string` (or an `Address` type), so multiple recipients are representable. | **Redesigned package**package mailer
import "errors"
var ErrNoRecipient = errors.New("mailer: no recipient")
// Message is the typed payload — replaces the boolean/primitive soup.
type Message struct {
To []string
Cc []string
Subject string
Body string
IsHTML bool
Attachments []Attachment
}
type Option func(*config)
type config struct {
retries int
urgent bool
}
func WithRetries(n int) Option { return func(c *config) { c.retries = n } }
func Urgent() Option { return func(c *config) { c.urgent = true } }
type Mailer struct {
conn *smtpconn.RawConn // unexported: internal type never escapes
cfg config
}
func New(opts ...Option) (*Mailer, error) {
cfg := config{retries: 3} // sensible default
for _, opt := range opts {
opt(&cfg)
}
conn, err := smtpconn.Dial()
if err != nil {
return nil, err
}
return &Mailer{conn: conn, cfg: cfg}, nil
}
// Send is the single, intention-revealing entry point.
func (m *Mailer) Send(msg Message) error {
if len(msg.To) == 0 {
return ErrNoRecipient
}
// ...
}
Self-Assessment¶
Rate yourself on each skill this chapter trains. Aim to explain the call site, not just produce a signature.
- I can spot a boolean/primitive trap in a signature and convert it to named options, an enum, or a builder — and pick the right one per language.
- I give the common case a default and keep the rare case expressible, without forcing fake defaults on parameters that have none.
- I make naming and argument order consistent so a caller can guess the next method correctly.
- I never leak an internal type across a public boundary, and I know why that locks me in (Hyrum's Law).
- I replace telescoping constructors with a builder/options that makes required fields mandatory and yields an immutable result.
- I keep the public surface minimal and orthogonal, hiding mechanism behind
__all__/ underscores / unexported names. - I design an error contract callers can branch on programmatically — typed/sentinel errors, documented, with structured fields — never string matching.
- I can sequence a breaking change additively with expand-contract, deprecation, and SemVer discipline.
- I can design from the caller in — write the desired call site first and derive the minimal API from it.
- I can review a package and name each violation against the rule it breaks.
If three or more are unchecked, revisit junior.md and the worked examples above before moving on.
Related Topics¶
junior.md— foundational rules for designing public surfaces.find-bug.md— spotting misuse-prone signatures in existing APIs.optimize.md— tightening an API you already shipped.../README.md— the API & Library Design chapter overview and positive rules.../07-boundaries/README.md— the consumer-side mirror: wrapping third-party code you depend on.../22-abstraction-and-information-hiding/README.md— internal module quality that underpins a clean surface.../../design-patterns/README.md— Builder, Factory, and other creational patterns referenced here.../../refactoring/README.md— parallel-change, primitive-obsession, and error-handling refactorings.
In this topic