Lesson 0017 · System Designer · Module 1
Protocols and ABCs: Clean Extension Points
An extension point is a promise: "anything with these methods works here." Protocols make the promise structural; ABCs make it nominal. Choose by who's making the promise.
Mission tie-in: "use protocols and abstract base classes to design clean extension points" — how the System Designer opens a module for extension without weakening its core.
Knowledge: two ways to promise
Protocol — structural typing: an object qualifies by having the methods, whether or not it ever heard of your interface. The extension point is open to anything that walks like the duck:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Notifier(Protocol):
def send(self, message: str, recipient: str) -> None: ...
def notify_all(notifier: Notifier, users: list[User]) -> None:
for user in users:
notifier.send(f"Update for {user.name}", user.email)
ABC — nominal typing: an object qualifies by declaring itself a subclass, and the base can provide default behavior and enforce method presence:
from abc import ABC, abstractmethod
class Notifier(ABC):
@abstractmethod
def send(self, message: str, recipient: str) -> None: ...
def send_bulk(self, messages: list[str], recipient: str) -> None: # shared behavior
for m in messages:
self.send(m, recipient)
How to choose:
- Protocol, when the module must accept things it doesn't own — third-party objects, framework callbacks, your own legacy code. The promise is "if you have these methods, you're in."
- ABC, when you own the family and want shared default behavior, enforced structure, or a place where the extension contract is discovered by subclassing.
Either way, the extension point should be deep (lesson 0001's reading): a small, stable promise that hides how much work it enables. If the interface is bigger than the implementation, it's not an extension point — it's a leak.
An interface is a promise about substitutability, and the mechanism you pick decides two things: who bears the cost of conforming, and which way the dependency arrow points in the source graph. Structural typing lets the consumer state exactly what it needs, so the interface can live beside the code that calls it and no implementation imports anything at all — dependency inversion with zero edges added to the import graph. Nominal typing makes the producer declare intent, which buys shared default behaviour, discoverability by subclassing, runtime checks and a place to evolve a family — paid for with an inheritance edge from every implementation back to your package, and therefore a release coupling between you and them.
Now the uncomfortable part, because it decides when either is worth anything. Neither mechanism enforces behaviour. A Protocol is checked only where a type checker runs, so in a codebase with no mypy or pyright in CI it is a comment with syntax highlighting. @runtime_checkable narrows that gap barely: isinstance then verifies that the names exist, not their signatures, so a class whose send() takes no arguments passes cleanly and fails at the call. An ABC gives you one real guarantee — instantiation fails if an abstract method is missing — which is still presence, not correctness; a subclass whose method raises NotImplementedError satisfies it. Substitutability in the Liskov sense is verified by exactly one thing in Python, and it is not the type system: a shared contract test that every implementation must pass.
How a senior designer works the case
A nine-method Notifier ABC lives in a shared/interfaces package imported by eight services owned by four teams. A new requirement needs a tenth method. The proposed change adds it to the base class.
What they look at first. Not the method. The package the interface lives in, and the list of things that import it — because that list is the blast radius of every future change to this promise. Then the usage: each service calls two or three of the nine methods, and every implementation stubs the rest.
The question they ask. "Who owns this interface — the callers or the implementations?" An interface owned by the implementations is a contract you must negotiate; one owned by a caller is a description of what that caller happens to need, and it can be changed by the person changing the caller.
The alternative they reject. Versioning the shared package, so teams can adopt shared-interfaces 2.x on their own schedule. It works, and it makes the coupling official: every interface change now needs a migration plan across four teams' release trains, and the nine-method promise — which no implementation ever needed in full — is preserved forever because removing anything is a breaking change.
# declared in the module that USES it, naming only what it calls
class SendsAlerts(Protocol):
def send(self, message: str, recipient: str) -> None: ...
class EscalationPolicy:
def __init__(self, alerts: SendsAlerts) -> None:
self._alerts = alerts # SlackClient imports nothing of ours
def check_sends_alerts(candidate: SendsAlerts, recorder: Recorder) -> None:
candidate.send("ping", "ops@example.com") # one contract test, parameterised
assert recorder.last_call == ("ping", "ops@example.com") # over every adapter
The consequence over time. The nine-method interface dissolves into three small ones, each sitting next to its consumer. Adding a method to the Slack client breaks nobody. The shared package stops being a place where four teams negotiate promises and becomes what it was good at — shared implementations. The honest cost: the same client now satisfies three protocols and no single file says so, so a change to it can break a consumer it never heard of. That is what the contract test is for, and it is the trade a senior takes knowingly — decentralised promises plus tests that catch drift, over one central promise that everyone has to schedule around.
† AI-generated code tends to reach forABC by default (it's the famous one). Review it: if nothing shares default behavior and nothing declares ancestry, a Protocol (or a bare callable, lesson 0016) was lighter and just as open.
| Your situation | Choose | Because |
|---|---|---|
| you must accept objects you don’t own | Protocol | Third-party classes will never subclass your base. |
| the variants share a real default step | ABC | The base is the only place that step can live once. |
| one method, no state, no family | a plain callable | The interface would add a name and nothing else. |
| you need a runtime isinstance check | ABC (or @runtime_checkable) | Structural checks verify method names, not signatures. |
| eight methods, implementations use three | neither, yet | The promise is bigger than the need — split it first. |
- "Protocols are typing sugar — duck typing already worked."
- It worked invisibly, which is the problem: the required shape existed only in the bodies of the functions that used it. Writing it down names the consumer's requirement, so it can be reviewed, narrowed and refactored instead of being rediscovered by whoever breaks it.
- "
@runtime_checkablemakesisinstancesafe." - It checks that the attribute names exist. Signatures, argument counts and return types are not verified, so a mismatched implementation passes the check and fails at the call site — one layer further from the cause (lesson 0014).
- "An ABC guarantees implementations are correct."
- It guarantees the methods exist at instantiation. A subclass that raises
NotImplementedError, returns the wrong type, or violates the base's documented ordering satisfies the ABC entirely. Correctness needs a contract test. - "Design the interface to cover the methods you'll probably need."
- Interface size is a tax charged to every implementation and every fake, forever. Unused methods get stubbed with
pass, then someone calls one. Add the method when the second implementation actually needs it.
When this is the wrong move
- You are publishing a plugin API to people you do not know. A nominal base class gives plugin authors a discoverable contract, a place for defaults, and somewhere to put deprecation shims. Structural typing gives them nothing to read and no signal when the shape changes.
- Your CI does not run a type checker. Then a Protocol enforces literally nothing. Either add the checker — the cheaper fix — or use an ABC, which at least fails at instantiation.
- The variants genuinely share a step. A template method in a base class is the only place that step can live exactly once. Protocols cannot carry behaviour.
- You need runtime dispatch on type. Registries keyed by class,
singledispatchandABCMeta.registerexist for this, and structural checks are the wrong tool.
Skill: which promise fits?
Your module must accept any object with a render() method, even ones you don't own. Choose:
You own a family of parsers sharing one default step. Choose:
An interface bigger than its implementations is a sign of:
Practice on your own code
Find an ABC in your codebase (or an AI-generated one). Ask: do the subclasses share default behavior, or do they each implement everything? If the ABC is just a contract with no shared behavior and no external subclasses, convert it to a Protocol.
In the lab: the phase-2 checkpoint ships a returns codebase with one wide abstract base class that every adapter inherits and only partly implements. You narrow it to the methods each consumer actually calls, decide per seam whether a protocol or a base class fits, and add the contract test that neither mechanism gives you.
Reveal: an ABC that was really a protocol
# an AI wrote this: a contract with zero shared behavior
class Exporter(ABC):
@abstractmethod
def export(self, data) -> str: ...
# every subclass implemented export() fully; nothing shared; nothing external
# convert to a protocol — same contract, open to anything with export()
class Exporter(Protocol):
def export(self, data) -> str: ...
- In two sentences, explain to a junior why an interface is usually better owned by the code that calls it than by the code that implements it.
- Find one interface in your codebase this week and count, per implementation, how many of its methods are real. Name the cost of the stubs.
- Assume a shared base class keeps gaining a method every quarter across four teams. Predict what the release process looks like in a year.
Your win
You can now pick the extension-point mechanism by ownership and shared behavior — protocol for open, ABC for family — and you know the leak test for the interface's size.
Read and watch deeper
- Fluent Python, Ramalho — ch. 13 "Protocols and ABCs": the definitive Python treatment of the choice.
- Python docs — typing.Protocol: the structural-typing reference.
- A Philosophy of Software Design, Ousterhout — ch. 4: deep modules as the shape of good interfaces.
- PEP 544 — Protocols: Structural subtyping and PEP 3119 — Introducing Abstract Base Classes: the two primary sources, each with the rationale and the explicit limits of what it checks.
- Watch: ArjanCodes YouTube — search "protocol or abstract base class".
- Reference: Glossary — protocol; and Design Principles — open–closed, the principle an extension point serves.
Show your agent-teacher an AI-generated ABC and argue the protocol conversion together.