Tutorial

Clean Code Python: Type Safety with Protocols Over ABCs

Python's ABC machinery adds inheritance overhead you do not need. Protocols give you structural subtyping — type-safe duck typing with zero coupling between your repository interfaces and implementations.

Tin Dang avatar
Tin Dang
Abstract geometric shapes fitting together precisely like puzzle pieces, representing structural type matching

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 approach
from abc import ABC, abstractmethod
from 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 tests
from 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 ApproachProtocol 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:

src/repositories/protocols.py
from typing import Protocol, runtime_checkable
from src.schemas.book import BookCreate, BookResponse
@runtime_checkable
class 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:

src/repositories/book_repository.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from src.models.book import Book
from 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 True

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

src/repositories/utils.py
from typing import TypeVar
from src.repositories.protocols import BookRepositoryProtocol
from 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 approach
from src.repositories.protocols import BookRepositoryProtocol
from 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 approach
from src.repositories.base import BookRepositoryBase # production import in service
from 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 interface
from src.repositories.book_repository import BookRepository # concrete import
from 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:

tests/fakes.py
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 True

A unit test for BookService becomes trivial:

tests/test_book_service.py
import pytest
from tests.fakes import FakeBookRepository
from src.services.book_service import BookService
from src.schemas.book import BookCreate
@pytest.mark.asyncio
async 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.asyncio
async 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_checkable is optional: static type checkers enforce Protocol conformance without it; add it only for isinstance() checks at runtime
  • TypeVar bounds preserve type information: generic functions that accept any Protocol-satisfying type remain fully typed without casts
0

Next in this series

Clean Code Python: The Repository Pattern with SQLAlchemy

Continue reading