No history yet

Architectural Protocols

Beyond Inheritance

In large software systems, rigid class hierarchies can become a liability. When you tie components together using direct inheritance, you create tight coupling. A change in a base class can ripple through every subclass, making the system brittle and difficult to maintain. We need a way to define interfaces based on what an object can do, not where it came from.

This is where structural subtyping comes in. Instead of checking if an object is an instance of a specific class (nominal subtyping), we check if it has the required methods and attributes. This is often called “duck typing”: if it walks like a duck and quacks like a duck, it’s a duck. Python’s Protocol formalizes this concept, giving us the power of duck typing with the safety of static type checking.

from typing import Protocol

# Define a 'contract' using Protocol
class SupportsQuacking(Protocol):
    def quack(self) -> str:
        ...

# This function doesn't care about the object's class,
# only that it fulfills the SupportsQuacking contract.
def make_it_quack(obj: SupportsQuacking):
    print(obj.quack())

class Duck:
    def quack(self) -> str:
        return "Quack!"

class Car:
    def honk(self) -> str:
        return "Honk!"

# A Duck can be used because it has a quack() method.
make_it_quack(Duck())

# A static type checker would flag this line as an error.
# make_it_quack(Car())

Notice that Duck doesn't inherit from SupportsQuacking. It simply has a quack method that matches the protocol's signature. The type checker understands this structural relationship, allowing Duck to be used wherever SupportsQuacking is expected.

Formalizing Architectural Boundaries

Protocols are perfect for defining the boundaries between different parts of your system, especially for external dependencies like databases or APIs. By defining a protocol for a data repository, you create a clear contract for how the rest of your application will interact with data storage. The application's core logic doesn't need to know or care about the specific database technology being used.

This decouples your business logic from your infrastructure. You can easily swap out a real database for an in-memory version during testing, or migrate from PostgreSQL to another database in the future, without rewriting your application's core.

from typing import Protocol, Optional

# Define a contract for any user repository
class UserRepository(Protocol):
    def get_user_by_id(self, user_id: int) -> Optional[dict]:
        ...

    def save_user(self, user_data: dict) -> None:
        ...

# A production implementation using a database
class PostgresUserRepository:
    def get_user_by_id(self, user_id: int) -> Optional[dict]:
        print(f"Fetching user {user_id} from PostgreSQL...")
        # Real database logic would go here
        return {"id": user_id, "name": "Alice"}

    def save_user(self, user_data: dict) -> None:
        print(f"Saving user to PostgreSQL...")
        # Real database logic would go here

# An in-memory implementation for testing
class InMemoryUserRepository:
    _users: dict[int, dict]

    def __init__(self):
        self._users = {}

    def get_user_by_id(self, user_id: int) -> Optional[dict]:
        print(f"Fetching user {user_id} from memory...")
        return self._users.get(user_id)

    def save_user(self, user_data: dict) -> None:
        print(f"Saving user to memory...")
        self._users[user_data['id']] = user_data

# This service depends on the contract, not the implementation
def process_user(repo: UserRepository, user_id: int):
    user = repo.get_user_by_id(user_id)
    if user:
        print(f"Processing user: {user['name']}")

# We can use either implementation
pg_repo = PostgresUserRepository()
mem_repo = InMemoryUserRepository()

process_user(pg_repo, 1)
process_user(mem_repo, 2)

Runtime Architectural Checks

Static type checkers are great for catching errors during development. But what if you need to verify that an object conforms to a protocol at runtime? This is common in plugin systems, dependency injection frameworks, or any scenario where you're working with objects from less trusted sources.

By default, you can't use isinstance() with a protocol. However, you can enable this behavior with the @runtime_checkable decorator. This tells Python to allow runtime checks against the protocol's structure.

from typing import Protocol, runtime_checkable

@runtime_checkable
class Serializable(Protocol):
    def to_json(self) -> str:
        ...

class User:
    def __init__(self, name: str):
        self.name = name
    
    def to_json(self) -> str:
        return f'{{"name": "{self.name}"}}'

class Product:
    def __init__(self, price: float):
        self.price = price

user = User("Bob")
product = Product(99.99)

print(f"Is user serializable? {isinstance(user, Serializable)}")
print(f"Is product serializable? {isinstance(product, Serializable)}")

Use @runtime_checkable with caution. It adds a small performance overhead because it has to inspect the object's methods and attributes at runtime. For most application logic, static type checking is sufficient.

Keeping Interfaces Lean

The Interface Segregation Principle suggests that clients shouldn't be forced to depend on interfaces they don't use. Large, monolithic protocols can violate this principle. A class might need to implement several unrelated methods just to satisfy a single, oversized protocol.

A better approach is to define multiple, smaller protocols, each with a specific responsibility. A class can then implement only the protocols that are relevant to its function. This makes the system more modular and easier to understand. A single class can conform to multiple protocols, signaling that it can play different roles in different contexts.

from typing import Protocol, List

# A lean protocol for readable data sources
class Readable(Protocol):
    def read(self) -> List[str]:
        ...

# A lean protocol for writeable data sources
class Writeable(Protocol):
    def write(self, data: str) -> None:
        ...

# This class can do both
class FileHandler:
    def read(self) -> List[str]:
        print("Reading from file...")
        return ["line 1", "line 2"]

    def write(self, data: str) -> None:
        print(f"Writing '{data}' to file...")

# A read-only class
class ApiClient:
    def read(self) -> List[str]:
        print("Reading from API...")
        return ["api_data_1", "api_data_2"]

# This function only needs to read data
def print_data(source: Readable):
    for item in source.read():
        print(f"- {item}")

# This function only needs to write data
def log_message(destination: Writeable, message: str):
    destination.write(message)

# Both FileHandler and ApiClient can be used as readable sources
file = FileHandler()
api = ApiClient()

print_data(file)
print_data(api)

# Only FileHandler can be used as a writeable destination
log_message(file, "hello world")

By using narrow, focused protocols, we create flexible components that can be composed in various ways. The print_data function doesn't need to know about writing, and the log_message function doesn't need to know about reading. This separation of concerns is fundamental to building robust, maintainable, and scalable enterprise applications.

Quiz Questions 1/5

What is the core principle of structural subtyping, often referred to as "duck typing"?

Quiz Questions 2/5

How do Protocols help in decoupling an application's core logic from its infrastructure, such as a database?