Why Protocols Matter for Layer Decoupling
In a layered Python application (domain, application/service, infrastructure, presentation), the most common source of accidental coupling is not imports—it is assumptions. A service assumes a repository has a certain method name, a controller assumes a logger has a certain interface, a domain workflow assumes a clock behaves a certain way. When those assumptions are informal, they spread as “tribal knowledge” and are enforced only at runtime.
Protocols (from typing) let you express those assumptions as explicit, checkable contracts without forcing inheritance or concrete base classes. This enables structural typing: an object is considered compatible if it has the required attributes/methods with compatible types, regardless of its class hierarchy. This is a strong fit for layer decoupling because your domain/application layer can depend on small, stable interfaces, while infrastructure provides implementations that “just match” the shape.
Structural Typing vs Nominal Typing (and Why You Want Both)
Python is nominal at runtime: isinstance(x, SomeClass) checks class identity. Static type checkers (mypy, pyright) can also do nominal typing: a value is compatible if it is an instance of a declared class or subclass. Structural typing is different: compatibility is based on the presence and types of members.
Protocols are the standard way to express structural types. They are especially useful when:
- You want to avoid importing infrastructure types into domain/application code.
- You want to accept “anything that behaves like X” (duck typing), but still want static checking.
- You want to define minimal interfaces (ports) that are stable even as implementations change.
- You want to test with lightweight fakes without building inheritance hierarchies.
Nominal typing is still useful for domain concepts where identity matters or where you want explicit subtype relationships. Protocols complement that by describing behavior at boundaries.
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
Defining a Protocol: The Smallest Useful Contract
A good protocol is small and focused. If you find yourself adding many unrelated methods, you are likely describing a concrete class rather than a boundary. Start by writing the application use case and identify what it needs from collaborators.
Example: A Use Case That Needs a Repository and a Clock
Suppose an application service needs to load a user, update a field, and persist it. It also needs “now” for auditing. Instead of depending on a concrete repository class and datetime.now(), define protocols.
from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timezone from typing import Protocol, runtime_checkable class User: def __init__(self, user_id: str, email: str, updated_at: datetime | None = None): self.user_id = user_id self.email = email self.updated_at = updated_at class UserRepository(Protocol): def get(self, user_id: str) -> User | None: ... def save(self, user: User) -> None: ... class Clock(Protocol): def now(self) -> datetime: ... @dataclass class UpdateEmail: repo: UserRepository clock: Clock def execute(self, user_id: str, new_email: str) -> None: user = self.repo.get(user_id) if user is None: raise LookupError(f"User {user_id} not found") user.email = new_email user.updated_at = self.clock.now() self.repo.save(user)Notice what is missing: no imports from infrastructure, no base classes, no framework types. The use case depends only on protocols and domain objects.
Providing Implementations in Infrastructure
Any class that matches the protocol shape is accepted by static type checkers.
from datetime import datetime, timezone class SystemClock: def now(self) -> datetime: return datetime.now(timezone.utc) class InMemoryUserRepo: def __init__(self): self._items: dict[str, User] = {} def get(self, user_id: str) -> User | None: return self._items.get(user_id) def save(self, user: User) -> None: self._items[user.user_id] = userNeither SystemClock nor InMemoryUserRepo inherits from anything. They simply satisfy the protocol.
Step-by-Step: Refactoring Toward Protocol-Based Boundaries
If your code currently passes around concrete classes, you can introduce protocols incrementally. A practical refactoring sequence:
Step 1: Identify the boundary and the direction of dependency
Pick a use case or service in the application layer. List what it calls on collaborators (methods, properties). Those calls define the boundary.
Step 2: Extract a protocol in the application layer
Create a Protocol with only the members the use case needs. Keep it minimal.
from typing import Protocol class EmailSender(Protocol): def send(self, to: str, subject: str, body: str) -> None: ...Step 3: Type your service with the protocol
Replace the concrete type annotation with the protocol.
from dataclasses import dataclass @dataclass class InviteUser: email_sender: EmailSender def execute(self, email: str) -> None: self.email_sender.send( to=email, subject="Welcome", body="Here is your invite...", )Step 4: Adapt infrastructure implementations to match
Most of the time, you just ensure method names and signatures match. If an existing library client doesn’t match, create a small adapter class in infrastructure.
class SmtpClient: def deliver(self, recipient: str, title: str, content: str) -> None: ... class SmtpEmailSender: def __init__(self, client: SmtpClient): self._client = client def send(self, to: str, subject: str, body: str) -> None: self._client.deliver(recipient=to, title=subject, content=body)Step 5: Use fakes in tests with zero inheritance
Because the protocol is structural, a tiny fake is enough.
class FakeEmailSender: def __init__(self): self.sent: list[tuple[str, str, str]] = [] def send(self, to: str, subject: str, body: str) -> None: self.sent.append((to, subject, body))Static type checkers will accept FakeEmailSender as EmailSender because it has a compatible send method.
Runtime Checking: When (and When Not) to Use @runtime_checkable
Protocols are primarily for static typing. At runtime, isinstance(x, SomeProtocol) is not allowed unless the protocol is marked with @runtime_checkable. Even then, runtime checks are limited: they only verify attribute presence, not full type compatibility.
Use runtime checking sparingly, typically at system boundaries where you want defensive validation of plugin objects or dependency injection configuration.
from typing import Protocol, runtime_checkable @runtime_checkable class MetricsSink(Protocol): def incr(self, name: str, value: int = 1) -> None: ... def wire_metrics(sink: object) -> MetricsSink: if not isinstance(sink, MetricsSink): raise TypeError("sink does not implement MetricsSink") return sinkPrefer static checking for internal code. Runtime protocol checks can give a false sense of safety because they cannot validate argument/return types deeply.
Designing Protocols for Stability: Narrow, Explicit, and Layer-Oriented
Protocols become part of your architecture. Treat them as stable “ports” that change less frequently than implementations. A few practical guidelines:
- Keep protocols narrow: define only what the consumer needs. If another consumer needs more, define a second protocol or extend carefully.
- Name protocols by role:
UserRepository,Clock,EmailSender,UnitOfWork. Avoid naming them after technologies (SqlAlchemyRepo). - Prefer domain/application vocabulary: method names should reflect intent (
get,save,publish) rather than database operations (select_by_id). - Avoid “god protocols”: if a protocol starts to look like a full ORM session, you are leaking infrastructure concerns upward.
Protocols with Properties and Attributes
Protocols can specify attributes and read-only properties. This is useful when you want to accept objects that expose data without forcing a specific class.
from typing import Protocol class HasRequestId(Protocol): @property def request_id(self) -> str: ... def log_with_request_id(ctx: HasRequestId, message: str) -> str: return f"[{ctx.request_id}] {message}"Any object with a request_id property (or attribute compatible with a read-only property) can be passed. This is handy for cross-cutting concerns like tracing contexts.
Protocol Composition and Optional Capabilities
Sometimes you need a base capability and an optional extension. You can model this by defining multiple protocols and using overloads or runtime branching.
from typing import Protocol class Reader(Protocol): def read(self, key: str) -> bytes | None: ... class Writer(Protocol): def write(self, key: str, data: bytes) -> None: ... class ReadWriteStore(Reader, Writer, Protocol): passThis lets you type a function to accept only what it needs:
def load_avatar(store: Reader, user_id: str) -> bytes | None: return store.read(f"avatar:{user_id}") def save_avatar(store: Writer, user_id: str, data: bytes) -> None: store.write(f"avatar:{user_id}", data)Layer decoupling improves because a read-only use case cannot accidentally depend on write methods.
Protocols for Unit of Work and Transaction Boundaries
A common layering challenge is transaction management. If application code calls session.commit() directly, it becomes coupled to a specific persistence technology. A protocol can represent the transaction boundary.
from typing import Protocol class UnitOfWork(Protocol): def __enter__(self) -> "UnitOfWork": ... def __exit__(self, exc_type, exc, tb) -> bool | None: ... def commit(self) -> None: ... def rollback(self) -> None: ...Then a use case can be written against UnitOfWork plus the repositories it exposes. There are two common shapes:
- UoW exposes repositories as attributes (e.g.,
uow.users). - Use case receives repositories separately and uses UoW only for transaction control.
Here is the “UoW exposes repositories” approach using protocols for both:
from typing import Protocol class UserRepository(Protocol): def get(self, user_id: str) -> User | None: ... def save(self, user: User) -> None: ... class UserUnitOfWork(Protocol): users: UserRepository def __enter__(self) -> "UserUnitOfWork": ... def __exit__(self, exc_type, exc, tb) -> bool | None: ... def commit(self) -> None: ... class ChangeEmail: def __init__(self, uow: UserUnitOfWork, clock: Clock): self._uow = uow self._clock = clock def execute(self, user_id: str, new_email: str) -> None: with self._uow as uow: user = uow.users.get(user_id) if user is None: raise LookupError(user_id) user.email = new_email user.updated_at = self._clock.now() uow.users.save(user) uow.commit()Infrastructure can implement UserUnitOfWork with SQLAlchemy, Django ORM, or a custom transaction manager, without changing the use case.
Protocols and Dependency Injection Without Frameworks
Protocols work well with simple, explicit dependency injection: pass dependencies via constructors or function parameters. The key is that the type annotations describe behavior, not concrete classes. You can wire dependencies in a composition root (a module that knows about infrastructure) while keeping application code clean.
def build_update_email_service() -> UpdateEmail: repo = InMemoryUserRepo() clock = SystemClock() return UpdateEmail(repo=repo, clock=clock)In a larger system, the composition root might create database connections, HTTP clients, and adapters. The application layer still sees only protocols.
Working with Third-Party Types Using Protocols
Protocols are particularly useful when you want to accept objects from third-party libraries without importing them everywhere. For example, you might want “something that can make HTTP requests” without tying your application to a specific client.
from typing import Protocol class HttpResponse(Protocol): @property def status_code(self) -> int: ... def json(self) -> dict: ... class HttpClient(Protocol): def get(self, url: str, timeout: float) -> HttpResponse: ...You can then write application code against HttpClient. In infrastructure, you can adapt requests, httpx, or a custom client to match. This reduces the blast radius if you switch libraries.
Generic Protocols: Stronger Reuse Without Losing Precision
Protocols can be generic, which is useful for repositories and serializers. A generic protocol can express “a repository of T” without hardcoding a specific entity type.
from typing import Protocol, TypeVar, Generic T = TypeVar("T") class Repository(Protocol[T]): def get(self, id: str) -> T | None: ... def save(self, item: T) -> None: ...Then you can annotate dependencies precisely:
class UpdateEmail: def __init__(self, repo: Repository[User], clock: Clock): self.repo = repo self.clock = clockThis keeps your ports reusable while still letting type checkers catch mismatches (e.g., accidentally passing a repository of a different type).
Common Pitfalls and How to Avoid Them
Over-specifying the protocol
If you include methods that the consumer does not need, you force implementations (including fakes) to grow unnecessarily. This increases coupling and makes refactoring harder. Keep protocols consumer-driven.
Leaking infrastructure concerns into the protocol
A protocol like def execute_sql(self, query: str) -> list[tuple]: ... is a persistence detail. Prefer intent-based methods that align with your application’s needs.
Using runtime protocol checks as a substitute for tests
@runtime_checkable can help validate plugin wiring, but it cannot guarantee semantic correctness. Still test behavior.
Confusing “structural compatibility” with “behavioral substitutability”
Two objects can match the same method signatures but behave differently (e.g., one repository caches, another hits the network). Protocols ensure shape, not semantics. Document expectations in docstrings and enforce critical behavior with tests.
Practical Pattern: Ports and Adapters with Protocols
A useful way to think about protocols in layered design is “ports and adapters”:
- Ports: protocols defined in the application layer that describe what the application needs (repositories, message buses, clocks, id generators).
- Adapters: infrastructure classes that implement those protocols by talking to databases, queues, HTTP APIs, or the filesystem.
In practice:
- Define protocols close to the use cases that consume them.
- Implement adapters in infrastructure modules.
- Wire everything in a composition root.
- Use fakes in tests that satisfy the same protocols.
This approach keeps dependencies pointing inward: application code does not import infrastructure, yet remains fully type-checked and explicit about what it needs.