Lesson 2: Contracts
Inheritance, with its costs stated plainly
Inheritance lets a class reuse and extend another class. It is the first tool most people are taught and the one most often misapplied.
class BaseProvider:
def __init__(self, api_key: str) -> None:
self.api_key = api_key
self.client = httpx.Client()
def generate(self, prompt: str) -> str:
response = self.client.post(self.url, json=self.build_payload(prompt))
return self.parse(response.json())
def build_payload(self, prompt: str) -> dict:
return {"prompt": prompt}
def parse(self, data: dict) -> str:
return data["text"]
class FastProvider(BaseProvider):
url = "https://fast.example/generate"
def parse(self, data: dict) -> str:
return data["output"]["content"]
This looks economical, and here is what it costs.
The base class becomes an interface you cannot change. Once three subclasses exist, altering generate risks breaking all of them in ways the type checker cannot see, because the coupling runs through inherited behaviour rather than through explicit calls.
Behaviour becomes hard to locate. Reading FastProvider, you cannot see what generate does without opening the parent. With two levels of inheritance you are reading three files to understand one call, and the effective code is an interleaving of all of them.
The hierarchy stops fitting. A provider that streams, or needs two requests, or authenticates differently does not fit the template. The usual responses are a flag parameter in the base class, an override that ignores most of the inherited work, or a second base class, and all three make the situation worse.
Subclasses become coupled to implementation details. If build_payload is called by generate, a subclass overriding it depends on when and how often the base calls it. That is not part of any documented contract, and changing it silently breaks subclasses.
None of this means inheritance is forbidden. It works well when the relationship is genuinely a specialisation, the hierarchy is one level deep, and the base class exists to define a contract rather than to share implementation. The exception hierarchy from Module 5 is a good example: RateLimitError really is a kind of ProviderError, the hierarchy is shallow, and the base classes carry no behaviour worth inheriting.
The rule to carry forward: inherit to declare a contract, not to reuse code. For code reuse, use composition, covered later in this lesson.
ABC and abstractmethod
An abstract base class defines a contract that subclasses must fulfil, and refuses to be instantiated itself.
from abc import ABC, abstractmethod
class LLMProvider(ABC):
"""The interface every model provider must implement."""
@abstractmethod
def generate(self, messages: list[Message], *, model: str) -> Completion:
"""Generate a completion for the given messages."""
@abstractmethod
def embed(self, texts: list[str], *, model: str) -> list[list[float]]:
"""Return an embedding vector for each input text."""
Two things this gives you. Instantiating LLMProvider directly raises TypeError, and so does instantiating a subclass that forgot to implement embed. That failure happens at construction rather than at the moment the missing method is called, which is much earlier and much clearer.
The declaration also documents the contract in one place. A new provider implementation has an explicit list of what it must supply.
The cost is that ABCs require inheritance, which brings the coupling described above. A class must inherit from LLMProvider to satisfy it, so you cannot make a third-party class conform without wrapping it, and every implementation is bound to your base class.
Use an ABC when you own all the implementations, when you want a shared default implementation for some methods, and when failing loudly at construction is valuable. That last point is real: an ABC catches a missing method immediately, whereas the alternative below catches it only when the type checker runs.
Protocol and structural typing
A Protocol describes a shape rather than a lineage. Any class with matching methods satisfies it, with no inheritance and no import.
from typing import Protocol
class LLMProvider(Protocol):
"""Anything that can generate and embed, regardless of its ancestry."""
def generate(self, messages: list[Message], *, model: str) -> Completion: ...
def embed(self, texts: list[str], *, model: str) -> list[list[float]]: ...
class FastProvider: # inherits nothing
def generate(self, messages: list[Message], *, model: str) -> Completion: ...
def embed(self, texts: list[str], *, model: str) -> list[list[float]]: ...
def run_extraction(provider: LLMProvider, docs: list[Document]) -> list[Extraction]:
...
run_extraction(FastProvider(), documents) # accepted, mypy verifies the shape
This is structural typing: the type checker verifies that FastProvider has methods with matching signatures, and that is the whole requirement.
Why it is often the better answer.
Implementations do not depend on your interface. A provider class can be written without importing anything of yours, which matters when the implementation is third-party or lives in another package.
Test doubles become trivial. A fake provider is any class with the right two methods, with no base class to inherit and no abstract methods to stub out.
class FakeProvider:
def __init__(self, canned: Completion) -> None:
self._canned = canned
def generate(self, messages: list[Message], *, model: str) -> Completion:
return self._canned
def embed(self, texts: list[str], *, model: str) -> list[list[float]]:
return [[0.0] * 8 for _ in texts]
The dependency direction is right. With an ABC, implementations depend on your interface. With a Protocol, your interface describes what you need and implementations are unaware of it, which is the direction that survives refactoring.
The cost is that conformance is checked statically. A class missing a method is caught by mypy, not at runtime, so a codebase without type checking gets no benefit at all. This is why the strict mypy setting from Module 1 has been present since the beginning.
[IMAGE PROMPT M7-1
Purpose: Contrast the dependency direction and coupling of inheritance, abstract base classes, and protocols.
Visual type: Three-panel comparison of dependency arrows.
Prompt: A clean educational comparison with three panels side by side, each showing boxes connected by dependency arrows. The left panel is headed "Inheritance for reuse" and shows a box labelled "BaseProvider (has behaviour)" at the top with three boxes beneath it labelled "FastProvider", "CheapProvider", and "LocalProvider", each connected upward by an arrow labelled "inherits behaviour". A caption beneath reads "subclasses coupled to base implementation". The middle panel is headed "ABC as contract" and shows a box labelled "LLMProvider (abstract)" at the top with the same three implementation boxes beneath, arrows labelled "must inherit". A caption beneath reads "implementations depend on your interface". The right panel is headed "Protocol" and shows a box labelled "LLMProvider (shape)" positioned to the left, with the same three implementation boxes on the right and no connecting arrows between them; instead a single dashed arrow points from the Protocol box toward the group, labelled "checked structurally by mypy". A caption beneath reads "implementations know nothing about your interface".
Required elements: Three headed panels, the same three implementation names in each, solid inheritance arrows in the first two panels, absence of inheritance arrows plus one dashed checking arrow in the third, a caption under each panel.
Style: Clean educational illustration, professional, uncluttered, high contrast, flat vector, consistent box sizing across panels.
Layout: Three equal panels side by side separated by thin vertical dividers, each reading top to bottom except the right panel which reads left to right.
Text labels: "Inheritance for reuse", "ABC as contract", "Protocol", "BaseProvider (has behaviour)", "LLMProvider (abstract)", "LLMProvider (shape)", "FastProvider", "CheapProvider", "LocalProvider", "inherits behaviour", "must inherit", "checked structurally by mypy", "subclasses coupled to base implementation", "implementations depend on your interface", "implementations know nothing about your interface".
Aspect ratio: 16:9
Accessibility: Distinguish the three approaches through arrow presence, line style, and text captions rather than colour alone.
Avoid: UML formality, code screenshots, decorative icons, tiny text, logos, watermarks.
Alt text: Three-panel comparison showing inheritance coupling subclasses to base implementation, an abstract base class requiring implementations to depend on your interface, and a protocol checked structurally so implementations know nothing about the interface.
END IMAGE PROMPT]
Choosing between them. Use a Protocol by default for interfaces your code consumes, such as a provider, a repository, or a cache. Use an ABC when you own every implementation and want a shared base or runtime enforcement. Using both is also legitimate: a Protocol declaring what your code needs, and an ABC providing a convenient base for your own implementations that happens to satisfy it.
Composition over inheritance
Composition means an object holds other objects and delegates to them, rather than inheriting from them.
Here is the inheritance version from earlier, refactored. Start by naming what was actually being shared: HTTP transport, retry policy, and circuit breaking. None of those is provider-specific.
class HTTPTransport:
"""Handles connection reuse, timeouts, retries, and circuit breaking."""
def __init__(self, *, timeout: httpx.Timeout, breaker: CircuitBreaker) -> None:
self._client = httpx.Client(timeout=timeout)
self._breaker = breaker
@with_retry(max_attempts=5)
def post_json(self, url: str, payload: dict, headers: dict) -> dict:
self._breaker.before_call()
try:
response = self._client.post(url, json=payload, headers=headers)
except httpx.HTTPError as exc:
self._breaker.record_failure()
raise ProviderError("transport failure") from exc
self._breaker.record_success()
return response.json()
class FastProvider:
"""A provider implementation. Satisfies LLMProvider structurally."""
URL = "https://fast.example/generate"
def __init__(self, api_key: str, transport: HTTPTransport) -> None:
self._api_key = api_key
self._transport = transport # composed, not inherited
def generate(self, messages: list[Message], *, model: str) -> Completion:
payload = {"model": model, "messages": [m.to_dict() for m in messages]}
data = self._transport.post_json(self.URL, payload, self._headers())
return Completion(text=data["output"]["content"], model=model)
def _headers(self) -> dict:
return {"Authorization": f"Bearer {self._api_key}"}
What improved.
Reading FastProvider shows you everything it does. self._transport.post_json is an explicit call to a named object, not inherited behaviour appearing from a parent file.
HTTPTransport is testable on its own and reusable by anything making HTTP calls, including the repository layer later in this module.
A provider that does not use HTTP simply does not take a transport, rather than inheriting one and overriding around it.
And the transport can be swapped per provider, so one with aggressive rate limits gets a different retry policy without a new subclass.
The general shape. Inheritance says "is a kind of". Composition says "has a" or "uses a". When you find yourself overriding a method to avoid inherited behaviour, or adding a flag to the base class so one subclass behaves differently, that is inheritance being used where composition belongs.