In Part 1 we laid out the folder structure for the BookStore API. Now we need to wire the layers together without coupling them. The classic approach is to define an abstract base class that every repository inherits from. It works. But it means every repository file imports from a shared base module, and every time you add a method to the interface, Python’s metaclass machinery will raise a TypeError at import time if any concrete class is missing it.
There is a better tool: typing.Protocol. Introduced in Python 3.8 via PEP 544, Protocols give you structural subtyping — the static typing community’s name for duck typing. A class satisfies a Protocol if it has the right methods, regardless of its inheritance chain.
The Problem with ABCs
Abstract Base Classes are Python’s original answer to interface definitions. Here is what a BookRepository ABC looks like:
# src/repositories/base.py — the ABC approachfrom abc import ABC, abstractmethodfrom src.schemas.book import BookCreate, BookResponse
class BookRepositoryBase(ABC): @abstractmethod async def get_by_id(self, book_id: int) -> BookResponse | None: ...
@abstractmethod async def list_all(self, *, limit: int = 50, offset: int = 0) -> list[BookResponse]: ...
@abstractmethod async def create(self, data: BookCreate) -> BookResponse: ...
@abstractmethod async def delete(self, book_id: int) -> bool: ...Now every concrete repository must inherit from BookRepositoryBase. The problem compounds when you add test fakes:
# tests/fakes.py — ABC forces inheritance even in testsfrom src.repositories.base import BookRepositoryBase
class FakeBookRepository(BookRepositoryBase): # Your test helper now depends on production code imports ...Your test file imports BookRepositoryBase from production code. If that base class imports src.schemas.book which imports src.db.session which tries to connect to a database — your test setup has a hidden transitive dependency on database infrastructure.
| ABC Approach | Protocol Approach | |
|---|---|---|
| Coupling | Concrete class must inherit from base | No inheritance required — zero coupling |
| Testability | Test fakes import production base class | Fakes are standalone classes with no imports |
| Import overhead | All subclasses load the entire ABC module tree | Protocol only used by type checkers — no runtime cost |
| Runtime check | `isinstance()` works via ABCMeta registration | `isinstance()` works via @runtime_checkable |
| Adding methods | All subclasses must implement or raise TypeError | Missing methods caught only at type-check time |
Protocols: Structural Subtyping
A Protocol defines a set of methods. Any class that has those methods satisfies the Protocol — at type-check time, with no runtime coupling.
Here is the BookRepositoryProtocol for the BookStore API:
from typing import Protocol, runtime_checkablefrom src.schemas.book import BookCreate, BookResponse
@runtime_checkableclass BookRepositoryProtocol(Protocol): async def get_by_id(self, book_id: int) -> BookResponse | None: ... async def list_all(self, *, limit: int = 50, offset: int = 0) -> list[BookResponse]: ... async def create(self, data: BookCreate) -> BookResponse: ... async def delete(self, book_id: int) -> bool: ...The ... (ellipsis) bodies are intentional — the Protocol only specifies the signature, not the implementation. The @runtime_checkable decorator adds isinstance() support for debugging.
Now the concrete implementation does not import from the Protocol file at all:
from sqlalchemy.ext.asyncio import AsyncSessionfrom sqlalchemy import selectfrom src.models.book import Bookfrom src.schemas.book import BookCreate, BookResponse
class BookRepository: def __init__(self, session: AsyncSession) -> None: self._session = session
async def get_by_id(self, book_id: int) -> BookResponse | None: result = await self._session.execute( select(Book).where(Book.id == book_id) ) book = result.scalar_one_or_none() return BookResponse.model_validate(book) if book else None
async def list_all(self, *, limit: int = 50, offset: int = 0) -> list[BookResponse]: result = await self._session.execute( select(Book).limit(limit).offset(offset) ) return [BookResponse.model_validate(b) for b in result.scalars()]
async def create(self, data: BookCreate) -> BookResponse: book = Book(**data.model_dump()) self._session.add(book) await self._session.commit() await self._session.refresh(book) return BookResponse.model_validate(book)
async def delete(self, book_id: int) -> bool: book = await self._session.get(Book, book_id) if not book: return False await self._session.delete(book) await self._session.commit() return TrueBookRepository has no base class and no import of protocols.py. The type checker — mypy or pyright — verifies at check time that BookRepository satisfies BookRepositoryProtocol wherever the Protocol type is used. If you miss a method, the type checker reports it. The runtime never sees the Protocol.
Python 3.12 TypeVar Bounds with Protocols
Protocols compose naturally with generics. Here is a generic paginate function that accepts any repository satisfying the Protocol:
from typing import TypeVarfrom src.repositories.protocols import BookRepositoryProtocolfrom src.schemas.book import BookResponse
Repo = TypeVar("Repo", bound=BookRepositoryProtocol)
async def paginate(repo: Repo, page: int, size: int = 20) -> list[BookResponse]: return await repo.list_all(limit=size, offset=page * size)The TypeVar bound means the type checker knows repo has all the methods of BookRepositoryProtocol. list_all is callable on repo — no casting needed.
In Python 3.12 you can write this more concisely with the type statement for type aliases, though the TypeVar pattern remains the idiomatic choice for bounded generics.
Using the Same Interface Three Ways
Here is the same service function written with each approach. The Protocol version is cleanest because the service’s only dependency is the Protocol — not the concrete implementation and not the ABC module:
# src/services/book_service.py — Protocol approachfrom src.repositories.protocols import BookRepositoryProtocolfrom src.schemas.book import BookResponse
class BookService: def __init__(self, repo: BookRepositoryProtocol) -> None: self._repo = repo
async def get_book(self, book_id: int) -> BookResponse | None: return await self._repo.get_by_id(book_id)The service depends on the Protocol, not on any concrete class. Swap BookRepository for FakeBookRepository in tests — the service never knows.
# src/services/book_service.py — ABC approachfrom src.repositories.base import BookRepositoryBase # production import in servicefrom src.schemas.book import BookResponse
class BookService: def __init__(self, repo: BookRepositoryBase) -> None: self._repo = repo
async def get_book(self, book_id: int) -> BookResponse | None: return await self._repo.get_by_id(book_id)Works, but now the service imports the ABC module. Any transitive dependency in that module tree affects your service’s import cost.
# src/services/book_service.py — no interfacefrom src.repositories.book_repository import BookRepository # concrete importfrom src.schemas.book import BookResponse
class BookService: def __init__(self, repo: BookRepository) -> None: self._repo = repo
async def get_book(self, book_id: int) -> BookResponse | None: return await self._repo.get_by_id(book_id)Now testing requires either a real AsyncSession or monkey-patching. Every test that touches BookService is an integration test whether you intended it or not.
Testing with Fake Implementations
Protocols enable simple in-memory fakes without mocking frameworks. The fake implements the same method signatures as the Protocol — that is the entire contract:
from src.schemas.book import BookCreate, BookResponse
class FakeBookRepository: def __init__(self) -> None: self._books: dict[int, BookResponse] = {} self._next_id = 1
async def get_by_id(self, book_id: int) -> BookResponse | None: return self._books.get(book_id)
async def list_all(self, *, limit: int = 50, offset: int = 0) -> list[BookResponse]: items = list(self._books.values()) return items[offset : offset + limit]
async def create(self, data: BookCreate) -> BookResponse: book = BookResponse(id=self._next_id, **data.model_dump()) self._books[self._next_id] = book self._next_id += 1 return book
async def delete(self, book_id: int) -> bool: if book_id not in self._books: return False del self._books[book_id] return TrueA unit test for BookService becomes trivial:
import pytestfrom tests.fakes import FakeBookRepositoryfrom src.services.book_service import BookServicefrom src.schemas.book import BookCreate
@pytest.mark.asyncioasync def test_get_book_returns_none_for_missing_id(): service = BookService(repo=FakeBookRepository()) result = await service.get_book(book_id=999) assert result is None
@pytest.mark.asyncioasync def test_create_book_returns_response_with_id(): repo = FakeBookRepository() service = BookService(repo=repo) created = await service.get_book(book_id=1) assert created is None # nothing created yet
# Direct repo create to set up state book = await repo.create(BookCreate(title="Fluent Python", isbn="9781492056355", price="49.99")) assert book.id == 1 assert book.title == "Fluent Python"No database. No session. No mock framework. Just a Python class with the right methods.
Key Takeaways
- Protocols give you structural subtyping: a class satisfies a Protocol by having the right methods — no inheritance required
- ABCs couple your test fakes to production imports:
FakeBookRepository(BookRepositoryBase)is an inheritance link you do not need - Services should depend on Protocols, not concrete classes: the dependency inversion principle applied cleanly
@runtime_checkableis optional: static type checkers enforce Protocol conformance without it; add it only forisinstance()checks at runtime- TypeVar bounds preserve type information: generic functions that accept any Protocol-satisfying type remain fully typed without casts