Software Design Reference System Designer · Flexible & Composable Code

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.

Reading ≈ 8 minPractice ≈ 25 min

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:

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.

The principle behind the principle

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 for ABC 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.
Field notes · picking the extension point
Your situationChooseBecause
you must accept objects you don’t ownProtocolThird-party classes will never subclass your base.
the variants share a real default stepABCThe base is the only place that step can live once.
one method, no state, no familya plain callableThe interface would add a name and nothing else.
you need a runtime isinstance checkABC (or @runtime_checkable)Structural checks verify method names, not signatures.
eight methods, implementations use threeneither, yetThe promise is bigger than the need — split it first.
What people get wrong after reading this
"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_checkable makes isinstance safe."
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

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: ...
Teach it back
  1. 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.
  2. 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.
  3. 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

Show your agent-teacher an AI-generated ABC and argue the protocol conversion together.