When should a shared library publish Protocols instead of ABCs as its extension points, and who owns them?
answer
- Ask where breakage becomes visible
- Coupling cost versus enforcement strength
- Runtime dispatch is a hard constraint
- Adding a member: loud versus silent
- Publish both, with different jobs
basics
~20 sPublish an abc.ABC when you need runtime enforcement, isinstance dispatch or shared implementation; publish a typing.Protocol when implementers should not depend on you. The deciding factor is enforcement: a Protocol is only as real as the type-checking gate in CI.
solid answer
~50 sTreat it as a question about **where breakage shows up**. A published `abc.ABC` is a hard dependency and a nominal contract: implementers import and inherit, they get shared code, and adding an abstract member breaks them loudly with a `TypeError` at instantiation. A published `typing.Protocol` costs implementers nothing and adapts classes you do not own, but adding a member breaks them silently — only a static type checker will ever say so, and only for teams that run one. So the first question is whether your consumers have a type-checking gate at all; without one a Protocol is a docstring. The second is whether you need runtime answers — plugin registries and `isinstance` dispatch need a nominal base or registration. The mature shape for a library is usually both: a Protocol as the typed contract that callers annotate against, plus an optional abstract base carrying convenience methods for implementers who want them.
code
python · 28 linesimport abc
from typing import Protocol
class Sink(Protocol): # the typed contract
def write(self, line: str) -> None: ...
class BaseSink(abc.ABC): # optional convenience
@abc.abstractmethod
def write(self, line: str) -> None: ...
def write_many(self, lines: list[str]) -> None:
for line in lines:
self.write(line)
class ListSink(BaseSink):
def __init__(self) -> None:
self.lines: list[str] = []
def write(self, line: str) -> None:
self.lines.append(line)
def scrape(sink: Sink) -> None: # accepts either style
sink.write("cpu=0.42")
sink = ListSink()
sink.write_many(["boot", "warm"])
scrape(sink)
print(sink.lines)go deeper
Focus on the underlying difference first: inheriting a base class is a commitment the interpreter can check, while a protocol is a description of a shape that a separate tool compares.
Be able to say what each choice costs an implementer — an import and an inheritance edge versus nothing at all — and what each gives the library author in return.
Argue from constraints you have hit: runtime dispatch needs, shared implementation, and whether consuming teams actually run a type checker, since an unenforced protocol is documentation.
Own the evolution story across teams — how a new required member breaks loudly or silently, what you commit to by publishing an inheritance contract, and why the behavioural contract needs a conformance suite regardless.
At the point where a library defines its extension points, the choice between a structural `typing.Protocol` and a nominal `abc.ABC` stops being a typing-syntax question and becomes a question about **coupling, evolution and enforcement** across teams. ## Frame it as: where does a mistake become visible, and to whom? With a published **abstract base class**, the contract is enforced by the interpreter in the implementer's own process. Forget a member and construction fails with a `TypeError` at the moment the implementer instantiates their class — usually in their own first test. That is a strong, tool-free guarantee, and it is the single best argument for an ABC on a team without a type-checking gate. The costs are equally concrete: - every implementer takes an import dependency on your package, - inheritance is a permanent structural commitment, - and you cannot bring in a class you do not own except by virtual registration, which verifies nothing. With a published **Protocol**, the contract is enforced by whatever static checker the *consumer* runs, at whatever version they pin, whenever their CI happens to run. Implementers depend on nothing. Classes that already exist conform on the strength of having the right methods. But the failure mode inverts: a class that has drifted out of conformance keeps constructing and keeps running, and the divergence surfaces as behaviour — a metrics sink that quietly stops recording, a silent truncation of the data you meant to collect — rather than as an exception at a definable point. ## Evolution is the second axis, and it cuts the other way - Adding a required member to a published ABC breaks every implementer loudly at their next instantiation: painful, but discoverable and immediate. - Adding a member to a published Protocol breaks nobody at runtime; the pre-existing implementations keep passing objects that no longer satisfy the annotation, and only a checker run will notice. - Widening is the mirror image: relaxing a Protocol is free and coordination-less because conformance is recomputed from shape every time, while relaxing an ABC still leaves an inheritance edge in everyone's code. If you expect the contract to grow, an ABC's loudness is an asset; if you expect it to be satisfied by many objects you will never see, structural typing is the only thing that scales. ## Team size and tooling change the answer more than aesthetics do On a 4-person team sharing one repository with a type checker in CI, consumer-side protocols are close to free: the whole graph is checked on every push, the arrow points where you want it, and nobody carries a base class they do not need. Publish that same library to teams who do not type-check and the protocols evaporate — you have written documentation and called it an interface. That is the honest test to apply, and it is a question about the consumer's engineering practice, not about your library. ## Runtime needs are a hard constraint, not a preference If the library must discover plugins, branch on capability, dispatch on kind, or validate a user-supplied object at a boundary before trusting it, it needs a runtime answer. - A nominal base gives that immediately; - `abc.ABCMeta.register` gives it for classes you do not own, at the price of checking nothing; - and a protocol can be made instance-checkable with `typing.runtime_checkable`, with its own limits. Decide this before the style argument, because it eliminates options. **Shared implementation is the other hard constraint.** If the extension point comes with real work — batching, retries, an iterator built on one primitive method — an abstract base class is how you hand that to implementers, and the **template-method shape** is exactly what `abc.abstractmethod` plus concrete methods expresses. A protocol contributes no code, by design. ## So the recommendation for a library is usually both, with distinct roles - Publish the Protocol as the *typed contract*: it is what your own functions annotate, it is narrow, and it never forces a dependency. - Publish an abstract base class as an *optional convenience*, carrying the shared implementation and giving implementers who inherit it loud runtime enforcement. Neither one is the canonical definition of the other — they are two views of the same shape, and it is your test suite that keeps them aligned. ## Whatever you publish, publish the behavioural contract with it Neither mechanism can express "must not truncate, must be idempotent, must not block". A documented contract plus a **conformance test suite** implementers can run against their own class is what makes an extension point real, and it is the part a lead is actually accountable for. The typing choice decides how loudly a *shape* mismatch is reported; only tests decide whether the implementation is right.
- You add a required member to an extension point. How does the blast radius differ between the two?With an abstract base class every implementer breaks loudly at their next instantiation with a TypeError — disruptive but immediate and precise. With a Protocol nothing breaks at runtime; implementations silently stop satisfying the annotation and only a static checker run reports it, so the practical failure is behavioural drift. Either way it is a breaking change and belongs in a major version with a migration note.
- Your library must validate a user-supplied object at the boundary before trusting it. Does that settle the choice?It settles the runtime half. You need a nominal base, a virtual registration, or a runtime-checkable protocol, because a plain Protocol contributes nothing at runtime. Note what an instance check actually buys: presence of members, not signatures and not behaviour, so it screens out obviously wrong objects rather than proving conformance.
- How do you keep a published Protocol and its companion abstract base class from drifting apart?Test it. Keep a test that binds an instance of the abstract base's reference implementation to a variable annotated with the protocol, so a checker fails when either side moves, and run the shared conformance suite against both. Some teams go further and have the base class inherit the protocol explicitly, which makes a checker verify the relationship at the definition.
saying these in an interview costs you the question
- Picks one style as universally correct
- Ignores whether consumers run a type checker at all
- Assumes Protocols give runtime enforcement
- Forgets abstract base classes can ship shared implementation
- Treats adding a Protocol member as non-breaking
- Expects the type system to encode behavioural contracts