The API you ship to yourself
Internal interfaces get none of the care external ones do, and they are the ones you'll live with longest.
Teams that would never ship a public API without review, versioning and documentation routinely create internal interfaces in a pull request nobody looked at closely, with a name chosen in thirty seconds.
Those internal interfaces then last longer than the public ones, because nothing external forces them to change and nothing internal makes changing them worth the trouble.
why they matter more than they look#
They are the seams your architecture is made of. A module's public functions are its contract, whether or not you called it a contract.
Everyone reads them. A public API is read by customers. An internal one is read by every engineer who joins, forever.
They shape what is easy. An interface that makes the right thing awkward guarantees people do the wrong thing, and then you have a pattern.
They are hard to change once used. Not because of compatibility, but because finding and updating every caller is work nobody has budgeted.
what to actually apply#
Not the full public-API apparatus. Four things:
1. Name it from the caller's side.
The best test is to write the call before the implementation:
# would a caller guess this?
orders.pending_for(customer_id, since=last_sync)
# or this?
OrderService.get_orders_by_customer_with_status_filter(customer_id, "PENDING", last_sync)The first reads like the sentence the caller was thinking. The second reads like the implementation leaked into the name.
2. Make the wrong call hard to write.
# a caller can get this wrong and never know
def transfer(from_account, to_account, amount): ...
transfer(b, a, 500) # silently backwards
# a caller cannot
def transfer(*, source: AccountId, dest: AccountId, cents: int): ...Keyword-only arguments, distinct types for things that must not be swapped, and units in the name. Every one of these converts a runtime bug into a compile or call-site error.
3. Return something that cannot be misread.
None for "not found" and None for "error" and None for "empty" is three different meanings on one value. A Result, an explicit exception, or distinct return types cost a few lines and remove a category of caller bug.
4. Document the failure modes, not the happy path.
def reserve(sku: str, qty: int) -> Reservation:
"""Reserve stock, or raise.
Raises:
OutOfStock: qty exceeds available. Callers should offer backorder.
SkuUnknown: not a real SKU — this is a bug, not a user error.
LockTimeout: transient, safe to retry with backoff.
"""The signature already said what it takes and returns. What it cannot say is which errors are retryable and which are programmer error — and that is exactly what a caller needs.
the deprecation problem#
Internal APIs never get deprecated because there is always something more urgent, and the old one keeps working.
The mechanism that helps is embarrassingly simple: make the old path noisy in development.
def get_orders_by_customer(*args, **kwargs):
warnings.warn(
"get_orders_by_customer is replaced by orders.pending_for(); "
"see ADR-14. Removal targeted 2026-12.",
DeprecationWarning, stacklevel=2,
)
return orders.pending_for(*args, **kwargs)Warnings in test output that name the replacement and a date get acted on. Silent compatibility shims live forever.
the boundary that actually needs versioning#
Most internal interfaces do not need versions — you can find every caller and change them together. That is the entire advantage of being internal and you should use it rather than building machinery to avoid it.
The exception is any interface crossing a deploy boundary: service to service, or anything consumed by a client you do not deploy simultaneously. There, old and new run at the same time by definition, and you need the same expand-contract discipline as a schema change — add the new field, support both, migrate callers, remove the old.
The mistake is applying that machinery to an in-process module boundary where a single commit could have updated everything.
the one-line version#
Design internal interfaces from the call site, make the wrong call unwriteable, document the errors, and change them freely while you still can — because the window where you can find every caller closes quietly, and nobody notices until they need it open.
— Dom, August 25, 2026