PYTH-270 · Lesson 10 of 12 · 25 min

Designing APIs and libraries

▶ Video coming soon A video version of this lesson is on the way.

Every function, class and module you write has users: the code that calls it. When those users are in the same file, a clumsy interface is a small annoyance. When they are spread across a codebase, or are other teams, or are strangers who installed your package, the interface becomes the most important part of the design, because it is the part that cannot easily change. An API, an application programming interface, is the set of names, parameters, return values and errors that callers depend on.

Ridgeline is about to have outside users for the first time. The Alder Hotel's developer wants to place wholesale orders from the hotel's purchasing system, and Lena has suggested publishing the pricing and order rules as a small package, ridgeline-core, that both the web shop and the wholesale integration use. This lesson covers how to design interfaces that are easy to use correctly and hard to use wrongly: naming, signatures, errors, what to make public, and how to change an API without breaking the people who rely on it.

Design from the caller's side

The most useful habit in API design is to write the calling code first. Before implementing a function, write the two or three lines that would use it, and ask whether they read clearly. Joshua Bloch, who designed much of Java's standard library, put the goal this way: an API should be easy to use and hard to misuse. A good API makes the simple case short, makes the right thing the obvious thing, and makes mistakes fail loudly and early.

The principle of least astonishment adds that an API should behave the way its users expect. A function called get_price should not also change the stock level; a method called save should not send an email; a function that takes a list should not change the list it was given unless its name says so, like list.sort, which sorts in place and returns None, while sorted returns a new list. Python's own conventions are a guide: users expect len to be cheap, iteration to be possible on collections, and errors to be exceptions.

Boolean and positional arguments hide meaning; keyword-only arguments make calls self-explanatory
# Hard to read at the call site: what are True, False and 2?
def make_label_v1(sku, a, b, c):
    return f"{sku} {'ground' if a else 'whole'} {'gift' if b else ''} x{c}".strip()

print(make_label_v1("RC-102", True, False, 2))

# Easy to read: keyword-only options with sensible defaults
def make_label(sku, *, grind="whole bean", gift=False, copies=1):
    parts = [sku, grind] + (["gift"] if gift else [])
    return " | ".join(parts) + f" x{copies}"

print(make_label("RC-102", grind="espresso", copies=2))
try:
    make_label("RC-102", "espresso")
except TypeError as e:
    print("TypeError:", e)
Output
RC-102 ground  x2
RC-102 | espresso x2
TypeError: make_label() takes 1 positional argument but 2 were given

Names and consistency

Names are the main documentation most users ever read. Use the vocabulary of the domain (coffee, roast, subscription, wholesale account) rather than technical filler (data, info, manager, helper), and use the same word for the same idea everywhere: if one function says sku, none should say product_code for the same thing. Functions that return a value are named for what they return (total_weight, low_stock); functions that act are named for what they do (reserve, cancel). Booleans read as questions (is_stale, has_stock).

Consistency extends to the order of parameters, the units of values and the style of errors. If every function in ridgeline-core takes the SKU first, measures weight in grams and raises RidgelineError subclasses, users learn the pattern once and can guess the rest. Inconsistency forces them to check the documentation for every call, and they will eventually guess wrong.

Signatures that prevent mistakes

Python's parameter syntax gives you precise control over how a function can be called. A bare * in the parameter list makes everything after it keyword-only, which is ideal for options and flags: callers must write gift=True, so the call explains itself, and you can later add or reorder options without breaking anyone. A / makes the parameters before it positional-only, which lets you rename them later because no caller can refer to them by name.

Defaults should be the safe, common choice. Never use a mutable default such as [] or {}, which is shared between calls; use None and create the object inside. Avoid boolean flags that switch between two different behaviors; two clearly named functions, such as export_csv and export_json, are usually better than export(as_json=True). Accept general inputs and return specific outputs: take any iterable of SKUs, but return a list or a dataclass with named fields rather than a bare tuple whose positions callers must remember.

Try it yourself

A label function that is hard to misuse

Write bag_label(sku, *, grind="whole bean", weight_g=340) returning a string like RC-104 | whole bean | 340 g. The bare * makes grind and weight_g keyword-only.

Validate the inputs: grind must be one of "whole bean", "filter" or "espresso", and weight_g must be a positive int; otherwise raise ValueError with a message naming the bad value.

def bag_label(sku, grind="whole bean", weight_g=340):
    return f"{sku} | {grind} | {weight_g} g"

Errors are part of the interface

How a function fails is as much a part of its API as what it returns. In Python, the convention is to raise an exception when a function cannot do its job, rather than returning None or a special value. A function that returns None for a missing coffee forces every caller to remember to check, and the ones that forget fail later, somewhere confusing, with an AttributeError on None. An exception fails at the point of the problem, with a message that says what went wrong.

A library should define its own exception hierarchy: one base class, such as RidgelineError, and specific subclasses for the failures callers might handle differently, such as UnknownCoffee and OutOfStock. Callers can catch the specific errors they can recover from, or catch the base class to handle everything from the library in one place, without accidentally catching unrelated errors such as a KeyError from their own code. Give exceptions useful attributes, such as the SKU and the quantity available, so callers do not have to parse messages.

A library exception hierarchy with useful attributes
class RidgelineError(Exception):
    """Base class for every error raised by ridgeline-core."""

class UnknownCoffee(RidgelineError):
    def __init__(self, sku):
        super().__init__(f"no coffee with SKU {sku}")
        self.sku = sku

class OutOfStock(RidgelineError):
    def __init__(self, sku, requested, available):
        super().__init__(f"{sku}: asked for {requested}, only {available} in stock")
        self.sku, self.requested, self.available = sku, requested, available

def reserve(stock, sku, bags):
    if sku not in stock:
        raise UnknownCoffee(sku)
    if bags > stock[sku]:
        raise OutOfStock(sku, bags, stock[sku])
    stock[sku] -= bags

stock = {"RC-101": 42, "RC-104": 9}
for sku, bags in [("RC-101", 12), ("RC-104", 20), ("RC-999", 1)]:
    try:
        reserve(stock, sku, bags)
        print("reserved", bags, sku)
    except OutOfStock as e:
        print("offer the", e.available, "remaining bags of", e.sku)
    except RidgelineError as e:
        print("cannot reserve:", e)
Output
reserved 12 RC-101
offer the 9 remaining bags of RC-104
cannot reserve: no coffee with SKU RC-999

Try it yourself

Raise, do not return None

The starter find_coffee(catalog, sku) returns None when the SKU is missing, so callers crash later with confusing errors. Define RidgelineError(Exception) and UnknownCoffee(RidgelineError), where UnknownCoffee(sku) stores self.sku and has the message no coffee with SKU <sku>.

Change find_coffee to raise UnknownCoffee for a missing SKU, and to return the coffee otherwise. catalog is a dictionary from SKU to coffee name.

def find_coffee(catalogue, sku):
    return catalogue.get(sku)

What to make public

Everything a user can reach, they may come to depend on. Hyrum Wright, an engineer at Google, observed that with enough users, every observable behavior of a system will be depended on by somebody, whatever the documentation promises. This is known as Hyrum's law, and it argues for a small public surface. Expose the names users need, hide everything else, and avoid leaking accidental details such as the order of items in a returned dictionary unless you intend to keep it.

Python marks privacy by convention. Names starting with an underscore are internal and may change without notice. A package's __init__.py imports the public names from its modules so users write from ridgeline_core import price_order, Money rather than reaching into ridgeline_core.pricing._rules. The __all__ list in a module declares its public names for from module import * and for documentation tools. Returning dataclasses or named tuples rather than raw dictionaries also keeps the shape of results explicit and stable.

Changing an API without breaking users

Interfaces must evolve, but every change to a published API can break someone. Semantic versioning, which you used in PYTH-110, gives a shared language: a patch release (1.4.2 to 1.4.3) fixes bugs without changing the interface, a minor release (1.4 to 1.5) adds features in a backward-compatible way, and a major release (1 to 2) is allowed to break things. Users can then choose how much change to accept by pinning versions.

Breaking changes are made gently through deprecation. First, release a version where the old name still works but is marked for removal: the documentation says so, and calling it emits a message through Python's warnings module, pointing to the replacement. Keep both working for at least one minor release, ideally longer. Only then remove the old name, in a major release, with a clear entry in the changelog. Adding new keyword-only parameters with defaults, new functions and new optional fields are all non-breaking ways to grow an API.

Try it yourself

A gentle rename

ridgeline-core is renaming calc_total(lines) to the clearer order_total(lines), where lines is a list of (qty, unit_price) tuples and the result is rounded to 2 decimal places.

Write order_total, and keep calc_total working as a thin wrapper that calls order_total and appends the message calc_total is renamed; use order_total to the list NOTICES, only the first time it is called. (A real library would use the warnings module; a list keeps the exercise easy to test.)

NOTICES = []

def calc_total(lines):
    return round(sum(q * p for q, p in lines), 2)

Designing for extension

A library cannot anticipate every use, so a good one offers deliberate extension points instead of forcing users to copy and modify its code. The patterns from earlier lessons are the tools. Accept a function as a parameter where users will want to vary one step, as sorted does with key. Define a Protocol for objects users may supply, such as a custom tax calculator. Provide a registry or a hook, such as a list of callbacks run after each order is priced, for behavior users want to add.

ridgeline-core lets the Alder Hotel's integration pass its own rounding rule, because the hotel's accounting system rounds each line rather than the total, and offers an on_reserved hook that the hotel uses to update its purchasing records. Each extension point is documented and tested like any other part of the public API, because once published, it is one.

Documentation and examples

An API is only as usable as its documentation. Every public function needs a docstring that says what it does, what the parameters mean (with units), what it returns and which exceptions it raises. Type hints document the shapes of values precisely and let editors help users as they type. But the most valuable documentation is usually a short example: the five lines that show how to price an order or reserve stock. Lesson 11 shows how to make those examples executable with doctest, so the documentation cannot quietly go out of date.

A diagram of the ridgeline_core package as an iceberg. Above the waterline are the public names: price_order, reserve, Money, RidgelineError and its subclasses. Below the waterline, much larger, are internal modules with underscore names: _rules, _rounding, _storage. A note says users depend on everything above the line.
Keep the public surface small: users come to depend on everything they can see

Worked problem

Reviewing an API before publishing

Sam proposes this function for ridgeline-core: def order(c, l, w=False, d=[]): ... returning a tuple (total, tax, ok) or None if an SKU is unknown. Lena reviews it with him.

Show solution
  1. Names: order, c, l, w and d say nothing. Rename to place_wholesale_order(customer, lines, *, delivery_date=None).
  2. Flag: w=False switches between retail and wholesale behavior; split into two functions instead of a boolean parameter.
  3. Mutable default: d=[] is shared between calls; use None and create a new list inside.
  4. Return value: a tuple with three positions and a status flag is easy to misread; return an OrderResult dataclass with named fields.
  5. Errors: returning None for an unknown SKU hides the problem; raise UnknownCoffee, part of the RidgelineError hierarchy.

Answer: Descriptive names, no boolean mode flag, no mutable default, a dataclass result and exceptions for failures.

Common mistakes

  • MistakeDesigning the implementation first and the interface as an afterthought.

    InsteadWrite the calling code first and shape the API to read well there.

  • MistakeUsing positional booleans such as process(order, True, False).

    InsteadUse keyword-only parameters or separate, clearly named functions.

  • MistakeReturning None to signal failure.

    InsteadRaise a specific exception from your library's hierarchy.

  • MistakeRaising generic Exception or ValueError for every library failure.

    InsteadDefine a base exception and meaningful subclasses with useful attributes.

  • MistakeExposing internal modules and helpers.

    InsteadKeep a small public surface, re-exported from __init__.py, and prefix internals with an underscore.

  • MistakeRemoving or renaming public names without warning.

    InsteadDeprecate first, keep both for a release or more, then remove in a major version.

Summary and key terms

  • An API is the names, parameters, return values and errors callers depend on; it is the hardest part of a design to change.
  • Write the calling code first; aim for easy to use correctly and hard to misuse.
  • Follow the principle of least astonishment and Python's own conventions.
  • Use domain vocabulary and consistent names, parameter orders and units.
  • Keyword-only parameters, safe defaults and specific return types prevent mistakes.
  • Raise exceptions from a library hierarchy instead of returning None; give them useful attributes.
  • Keep the public surface small (Hyrum's law), and change it through semantic versioning and deprecation.
API
The interface of names, parameters, results and errors that callers rely on.
principle of least astonishment
An interface should behave the way its users expect.
keyword-only parameter
A parameter after a bare * that must be passed by name.
positional-only parameter
A parameter before a / that cannot be passed by name.
exception hierarchy
A base exception class with specific subclasses for different failures.
public surface
Everything users of a library can reach and therefore depend on.
Hyrum's law
With enough users, every observable behavior will be depended on by someone.
__all__
A module-level list naming the module's public names.
semantic versioning
Version numbers of the form major.minor.patch that signal the kind of change.
deprecation
Marking an API for future removal while it still works.
breaking change
A change that can make existing callers fail.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. What does a bare * in a parameter list do?

    Show answer

    Makes the parameters after it keyword-only.

  2. Why is raising an exception better than returning None for a missing coffee?

    Show answer

    It fails at the point of the problem with a clear message, instead of a confusing error later in a caller that forgot to check.

  3. Why give a library one base exception class?

    Show answer

    So callers can catch all of the library's errors in one place without catching unrelated ones.

  4. What does Hyrum's law suggest about public surfaces?

    Show answer

    Keep them small, because users will depend on anything observable.

  5. Which version number changes for a backward-compatible new feature under semantic versioning?

    Show answer

    The minor version.

  6. Name a non-breaking way to add an option to an existing function.

    Show answer

    Add a new keyword-only parameter with a default that keeps the old behavior.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz