Skip to content

Pythonic Design: Protocols, ABCs & SOLID

What this lesson gives you

Duck typing, abstract base classes, and the SOLID principles are the design language of senior engineers and consultants.

Estimated time: 22 min read · Part: Object-Oriented Python

Learning objectives

  • Understand structural typing (Protocols) vs nominal typing (ABCs) and when to use each
  • Define and implement abstract base classes with abc.ABC and @abstractmethod
  • State each SOLID principle in one sentence and give a concrete Python example
  • Apply Dependency Inversion to decouple business logic from infrastructure
  • Recognize when a design violates SOLID and how to refactor it

Python is built on duck typing: "if it walks like a duck and quacks like a duck, it's a duck." Code doesn't check "is this object of type X?" — it asks "does this object have a .quack() method?" This structural approach to type checking is powerful and flexible, but it can leave contracts implicit and hard for tools to verify.

Two mechanisms formalize duck-typing contracts: Protocols (PEP 544, Python 3.8+) define structural interfaces that any class satisfying them is compatible with — no inheritance required; Abstract Base Classes (ABCs) define nominal contracts that subclasses must explicitly inherit from and implement. Both have their place; understanding the difference is a senior-level skill.

Protocols: Structural Typing Without Inheritance

A Protocol defines a structural interface: any class that has the required methods and attributes satisfies the protocol — even if it's never imported from, or heard of, the protocol. Type checkers (mypy, pyright) use this for static analysis without requiring class hierarchies. This is the Pythonic way to say "I need something that behaves like X" without coupling to a specific class.

protocols_full.py
from typing import Protocol, runtime_checkable

# Define the interface structurally — no inheritance needed from users
@runtime_checkable   # enables isinstance() checks at runtime
class Serializer(Protocol):
    def dumps(self, data: dict) -> str: ...
    def loads(self, text: str) -> dict: ...

class JSONSerializer:              # does NOT inherit from Serializer
    def dumps(self, data: dict) -> str:
        import json; return json.dumps(data)
    def loads(self, text: str) -> dict:
        import json; return json.loads(text)

class TOMLSerializer:              # also satisfies the protocol structurally
    def dumps(self, data: dict) -> str:
        import tomllib
        ...
    def loads(self, text: str) -> dict:
        ...

# Any class with dumps() and loads() satisfies Serializer
def save_config(config: dict, serializer: Serializer) -> str:
    return serializer.dumps(config)

save_config({"debug": True}, JSONSerializer())    # works
save_config({"debug": True}, TOMLSerializer())    # works

# Runtime check (because of @runtime_checkable)
isinstance(JSONSerializer(), Serializer)    # True
isinstance("not a serializer", Serializer)  # False

# ── Iterable Protocol — the built-in example ─────────────────────────────────
class Countdown:
    """Any class implementing __iter__ and __next__ is Iterable."""
    def __init__(self, n: int):
        self.n = n

    def __iter__(self):
        return self

    def __next__(self):
        if self.n <= 0:
            raise StopIteration
        self.n -= 1
        return self.n + 1

for n in Countdown(5):
    print(n, end=" ")   # 5 4 3 2 1
# No inheritance from any Iterable class — pure structural protocol

5 4 3 2 1

Abstract Base Classes: Enforced Nominal Contracts

An ABC (Abstract Base Class) enforces its contract nominally: subclasses must explicitly inherit from it and must implement all @abstractmethods, or instantiation is refused with TypeError. This is stricter than a Protocol — it prevents "accidental" implementations and documents that the implementer intentionally satisfies the contract. Use ABCs when you want to be explicit and when you want early error detection (at instantiation time, not at first method call).

abcs_full.py
from abc import ABC, abstractmethod
from typing import Iterator

class Storage(ABC):
    """Abstract interface for a key-value store.
    Any concrete implementation must provide get, put, delete, and keys.
    """
    @abstractmethod
    def get(self, key: str) -> bytes:
        """Retrieve value by key. Raises KeyError if not found."""
        ...

    @abstractmethod
    def put(self, key: str, value: bytes) -> None:
        """Store value under key, overwriting if exists."""
        ...

    @abstractmethod
    def delete(self, key: str) -> None:
        """Remove key. Raises KeyError if not found."""
        ...

    @abstractmethod
    def keys(self) -> Iterator[str]:
        """Iterate all stored keys."""
        ...

    # Non-abstract method: shared behavior implemented on top of the abstracts
    def exists(self, key: str) -> bool:
        try:
            self.get(key)
            return True
        except KeyError:
            return False

class MemoryStore(Storage):
    """In-memory store — fast, non-persistent. Good for tests."""
    def __init__(self):
        self._data: dict[str, bytes] = {}

    def get(self, key):
        if key not in self._data:
            raise KeyError(key)
        return self._data[key]

    def put(self, key, value):
        self._data[key] = value

    def delete(self, key):
        if key not in self._data:
            raise KeyError(key)
        del self._data[key]

    def keys(self):
        return iter(self._data.keys())

# class IncompleteStore(Storage):
#     def get(self, key): ...
#     # forgot put, delete, keys
# IncompleteStore()  # TypeError: Can't instantiate abstract class — caught at instantiation!

# Usage — code that works with ANY Storage implementation
def copy_store(source: Storage, dest: Storage) -> int:
    count = 0
    for key in source.keys():
        dest.put(key, source.get(key))
        count += 1
    return count

Protocol vs ABC: badge vs job description

A Protocol is like a job description: if you can do the tasks, you qualify — you don't need to declare it in advance or carry a specific badge. A ABC is like a professional certification: you must explicitly register, pass the requirements, and carry the badge. Protocols are more flexible (any existing class can satisfy them without modification); ABCs are more explicit and enforceable. Use Protocol when working with external code or existing classes; use ABC when designing a hierarchy you own and control.

The SOLID Principles

SOLID is a set of five design principles for object-oriented software, coined by Robert C. Martin. They are the most widely known and discussed principles in professional software design, and every senior engineering interview will probe at least one of them. The key is not to recite the names — it's to give a concrete example of each and explain the practical consequence of violating it.

Letter Principle One sentence Violation signal
S Single Responsibility A class has one reason to change. Class is named with "And": UserManagerAndLogger
O Open/Closed Open to extension, closed to modification. Adding a feature requires editing existing code's switch/if chains
L Liskov Substitution Subtypes must be usable wherever the base type is. Subclass throws exceptions for methods parent doesn't
I Interface Segregation Many small interfaces beat one fat one. Classes implement methods they don't need (raise NotImplementedError)
D Dependency Inversion Depend on abstractions, not concretions. Business logic directly imports a database driver or HTTP library
solid_examples.py
from abc import ABC, abstractmethod
from typing import Protocol

# ── S: Single Responsibility ──────────────────────────────────────────────────
# BEFORE: one class does too much
class OrderProcessorBad:
    def process(self, order): ...
    def send_email(self, order): ...      # email is not "processing"
    def save_to_db(self, order): ...      # DB is not "processing"
    def generate_pdf(self, order): ...    # PDF is not "processing"

# AFTER: each class has one reason to change
class OrderProcessor:
    def __init__(self, repo, notifier):
        self._repo      = repo
        self._notifier  = notifier
    def process(self, order):
        self._repo.save(order)
        self._notifier.notify(order.customer_email, "Order confirmed")

# ── D: Dependency Inversion — the most impactful SOLID principle ──────────────
# BEFORE: business logic directly depends on a concrete database
class OrderRepository:
    def save(self, order):
        import psycopg2
        conn = psycopg2.connect("postgresql://...")  # HARD dependency!
        # ...

# AFTER: business logic depends on an abstraction; infra injects the concrete
class OrderRepo(Protocol):
    def save(self, order) -> None: ...
    def get(self, order_id: int): ...

class PostgresOrderRepo:              # one concrete implementation
    def save(self, order): ...
    def get(self, order_id): ...

class InMemoryOrderRepo:              # test/dev implementation
    def __init__(self): self._db = {}
    def save(self, order): self._db[order.id] = order
    def get(self, order_id): return self._db[order_id]

class OrderService:
    def __init__(self, repo: OrderRepo):    # depends on abstraction
        self._repo = repo

    def place(self, order):
        self._repo.save(order)

# Production
service = OrderService(PostgresOrderRepo())
# Tests — no real DB needed
service = OrderService(InMemoryOrderRepo())

Consulting lens: Dependency Inversion is why systems stay testable

Dependency Inversion is the principle that makes systems testable and swappable. When your OrderService depends on an OrderRepo interface rather than Postgres directly, you can: unit-test it with an in-memory fake (no database required), switch from Postgres to DynamoDB without touching the service, test failure scenarios by injecting a repo that raises errors on demand. In solutions consulting, demonstrating that your architecture decouples business logic from infrastructure — and explaining the Dependency Inversion principle — is a signal that you build systems that can evolve. That's precisely what enterprise clients pay for.

WHY Why does Python have both Protocol and ABC? ABCs (pre-3.8) require explicit inheritance — callers must know about and inherit from the ABC. Protocols (3.8+) enable structural typing: any class with the right methods qualifies, even if written before the protocol existed. Protocols are better for working with external libraries and existing code. ABCs are better when you want to enforce a contract on your own class hierarchy and catch missing methods at instantiation time, not at first method call.

HOW How does @abstractmethod enforcement work? When you inherit from an ABC with @abstractmethods, Python tracks which abstract methods are still unimplemented. At instantiation (MyClass()), if any abstract methods remain unoverridden, Python raises TypeError: Can't instantiate abstract class X with abstract method(s) y, z. This catches the error at class construction time — before any method is ever called — making it a stronger guarantee than duck-typing alone.

WHERE Where does Single Responsibility matter most? In classes that handle both domain logic and I/O. A UserService that also writes log files, sends emails, and manages its own database connection is violating SRP — and it's impossible to test without all of those systems present. Split at the I/O boundary: pure domain logic in one class, I/O handling injected. The domain class becomes independently testable, and each I/O concern can be changed without touching domain code.

Knowledge check

InterviewThe "D" in SOLID recommends that high-level modules depend on abstractions, not concretions. What practical benefit does this give?

  • It makes the program use less memory.
  • Implementations can be swapped or mocked without changing business logic — enabling easy testing with fake dependencies and painless vendor changes.
  • It forces every class to inherit from a single base class.
  • It eliminates the need for testing entirely.
Answer

Implementations can be swapped or mocked without changing business logic — enabling easy testing with fake dependencies and painless vendor changes.

When business logic depends on an interface (Protocol or ABC) rather than a concrete class, you can swap the implementation — an in-memory fake in tests, a different database in production, a different provider when requirements change — without touching the business logic. This decoupling is what makes systems testable and vendor-agnostic. It's the most immediately practical SOLID principle to apply.

Knowledge check

DesignA class has methods process_payment(), send_receipt_email(), and log_transaction_to_csv(). Which SOLID principle does this violate, and how?

  • Open/Closed — the class can't be extended.
  • Single Responsibility — the class has three reasons to change: payment logic, email infrastructure, and CSV logging. Each should be in a separate class.
  • Liskov Substitution — the subclass can't substitute the base.
  • Interface Segregation — the interface is too small.
Answer

Single Responsibility — the class has three reasons to change: payment logic, email infrastructure, and CSV logging. Each should be in a separate class.

Single Responsibility Principle states a class should have one reason to change. Payment processing logic changes when payment rules change; email sending changes when you switch email providers; CSV logging changes when you change the log format. Three independent reasons to change = three responsibilities = SRP violation. Refactor: inject email and logging dependencies, keep payment logic pure.

Knowledge check

InterviewWhat is the difference between a Protocol and an ABC in Python?

  • Protocol is for Python 2; ABC is for Python 3.
  • Protocol is structural (any class with the right methods qualifies, no inheritance needed); ABC is nominal (the class must explicitly inherit from it and implement all abstract methods or instantiation fails).
  • Protocol requires @abstractmethod; ABC does not.
  • They are identical — just different names for the same thing.
Answer

Protocol is structural (any class with the right methods qualifies, no inheritance needed); ABC is nominal (the class must explicitly inherit from it and implement all abstract methods or instantiation fails).

Protocol (typing.Protocol, 3.8+) uses structural subtyping: a class satisfies a protocol if it has the required methods/attributes, regardless of inheritance. Type checkers verify this statically. ABC (abc.ABC) uses nominal subtyping: a class must explicitly inherit from the ABC. Python enforces at runtime that all @abstractmethod s are overridden. Protocols are more flexible and Pythonic; ABCs provide stronger runtime guarantees and are good for owned hierarchies.

Exercise: Design a Storage Abstraction

Design a system for storing and retrieving user preferences. Define: (1) a PreferenceStore ABC with get(user_id, key), set(user_id, key, value), and delete(user_id, key), (2) a MemoryPreferenceStore implementation, (3) a UserPreferenceService that depends on the ABC (not the concrete implementation), (4) demonstrate that the service works with the memory store and would work equally with a database store. Apply SOLID principles throughout.

Show solution
from abc import ABC, abstractmethod
from collections import defaultdict
from typing import Any

# Interface — the abstraction everything depends on
class PreferenceStore(ABC):
    @abstractmethod
    def get(self, user_id: str, key: str, default: Any = None) -> Any: ...

    @abstractmethod
    def set(self, user_id: str, key: str, value: Any) -> None: ...

    @abstractmethod
    def delete(self, user_id: str, key: str) -> None: ...

    def get_all(self, user_id: str) -> dict:
        """Default implementation on top of abstracts — subclasses may override."""
        raise NotImplementedError("get_all not required by all stores")

# In-memory implementation — fast, for tests and dev
class MemoryPreferenceStore(PreferenceStore):
    def __init__(self):
        self._store: dict[str, dict[str, Any]] = defaultdict(dict)

    def get(self, user_id, key, default=None):
        return self._store[user_id].get(key, default)

    def set(self, user_id, key, value):
        self._store[user_id][key] = value

    def delete(self, user_id, key):
        self._store[user_id].pop(key, None)

    def get_all(self, user_id):
        return dict(self._store[user_id])

# Service: depends on abstraction only (Dependency Inversion)
# Single responsibility: only handles preference business logic
class UserPreferenceService:
    def __init__(self, store: PreferenceStore):  # interface, not concrete
        self._store = store

    def set_theme(self, user_id: str, theme: str) -> None:
        if theme not in ("light", "dark", "system"):
            raise ValueError(f"Invalid theme: {theme}")
        self._store.set(user_id, "theme", theme)

    def get_theme(self, user_id: str) -> str:
        return self._store.get(user_id, "theme", default="system")

    def enable_feature(self, user_id: str, feature: str) -> None:
        self._store.set(user_id, f"feature.{feature}", True)

    def feature_enabled(self, user_id: str, feature: str) -> bool:
        return bool(self._store.get(user_id, f"feature.{feature}", False))

# Test — no database needed
store   = MemoryPreferenceStore()
service = UserPreferenceService(store)   # inject memory store

service.set_theme("user-1", "dark")
service.enable_feature("user-1", "beta_dashboard")

print(service.get_theme("user-1"))                          # "dark"
print(service.feature_enabled("user-1", "beta_dashboard"))  # True
print(service.feature_enabled("user-1", "ai_suggestions"))  # False

# A real DB implementation would satisfy PreferenceStore contract
# and drop in at the same injection point — zero changes to UserPreferenceService

Key takeaways

  • Duck typing: Python checks for methods, not class identity. Protocol formalizes this structurally; ABC enforces it nominally.
  • Protocol (3.8+): any class with the right methods satisfies it — no inheritance. ABC: must explicitly subclass and implement all abstract methods.
  • SOLID: **S**ingle Responsibility, **O**pen/Closed, **L**iskov, **I**nterface Segregation, **D**ependency Inversion.
  • Dependency Inversion is the most immediately valuable principle: depend on abstractions → swappable implementations → testable systems.
  • Being able to name a SOLID principle and give a concrete example is high-leverage interview signal.