Documenting the Design, Not Just the API

The Course
•
September 19, 2026
•
9 min read

Everything this week has been a decision with a cost: an identity map that can go stale, a cache that evicts, validation on every load, a module-level driver registration. Each was reasonable. None is obvious from reading the code.

Documentation is where those decisions survive, and a library’s docs have three separate jobs.

What the README owes a reader

Someone lands on your project with one question: can this do what I need, and what will it cost me? Answer that in the first screen.

The library’s README leads with what it deliberately isn’t:

A deliberately small ORM: typed fields, a model registry, get/save/delete and
transactions, over any DB-API connection. No joins, no relationships, no
migrations, no async — on purpose.

Then two sections that matter more than any tutorial:

What it guarantees. The promises a caller may build on — validation on the way in and out, all-or-nothing writes, one object per row while cached, its own exception hierarchy, no driver types in the API.

What it does not do, as a table, with a workaround for each:

LimitationWhyWorkaround
get_many never chunksOne query, however many idsChunk at the call site; the limit is the driver’s
Identity map capped at 128An unbounded cache is a leak — 1.7 MB per 5000 rowsRaise cache_size, or refresh()
A rolled-back save stays in the cacheThe cache doesn’t join the transactionClear Model._instances after a rollback
Not thread-safeNo locking; the registry and caches are sharedOne connection and registry per thread

That table is the most useful thing in the document. It’s also the hardest to write, because every row is an admission — and every row saves someone an afternoon and saves you an issue thread.

Limitations are a feature list in disguise

“No joins, no migrations, no async — on purpose” tells a reader more about whether to adopt your library than any feature list. Scope stated plainly reads as confidence; scope left vague reads as an unfinished project.

Docstrings answer the question the caller has

Not what the method is called. What it does that the signature doesn’t say.

    def delete(self, connection: Connection) -> Self:
        """Remove this row. The object stays usable, and saving it inserts anew."""

The name says “delete”. The docstring says what happens to the object afterwards — which is the thing a caller can’t guess and will otherwise discover by experiment.

    @classmethod
    def get_many(cls, connection: Connection, ids: Sequence[int]) -> dict[int, Self]:
        """Every row in one query, instead of one query per row.

        Anything already in the identity map is served from there, so a second
        call for the same ids costs nothing.
        """

Compare with what this docstring could have said — “Get many objects by id” — which adds nothing to the signature.

Three habits:

  • Lead with the one-line behaviour, in the imperative.
  • Say what the caller can’t see: side effects, what’s cached, what’s validated, what it raises and when.
  • Don’t restate types that annotations already carry.
A docstring is also a test Python
def dedupe(ids):
    """Remove duplicates, keeping the order of first appearance.

    >>> dedupe([3, 1, 3, 2, 1])
    [3, 1, 2]
    >>> dedupe([])
    []
    """
    return list(dict.fromkeys(ids))


import doctest

results = doctest.testmod(verbose=False)
print(f"doctests run: {results.attempted}, failed: {results.failed}")

print("\nNow with a wrong example:")

def broken(ids):
    """Remove duplicates.

    >>> broken([3, 1, 3])
    [1, 3]
    """
    return list(dict.fromkeys(ids))


print(doctest.run_docstring_examples(broken, {"broken": broken}, verbose=False) or "")

doctest runs the examples in your docstrings and fails when they stop being true. That makes examples the only part of documentation that can’t quietly rot — worth using for small, pure functions with obvious inputs. It fits dedupe and not save, which needs a database.

A decision record for the why

Docstrings say what the code does. The README says what the library promises. Neither says why you chose this over the obvious alternative, and that’s the question your future self — or the next maintainer — will actually have.

An architecture decision record is a short file per decision, and it needs four parts:

# 3. Validate rows loaded from the database

## Status
Accepted (Day 3)

## Context
Rows can be written by anything: another service, a migration, a shell. SQLite
has type affinity rather than type enforcement, so a column declared `integer`
can hold the string 'lots'. Our field annotations claim `post.views` is an
`int`, and a checker believes them.

## Decision
`get` and `get_many` build objects through `cls(**row)`, so every value passes
through the same field validation as a caller's assignment. No fast path may
skip it.

## Consequences
- A corrupt row raises `ValidationError` on load rather than `TypeError` later,
  far from the cause.
- Loading is measurably slower: validation is roughly a third of the CPU in a
  load-heavy profile.
- Callers cannot load and inspect a bad row through the library at all. They
  need raw SQL to see it, which is a real cost for anyone debugging data.

The section that earns its place is Consequences, and specifically the bad ones. A decision record listing only benefits is marketing; the value is in having written down what you gave up, so that when the cost shows up later nobody relitigates the decision from scratch.

Three or four of these are enough for a library this size: the identity map, validating on load, the exception hierarchy, driver registration.

Exercise

Write the README's limitation table

Below is a small library with four behaviours a user will be surprised by. Find them by reading the code, then write the table. This exercise has no tests — the check is the review below.

from collections import OrderedDict

_cache = OrderedDict()
MAX_ENTRIES = 100


class Feed:
    """Reads items from a source, remembering what it has seen."""

    def __init__(self, source, *, page_size=50):
        self.source = source
        self.page_size = page_size

    def items(self, since=None):
        """Return items newer than `since` (a timestamp), newest first."""
        key = (self.source, since)
        if key in _cache:
            return _cache[key]
        items = sorted(self._read(since), key=lambda item: -item["at"])
        _cache[key] = items
        while len(_cache) > MAX_ENTRIES:
            _cache.popitem(last=False)
        return items

    def _read(self, since):
        page = self.source.fetch(limit=self.page_size)
        return [item for item in page if since is None or item["at"] > since]

    def mark_read(self, item):
        item["read"] = True
        return item


print("Read the code. What would surprise a user?")
Hint 1: the idea

Read it as the user who’ll call items() and mark_read(). For each line, ask what the caller will assume that isn’t true. Four places to look: how many items one call can return; how long a result stays the same; who else shares the cache; and what the caller gets back — a copy, or the thing itself.

Hint 2: a first step

Start in _read: in what order are limit and since applied? Then trace items() called twice with the same arguments. Which object does the second call hand back — and what does mark_read then do to it?

Show one solution

Four, and the fourth is the one people miss.

LimitationWhyWorkaround
items() returns at most page_size items_read fetches one page and filters it; since is applied after the limit, so an old since can return nothing even when matching items existPass a larger page_size, or page at the call site
Results are cached and never expire within a run_cache is keyed by (source, since) and only evicted by sizeUse a fresh since, or clear _cache
The cache is shared by every Feed in the process_cache is a module-level dict, not per instanceOne process per tenant, or key by something unique
items() shares the cached records themselvesIt returns the cached list, and mark_read mutates the dicts inside it in placeReturn deep copies, or make the records immutable — a tuple or shallow copy is not enough

The fourth is the real bug and the hardest to see: items() hands out the cached list, and mark_read mutates the dicts inside it. Note what does not fix it — returning tuple(items) or list(items). Those copy the outer container, but the elements are the same dict objects, so mark_read still reaches through and mutates the cached record. Isolating it means copying the records themselves (copy.deepcopy, or building fresh dicts) or making them immutable. That’s Day 1’s lesson, exactly — shallow copies share their contents — arriving as a production incident.

Notice what the table does for each row: names the surprise, says what in the code causes it, and gives the caller something to do. A limitation without a workaround is a complaint; with one, it’s documentation.

Writing this table is also how you find out which limitations you’re not willing to ship. Two of these four are worth fixing rather than documenting — returning deep copies of the records, and applying since before the limit — and the table is what makes that obvious.

What you learned

  • A README should answer “can this do what I need, and what will it cost me?” before it teaches anything.
  • A table of limitations with workarounds is the highest-value section you can write, and the hardest.
  • Docstrings say what the signature can’t: side effects, caching, validation, errors.
  • A decision record keeps the why, and its Consequences section must include what you gave up.
Last updated on September 19, 2026

Was this article helpful?

Your response is saved on this device.