Free Ebook cover Python Data Modeling in Practice: Dataclasses, Pydantic, and Type Hints

Python Data Modeling in Practice: Dataclasses, Pydantic, and Type Hints

New course

14 pages

Protocols and Structural Typing for Layer Decoupling

Capítulo 7

Estimated reading time: 11 minutes

+ Exercise

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 App

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] = user

Neither 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 sink

Prefer 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):     pass

This 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 = clock

This 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.

Now answer the exercise about the content:

How do Protocols support layer decoupling in a Python application?

You are right! Congratulations, now go to the next page

You missed! Try again.

Protocols define minimal contracts based on required members, enabling structural typing. Application code depends on these stable ports, while infrastructure supplies any implementation that matches the interface shape without inheritance.

Next chapter

Modeling Collections, Nested Structures, and Optionality

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.