Protocol or ABC for a pluggable interface?
I was designing the provider boundary with a student for a CLI tool that talks to two different image-generation backends.
Same inputs from the user, two different SDKs underneath. We were figuring out how best to define the shared contract: an abstract base class, or a typing.Protocol? This article has the answer.
The setup: one contract, two backends
The CLI should not know which provider it's talking to. It calls something like submit(request) and gets a typed result back. Each provider translates that into its own SDK calls. This is the classic case for a shared interface.
My reflex is to reach for abstract base classes (ABCs) here. You write a base class with @abstractmethods, every provider inherits from it, and Python refuses to instantiate a subclass that does not implement one or more abstract methods at runtime.
It works. I've used it for Pybites search and as part of the repository pattern.
However, there is a downside: required inheritance. When the backends are pluggable, I don't own every implementation. A third-party provider shouldn't have to import my base class just to count as a valid provider. Enter protocols.
Why Protocol fits better here
typing.Protocol uses structural typing. A class satisfies the interface by having the right methods with the right signatures, not by inheriting from anything.
Quick example:
from typing import Protocol
from pixgen.models import GenRequest, GenResult # your Pydantic models
class Provider(Protocol):
def submit(self, request: GenRequest) -> GenResult: ...
class OpenAIProvider: # note: no inheritance
def submit(self, request: GenRequest) -> GenResult:
...
def run(provider: Provider, request: GenRequest) -> GenResult:
return provider.submit(request)
OpenAIProvider does not inherit from Provider (like an ABC would require), yet the type checker (ty, mypy, pyrefly) will accept it wherever a Provider is expected.
If you miss a method, get the signature wrong, or return the wrong type, a static type checker flags it before you run the code. Python itself won't enforce the protocol at runtime, so the checker is doing the work here. No forced inheritance, and you catch mistakes before you ship.
Three things this buys me:
- No coupling for outside implementers. A plugin author writes a class with a
submitmethod. They don't import my package to prove conformance. The contract lives in the shape, not the family tree. - Providers evolve independently. As long as a class keeps the right method shape, its internals can change freely, and it never inherits methods it doesn't use. An ABC with several abstract methods forces every provider to implement all of them, even as empty stubs.
- It reads as composition, not hierarchy. The provider is a value I pass into functions.
Related articles: How an AI expense agent is actually structured and Why Rust makes you import a trait to use its methods.
When I'd still go with ABCs
If the Protocol is only describing the provider contract, it's a great fit. The moment providers need to share concrete behavior, an ABC is the better tool:
from abc import ABC, abstractmethod
class BaseProvider(ABC):
def submit(self, request: GenRequest) -> GenResult:
self._validate(request) # shared, concrete
return self._call_api(request) # provider-specific
@abstractmethod
def _call_api(self, request: GenRequest) -> GenResult: ...
If every provider runs similar logic and only differs in the actual API call, inheritance is the right tool for shared implementation. Protocol only describes shape, not shared behavior.
Why not use both?
In practice ABCs and protocols aren't mutually exclusive. You can use Protocol for the public plugin boundary and an internal ABC that providers share.
A plugin ecosystem doesn't force everyone to depend on your base class. The contract lives in the method shape, not the class hierarchy. A Protocol defines the contract, an ABC on the other hand, is just an implementation detail my providers happen to reuse.
This is also the split we've architected in our agentic AI cohort. The expense-tracker app defines an Assistant Protocol for LLM providers, so OpenAIAssistant and GroqAssistant conform by shape without inheriting from anything. Swapping in a new SDK never touches the base. Storage on the other hand uses an ABC and the repository pattern (ExpenseRepository), because here we want the in-memory and database versions to use the exact same contract.
How to decide which pattern to use
Default to Protocol: it's the lighter contract and it doesn't force inheritance on anyone. Switch to ABC when providers need shared implementation, or when you want Python itself to enforce the abstract methods at instantiation time.
For a plugin "conform by shape" is often a better match than "conform by inheritance". I wrote up the fuller typing.Protocol walkthrough a while back on Pybites' blog. There you can read more in detail how the type checker enforces protocols, how to enhance them with @typing.runtime_checkable, and a bit more on Python's duck typing philosophy.
Shipping fast with AI but don't fully trust the code? I help developers 1:1 turn AI-built apps into something they understand and own. How it works →