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.

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.

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.

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.

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: ...

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.