Abstract Base Classes and Structural Protocols

Updated

September 7, 2026

Abstract Base Classes and Structural Protocols

After reading this chapter, you will master Python’s two complementary interface design paradigms: nominal enforcement using Abstract Base Classes (abc.ABC), and structural static duck typing using Protocols (typing.Protocol from PEP 544). You will enforce runtime contracts, register virtual subclasses, implement @runtime_checkable interfaces, and avoid decorator ordering traps.

Mental model

Python provides two approaches for defining and enforcing contracts between components:

Nominal Subtyping (abc.ABC):
  "Explicit Inheritance"
  Class hierarchy defines type.
  Checked at instantiation time via @abstractmethod.

     ┌─────────────────┐
     │  StorageBackend │ (abc.ABC)
     └────────┬────────┘
              ▲
              │ inherits
     ┌────────┴────────┐
     │  S3Storage      │ (Concrete)
     └─────────────────┘


Structural Subtyping (typing.Protocol - PEP 544):
  "Static Duck Typing"
  Shape and attributes define type.
  No inheritance required! Zero coupling between libraries.

     ┌─────────────────┐             ┌─────────────────┐
     │  LogWriter      │ (Protocol)  │  ConsoleLogger  │ (No base class!)
     │  - write(msg)   │ <.......... │  - write(msg)   │
     └─────────────────┘  Matches    └─────────────────┘

Comparison Matrix

┌──────────────────────────────┬───────────────────────────────┬───────────────────────────────┐
│ Property                     │ Abstract Base Class (abc.ABC) │ Protocol (typing.Protocol)    │
├──────────────────────────────┼───────────────────────────────┼───────────────────────────────┤
│ Philosophy                   │ Nominal: Must inherit or reg. │ Structural: "Shape" matches   │
│ Enforcement Point            │ Runtime (at instantiation)    │ Static (Mypy/Pyright) + opt.  │
│ Third-party adaptability     │ Requires ABC.register(cls)    │ Automatic if signature fits   │
│ Primary Use Case             │ Framework base abstractions   │ Decoupled library interfaces  │
└──────────────────────────────┴───────────────────────────────┴───────────────────────────────┘

Minimal example

Save as interfaces_overview.py:

# interfaces_overview.py
from abc import ABC, abstractmethod
from typing import Protocol, runtime_checkable

# 1. Nominal Interface with abc.ABC
class MetricPublisher(ABC):
    """Nominal interface: Subclasses MUST implement publish_metric."""

    @abstractmethod
    def publish_metric(self, name: str, value: float) -> None:
        """Send a single metric measurement to the telemetry collector."""

class CloudWatchPublisher(MetricPublisher):
    def publish_metric(self, name: str, value: float) -> None:
        print(f"[CloudWatch] Published {name} = {value}")

# 2. Structural Interface with typing.Protocol (PEP 544)
@runtime_checkable
class Closable(Protocol):
    """Structural interface: Any object with a close() method satisfies this contract."""
    def close(self) -> None: ...

class DatabaseConnection:
    """Note: Does NOT inherit from Closable, but satisfies the structural contract!"""
    def close(self) -> None:
        print("[Database] Connection pool cleanly closed.")

def terminate_resource(resource: Closable) -> None:
    # Runtime verification via @runtime_checkable
    if isinstance(resource, Closable):
        resource.close()

def main() -> None:
    # Test ABC enforcement
    pub = CloudWatchPublisher()
    pub.publish_metric("cpu_load", 42.5)

    # Test Structural Protocol
    db = DatabaseConnection()
    terminate_resource(db)

if __name__ == "__main__":
    main()

Run via uv run python interfaces_overview.py:

[CloudWatch] Published cpu_load = 42.5
[Database] Connection pool cleanly closed.

Worked examples

Case 1: Enforcing Framework Contracts with Abstract Base Classes

When building an enterprise data ingestion pipeline, you want to enforce that every storage connector implements reading, writing, and health-checking before allowing the class to be instantiated:

# storage_connector.py
from abc import ABC, abstractmethod
from typing import Any

class StorageConnector(ABC):
    """Abstract driver interface for multi-cloud blob stores."""

    def __init__(self, endpoint_url: str) -> None:
        self.endpoint_url = endpoint_url

    @abstractmethod
    def upload_blob(self, key: str, payload: bytes) -> bool:
        """Upload raw bytes to cloud storage under key."""

    @abstractmethod
    def download_blob(self, key: str) -> bytes:
        """Fetch raw bytes from cloud storage."""

    @property
    @abstractmethod
    def is_connected(self) -> bool:
        """Return True if connection to storage endpoint is active."""

class LocalFileStorage(StorageConnector):
    def __init__(self, directory: str) -> None:
        super().__init__(endpoint_url=f"file://{directory}")
        self._active = True

    @property
    def is_connected(self) -> bool:
        return self._active

    def upload_blob(self, key: str, payload: bytes) -> bool:
        print(f"  [LocalFile] Stored {len(payload)} bytes to key '{key}'")
        return True

    def download_blob(self, key: str) -> bytes:
        return b"mock_payload_content"

def main() -> None:
    storage = LocalFileStorage("/var/data")
    print(f"Storage Endpoint: {storage.endpoint_url}")
    print(f"Connected:        {storage.is_connected}")
    storage.upload_blob("config.json", b'{"status": "ok"}')

if __name__ == "__main__":
    main()

Run:

uv run python storage_connector.py

Output:

Storage Endpoint: file:///var/data
Connected:        True
  [LocalFile] Stored 16 bytes to key 'config.json'

Case 2: Structural Decoupling with Protocols Across Unrelated Packages

In microservices and decoupled libraries, you frequently want to accept any object that can serialize itself to JSON without forcing the caller to inherit from your library’s base classes:

# decoupled_service.py
from typing import Protocol, runtime_checkable

@runtime_checkable
class Serializable(Protocol):
    def to_json(self) -> str:
        """Convert object state into a valid JSON string."""
        ...

class AuditEvent:
    """An external domain object with NO dependency on our library!"""
    def __init__(self, action: str, user_id: int) -> None:
        self.action = action
        self.user_id = user_id

    def to_json(self) -> str:
        return f'{{"action": "{self.action}", "user_id": {self.user_id}}}'

def dispatch_event(event: Serializable) -> None:
    """Accepts any object conforming to the Serializable structure."""
    if not isinstance(event, Serializable):
        raise TypeError(f"Object of type {type(event).__name__} does not implement to_json()")
    print(f"Transmitted to EventHub: {event.to_json()}")

def main() -> None:
    audit = AuditEvent("USER_PASSWORD_RESET", 4821)
    dispatch_event(audit)

if __name__ == "__main__":
    main()

Run:

uv run python decoupled_service.py

Output:

Transmitted to EventHub: {"action": "USER_PASSWORD_RESET", "user_id": 4821}

Case 3: Virtual Subclasses via ABC.register()

Sometimes you want an existing third-party class (which you cannot edit) to be recognized as a subclass of your ABC without monkey-patching or subclassing it. Python allows registering virtual subclasses:

# virtual_subclass.py
from abc import ABC, abstractmethod

class SequenceStream(ABC):
    @abstractmethod
    def read_chunk(self, size: int) -> bytes:
        raise NotImplementedError

class ThirdPartyHardwareBuffer:
    """A third-party class provided by a compiled vendor package."""
    def read_chunk(self, size: int) -> bytes:
        return b"\x00" * size

# Register ThirdPartyHardwareBuffer as a virtual subclass of SequenceStream!
SequenceStream.register(ThirdPartyHardwareBuffer)

def process_stream(stream: SequenceStream) -> None:
    print(f"Processing verified stream: {type(stream).__name__}")
    data = stream.read_chunk(4)
    print(f"  Read bytes: {data}")

def main() -> None:
    hw = ThirdPartyHardwareBuffer()

    # isinstance and issubclass now recognize the virtual inheritance!
    print(f"Is hw an instance of SequenceStream? {isinstance(hw, SequenceStream)}")
    print(f"Is class a subclass of SequenceStream? {issubclass(ThirdPartyHardwareBuffer, SequenceStream)}")

    process_stream(hw)

if __name__ == "__main__":
    main()

Run:

uv run python virtual_subclass.py

Output:

Is hw an instance of SequenceStream? True
Is class a subclass of SequenceStream? True
Processing verified stream: ThirdPartyHardwareBuffer
  Read bytes: b'\x00\x00\x00\x00'

Pitfalls

Pitfall 1: Attempting to Instantiate an Incomplete ABC

If any method marked with @abstractmethod is not overridden in a subclass, CPython forbids instantiation and raises TypeError at construction time:

from abc import ABC, abstractmethod

class BaseWorker(ABC):
    @abstractmethod
    def process_job(self) -> None: pass

class IncompleteWorker(BaseWorker):
    pass

# THE TRAP:
try:
    w = IncompleteWorker()
except TypeError as err:
    print(f"Caught expected error: {err}")

Output:

Caught expected error: Can't instantiate abstract class IncompleteWorker without an implementation for abstract method 'process_job'

Pitfall 2: Inverting Decorator Order with @property and @abstractmethod

When creating an abstract property, @property must be the outermost decorator, and @abstractmethod must be the innermost:

from abc import ABC, abstractmethod

# THE TRAP (WRONG ORDER):
class BadClass(ABC):
    @abstractmethod
    @property          # BUG: Python does not recognize this as an abstract property!
    def name(self) -> str: pass

# THE FIX (CORRECT ORDER):
class GoodClass(ABC):
    @property
    @abstractmethod    # Correct: @property wraps @abstractmethod
    def name(self) -> str: pass

Pitfall 3: Assuming @runtime_checkable Validates Types and Signatures

@runtime_checkable on a Protocol only checks that an attribute or method exists and is callable; it does not validate parameter types, return types, or argument counts at runtime!

from typing import Protocol, runtime_checkable

@runtime_checkable
class MathOp(Protocol):
    def compute(self, a: int, b: int) -> int: ...

class BrokenMath:
    # Has a method named 'compute', but signature takes ZERO arguments!
    def compute(self) -> str:
        return "not numbers"

b = BrokenMath()
# isinstance evaluates to True because it only checks for the presence of the method name!
print(f"isinstance check: {isinstance(b, MathOp)}")  # True!

Takeaway: Use static type checkers (mypy or pyright) to verify signatures; treat @runtime_checkable as a shallow existence check.


Exercises

  1. Define an ABC CryptographicSigner with abstract methods sign(payload: bytes) -> bytes and verify(payload: bytes, signature: bytes) -> bool. Implement a concrete subclass HMACSigner.
  2. Define a typing.Protocol named Renderable that requires a render_html() -> str method. Write two completely unrelated classes that conform to this protocol without inheriting from it.
  3. Demonstrate the error raised when attempting to instantiate an ABC subclass that forgot to implement an abstract @property.
  4. Implement a custom __subclasshook__ on an ABC so that any class defining an .export() method is dynamically recognized as a subclass via issubclass().
  5. Compare the runtime speed of isinstance(obj, ABC) vs isinstance(obj, Protocol) across 1,000,000 iterations.

Further reading

  • PEP 544: Protocols: Structural subtyping (static duck typing).
  • PEP 3119: Introducing Abstract Base Classes.
  • Luciano Ramalho: Fluent Python (Chapter 13: Interfaces, Protocols, and ABCs).
  • Python Documentation: abcAbstract Base Classes.