DEV Community

Cover image for The Interface Gallery: Abstract Base Classes and Protocols
Aaron Rose
Aaron Rose

Posted on

The Interface Gallery: Abstract Base Classes and Protocols

Timothy had built a library system with multiple storage backends—database storage, file storage, cloud storage. Each backend had the same methods (save_book, load_book, delete_book), but nothing enforced this contract. Developers kept forgetting to implement all methods, leading to runtime crashes.

class DatabaseStorage:
    def save_book(self, book):
        # Implementation
        pass

    def load_book(self, book_id):
        # Implementation
        pass

    def delete_book(self, book_id):
        # Implementation
        pass

class FileStorage:
    def save_book(self, book):
        # Implementation
        pass

    def load_book(self, book_id):
        # Implementation
        pass

    # Oops! Forgot delete_book
    # No error until runtime when someone calls it

class Library:
    def __init__(self, storage):
        self.storage = storage

    def remove_book(self, book_id):
        self.storage.delete_book(book_id)  # Crashes if FileStorage!
Enter fullscreen mode Exit fullscreen mode

Margaret found him debugging yet another missing method. "You need contracts," she explained. "Come to the Interface Gallery—where Python enforces that classes implement required methods."

The Problem with Duck Typing

Timothy learned about Python's flexibility and its cost:

# Python's duck typing: "If it walks like a duck..."
def process_storage(storage):
    storage.save_book(book)  # Assumes save_book exists
    storage.load_book(1)     # Assumes load_book exists
    storage.delete_book(1)   # Assumes delete_book exists

# No error until runtime when a method is missing!
# No way to check if an object has all required methods
# Documentation can help, but humans forget
Enter fullscreen mode Exit fullscreen mode

"Python's duck typing is flexible but risky," Margaret explained. "You can pass any object, but you only discover missing methods when code runs. Abstract Base Classes enforce contracts at class definition time."

Abstract Base Classes: Enforcing Contracts

Margaret showed Timothy the ABC module:

from abc import ABC, abstractmethod

class Storage(ABC):
    @abstractmethod
    def save_book(self, book):
        """Save a book to storage"""
        pass

    @abstractmethod
    def load_book(self, book_id):
        """Load a book from storage"""
        pass

    @abstractmethod
    def delete_book(self, book_id):
        """Delete a book from storage"""
        pass

# Can't instantiate abstract class
# storage = Storage()  # TypeError: Can't instantiate abstract class

# Must implement ALL abstract methods
class DatabaseStorage(Storage):
    def save_book(self, book):
        print(f"Saving {book} to database")

    def load_book(self, book_id):
        print(f"Loading book {book_id} from database")
        return {"id": book_id, "title": "Book"}

    def delete_book(self, book_id):
        print(f"Deleting book {book_id} from database")

# This works - all methods implemented
db_storage = DatabaseStorage()

# This fails at instantiation!
class IncompleteStorage(Storage):
    def save_book(self, book):
        pass

    # Missing load_book and delete_book

# Class definition is fine - no error yet
# Error happens HERE when you try to create an instance:
# incomplete = IncompleteStorage()  
# TypeError: Can't instantiate abstract class IncompleteStorage 
# with abstract methods load_book, delete_book
Enter fullscreen mode Exit fullscreen mode

"Abstract Base Classes define contracts," Margaret explained. "Subclasses must implement all @abstractmethod methods or they can't be instantiated. The error occurs when you try to create an instance, not at class definition time. However, type checkers like mypy catch incomplete implementations at definition time—before the code even runs."

Abstract Methods Can Have Default Implementation

Timothy learned abstract methods could provide defaults:

from abc import ABC, abstractmethod

class Storage(ABC):
    @abstractmethod
    def save_book(self, book):
        """Save a book - subclasses can call super() for logging"""
        print(f"[Storage] Saving book: {book.title}")

    @abstractmethod
    def load_book(self, book_id):
        pass

class DatabaseStorage(Storage):
    def save_book(self, book):
        super().save_book(book)  # Call abstract method for logging
        print(f"[Database] Writing to database")

    def load_book(self, book_id):
        return {"id": book_id}

db = DatabaseStorage()
db.save_book(Book("Dune", "Herbert"))
# [Storage] Saving book: Dune
# [Database] Writing to database
Enter fullscreen mode Exit fullscreen mode

"Abstract methods can contain code," Margaret noted. "They still must be overridden, but subclasses can call super() to use the default implementation. This provides shared behavior while enforcing the contract."

Abstract Properties

Margaret showed Timothy how to require properties:

from abc import ABC, abstractmethod

class LibraryItem(ABC):
    @property
    @abstractmethod
    def title(self):
        """Item must have a title property"""
        pass

    @property
    @abstractmethod
    def catalog_id(self):
        """Item must have a catalog ID"""
        pass

class Book(LibraryItem):
    def __init__(self, title, catalog_id):
        self._title = title
        self._catalog_id = catalog_id

    @property
    def title(self):
        return self._title

    @property
    def catalog_id(self):
        return self._catalog_id

book = Book("Dune", "B-1965-001")
print(book.title)  # Works - property implemented
Enter fullscreen mode Exit fullscreen mode

"Combine @property and @abstractmethod to require properties," Margaret explained. "The order matters: @property first, then @abstractmethod."

Abstract Class Methods and Static Methods

Timothy learned abstract methods could be classmethods or staticmethods:

from abc import ABC, abstractmethod

class LibraryItem(ABC):
    @classmethod
    @abstractmethod
    def from_json(cls, json_data):
        """Factory method - subclasses must implement"""
        pass

    @staticmethod
    @abstractmethod
    def validate_isbn(isbn):
        """Validation - subclasses must implement"""
        pass

    @abstractmethod
    def get_title(self):
        """Instance method - subclasses must implement"""
        pass

class Book(LibraryItem):
    def __init__(self, title, isbn):
        self.title = title
        self.isbn = isbn

    @classmethod
    def from_json(cls, json_data):
        return cls(json_data['title'], json_data['isbn'])

    @staticmethod
    def validate_isbn(isbn):
        return len(isbn) == 13

    def get_title(self):
        return self.title

# All three abstract method types implemented
book = Book("Dune", "9780441013593")
loaded = Book.from_json({"title": "Foundation", "isbn": "9780553293357"})
print(Book.validate_isbn("9780441013593"))  # True
Enter fullscreen mode Exit fullscreen mode

"You can make class methods and static methods abstract," Margaret noted. "The decorator order matters: @classmethod or @staticmethod first, then @abstractmethod. This enforces factory methods, validation methods, or utility methods."

Checking Types with isinstance()

Timothy learned ABCs enabled type checking:

from abc import ABC, abstractmethod

class Storage(ABC):
    @abstractmethod
    def save_book(self, book):
        pass

class DatabaseStorage(Storage):
    def save_book(self, book):
        print("Saving to database")

class FileStorage(Storage):
    def save_book(self, book):
        print("Saving to file")

db = DatabaseStorage()
file = FileStorage()

# Both are instances of Storage
print(isinstance(db, Storage))    # True
print(isinstance(file, Storage))  # True

# Type hints work with ABCs
def backup_to_multiple(storages: list[Storage]):
    for storage in storages:
        storage.save_book(book)  # Type checker knows this method exists

backup_to_multiple([db, file])  # Type checker approves
Enter fullscreen mode Exit fullscreen mode

"ABCs create a type hierarchy," Margaret noted. "All implementations are instances of the abstract base. Type checkers and isinstance() can verify objects match the contract."

Python's Built-in ABCs: collections.abc

Margaret showed Timothy that Python's standard library used ABCs extensively:

from collections.abc import Sequence, MutableSequence

class BookList(MutableSequence):
    """Custom list-like container for books"""
    def __init__(self):
        self._books = []

    # Must implement these 5 abstract methods:
    def __getitem__(self, index):
        return self._books[index]

    def __setitem__(self, index, value):
        self._books[index] = value

    def __delitem__(self, index):
        del self._books[index]

    def __len__(self):
        return len(self._books)

    def insert(self, index, value):
        self._books.insert(index, value)

# MutableSequence ABC provides these methods for free:
# append, extend, pop, remove, reverse, clear, count, index, etc.

books = BookList()
books.append(book1)           # Free method from ABC!
books.extend([book2, book3])  # Free method!
books.reverse()               # Free method!
print(books.count(book1))     # Free method!
Enter fullscreen mode Exit fullscreen mode

"Python's collections.abc module provides ABCs for common container types," Margaret explained. "Implement a few required methods, get dozens for free. This is how Python's built-in collections work—they inherit from these ABCs."

Virtual Subclasses with register()

Margaret showed Timothy a dangerous escape hatch:

from abc import ABC, abstractmethod

class Storage(ABC):
    @abstractmethod
    def save_book(self, book):
        pass

# Third-party class we can't modify
class LegacyStorage:
    def save_book(self, book):
        print("Legacy save")

# Register as virtual subclass - no inheritance needed
Storage.register(LegacyStorage)

legacy = LegacyStorage()
print(isinstance(legacy, Storage))  # True!

# But register() doesn't enforce the contract!
class BadStorage:
    pass  # Missing save_book entirely!

Storage.register(BadStorage)
bad = BadStorage()  # No error - register bypasses checks!
print(isinstance(bad, Storage))  # True - but save_book missing!
# bad.save_book(book)  # AttributeError at runtime!
Enter fullscreen mode Exit fullscreen mode

"The register() method creates virtual subclasses," Margaret warned. "It makes isinstance() return True without inheritance or contract checking. Useful for third-party classes you can't modify, but dangerous—it bypasses all enforcement. Use sparingly and only when you're certain the class fulfills the contract."

Protocols: Duck Typing with Type Checking

Margaret showed Timothy Python 3.8's modern alternative:

from typing import Protocol

class StorageProtocol(Protocol):
    def save_book(self, book) -> None:
        """Save a book to storage"""
        ...

    def load_book(self, book_id: int) -> dict:
        """Load a book from storage"""
        ...

    def delete_book(self, book_id: int) -> None:
        """Delete a book from storage"""
        ...

# No inheritance needed!
class DatabaseStorage:
    def save_book(self, book):
        print("Saving to database")

    def load_book(self, book_id):
        return {"id": book_id}

    def delete_book(self, book_id):
        print("Deleting from database")

# DatabaseStorage doesn't inherit from StorageProtocol
# But it matches the protocol structurally

def use_storage(storage: StorageProtocol):
    storage.save_book(book)

db = DatabaseStorage()
use_storage(db)  # Type checker approves - structural match!
Enter fullscreen mode Exit fullscreen mode

"Protocols are structural typing," Margaret explained. "Classes don't need to inherit from the protocol. If they have the right methods with the right signatures, they match. It's duck typing that type checkers can verify."

ABC vs Protocol: When to Use Which

Margaret clarified the distinction:

# ABC - Explicit inheritance required
from abc import ABC, abstractmethod

class StorageABC(ABC):
    @abstractmethod
    def save_book(self, book):
        pass

class DatabaseStorage(StorageABC):  # Must explicitly inherit
    def save_book(self, book):
        pass

# isinstance() works at runtime
print(isinstance(DatabaseStorage(), StorageABC))  # True

# Protocol - Structural matching
from typing import Protocol

class StorageProtocol(Protocol):
    def save_book(self, book) -> None:
        ...

class FileStorage:  # No inheritance needed
    def save_book(self, book):
        pass

# isinstance() doesn't work naturally with Protocols
# (unless using runtime_checkable, shown later)
# But type checkers verify structural match
Enter fullscreen mode Exit fullscreen mode

Use ABC when:

  • You control all implementations (internal code)
  • You want runtime isinstance() checks
  • You need shared default implementation (via super())
  • You want explicit "is-a" relationships

Use Protocol when:

  • You don't control implementations (third-party, stdlib)
  • You want flexibility without inheritance
  • You only care about structure, not lineage
  • You prefer duck typing with type safety

Runtime Checkable Protocols

Timothy learned protocols could enable runtime checks:

from typing import Protocol, runtime_checkable

@runtime_checkable
class StorageProtocol(Protocol):
    def save_book(self, book) -> None:
        ...

    def load_book(self, book_id: int) -> dict:
        ...

class DatabaseStorage:
    def save_book(self, book):
        pass

    def load_book(self, book_id):
        return {}

db = DatabaseStorage()

# Now isinstance() works!
print(isinstance(db, StorageProtocol))  # True

# But only checks method existence, not signatures
class FakeStorage:
    def save_book(self):  # Wrong signature!
        pass

    def load_book(self):  # Wrong signature!
        pass

fake = FakeStorage()
print(isinstance(fake, StorageProtocol))  # True - only checks names!
Enter fullscreen mode Exit fullscreen mode

"@runtime_checkable enables isinstance() with protocols," Margaret cautioned. "But it only checks method names, not signatures. Type checkers are smarter—they verify full signatures."

The Empty Protocol Trap

Margaret warned Timothy about a common mistake:

from typing import Protocol, runtime_checkable

# WRONG - empty protocol matches almost everything!
@runtime_checkable
class EmptyProtocol(Protocol):
    pass

class Book:
    def __init__(self, title):
        self.title = title

book = Book("Dune")
print(isinstance(book, EmptyProtocol))  # True - Book matches!

# Empty protocols are useless for type checking
# They accept anything

# RIGHT - always define at least one method
@runtime_checkable
class Saveable(Protocol):
    def save(self) -> None:
        """Must have save method"""
        ...

print(isinstance(book, Saveable))  # False - Book lacks save()

class SaveableBook:
    def save(self):
        print("Saving...")

print(isinstance(SaveableBook(), Saveable))  # True - has save()
Enter fullscreen mode Exit fullscreen mode

"Always define at least one method in a protocol," Margaret emphasized. "Empty protocols are meaningless—they match everything. A protocol should specify required behavior, not be an empty shell."

Multiple Abstract Methods

Margaret showed Timothy interfaces with multiple requirements:

from abc import ABC, abstractmethod

class Searchable(ABC):
    @abstractmethod
    def search(self, query: str):
        """Search for items"""
        pass

class Sortable(ABC):
    @abstractmethod
    def sort_by(self, field: str):
        """Sort items by field"""
        pass

class Filterable(ABC):
    @abstractmethod
    def filter_by(self, predicate):
        """Filter items"""
        pass

# Combine multiple ABCs
class Catalog(Searchable, Sortable, Filterable):
    def __init__(self):
        self.books = []

    def search(self, query):
        return [b for b in self.books if query in b.title]

    def sort_by(self, field):
        return sorted(self.books, key=lambda b: getattr(b, field))

    def filter_by(self, predicate):
        return [b for b in self.books if predicate(b)]

catalog = Catalog()
print(isinstance(catalog, Searchable))   # True
print(isinstance(catalog, Sortable))     # True
print(isinstance(catalog, Filterable))   # True
Enter fullscreen mode Exit fullscreen mode

"Multiple inheritance with ABCs creates rich contracts," Margaret explained. "Each ABC defines one capability. Combine them to specify complex requirements."

Concrete Methods in ABCs

Timothy learned ABCs could have regular methods too:

from abc import ABC, abstractmethod

class Storage(ABC):
    @abstractmethod
    def save_book(self, book):
        pass

    @abstractmethod
    def load_book(self, book_id):
        pass

    # Concrete method - available to all subclasses
    def save_multiple(self, books):
        for book in books:
            self.save_book(book)  # Uses abstract method

    # Concrete method with utility
    def validate_book(self, book):
        if not hasattr(book, 'title'):
            raise ValueError("Book must have title")
        return True

class DatabaseStorage(Storage):
    def save_book(self, book):
        self.validate_book(book)  # Use concrete method
        print(f"Saving {book.title}")

    def load_book(self, book_id):
        return {"id": book_id}

db = DatabaseStorage()
db.save_multiple([book1, book2, book3])  # Concrete method works!
Enter fullscreen mode Exit fullscreen mode

"ABCs can mix abstract and concrete methods," Margaret noted. "Abstract methods define the contract. Concrete methods provide shared functionality that uses the abstract methods."

Real-World Example: Plugin System

Margaret demonstrated a practical pattern:

from abc import ABC, abstractmethod

class BookProcessor(ABC):
    """Base class for book processing plugins"""

    @abstractmethod
    def process(self, book) -> dict:
        """Process a book and return metadata"""
        pass

    @abstractmethod
    def get_name(self) -> str:
        """Return processor name"""
        pass

    # Concrete helper method
    def validate_input(self, book):
        if not hasattr(book, 'title'):
            raise ValueError(f"{self.get_name()}: Invalid book")

class ISBNExtractor(BookProcessor):
    def process(self, book):
        self.validate_input(book)
        # Extract ISBN from book
        return {"isbn": book.isbn}

    def get_name(self):
        return "ISBN Extractor"

class SummaryGenerator(BookProcessor):
    def process(self, book):
        self.validate_input(book)
        # Generate summary
        return {"summary": f"Summary of {book.title}"}

    def get_name(self):
        return "Summary Generator"

class ProcessingPipeline:
    def __init__(self):
        self.processors: list[BookProcessor] = []

    def register(self, processor: BookProcessor):
        self.processors.append(processor)

    def process_book(self, book):
        results = {}
        for processor in self.processors:
            results[processor.get_name()] = processor.process(book)
        return results

# Build pipeline
pipeline = ProcessingPipeline()
pipeline.register(ISBNExtractor())
pipeline.register(SummaryGenerator())

# Process books - all processors match contract
metadata = pipeline.process_book(book)
Enter fullscreen mode Exit fullscreen mode

"Plugin systems benefit from ABCs," Margaret explained. "The base class defines the plugin interface. Each plugin implements the contract. The system can work with any conforming plugin without knowing specifics."

When NOT to Use ABCs

Margaret emphasized knowing when ABCs were overkill:

# DON'T create ABCs for everything - this is overkill:
from abc import ABC, abstractmethod

class Named(ABC):
    @abstractmethod
    def get_name(self):
        pass

class Book(Named):
    def __init__(self, title):
        self.title = title

    def get_name(self):
        return self.title

# Better - just use a regular class or duck typing:
class Book:
    def __init__(self, title):
        self.title = title

    def get_name(self):
        return self.title

# Functions work with any object that has get_name
def print_name(obj):
    print(obj.get_name())  # Duck typing - no ABC needed
Enter fullscreen mode Exit fullscreen mode

Use ABCs when:

  • Multiple implementations exist or are planned
  • Contract enforcement is critical for correctness
  • Building plugin/extension systems
  • Need shared default behavior via concrete methods
  • Want explicit "is-a" relationships

Don't use ABCs when:

  • Only one implementation exists
  • Simple duck typing suffices
  • The interface is obvious and unlikely to vary
  • Over-engineering for a simple problem
  • Adding unnecessary complexity

"ABCs are tools, not requirements," Margaret cautioned. "They add value in systems with multiple implementations and strict contracts. For simple cases, regular classes or duck typing are clearer and more Pythonic."

Timothy's Interface Wisdom

Through exploring the Interface Gallery, Timothy learned essential principles:

Abstract Base Classes enforce contracts: Subclasses must implement all @abstractmethod methods.

Can't instantiate abstract classes: Error occurs at instantiation time, not class definition time.

Type checkers catch errors earlier: mypy finds incomplete implementations before runtime.

Abstract methods can have default implementations: Subclasses can call super() for shared behavior.

@property and @abstractmethod combine: Require properties in subclasses—@property first, then @abstractmethod.

@classmethod and @staticmethod can be abstract: Enforce factory methods and utilities—decorator first, then @abstractmethod.

collections.abc provides built-in ABCs: Implement required methods, get dozens free (Sequence, MutableSequence, etc.).

register() creates virtual subclasses: Makes isinstance() work without inheritance—useful but bypasses enforcement.

isinstance() works with ABCs: Check if objects fulfill abstract contracts at runtime.

Protocols are structural typing: Match by structure, not inheritance.

Protocols don't require inheritance: If methods match, type checkers approve.

@runtime_checkable enables isinstance(): But only checks method names, not signatures.

Empty protocols are useless: Always define at least one method in a protocol.

ABCs are for controlled implementations: When you own all the code and need strict contracts.

Protocols are for flexibility: When you don't control implementations or want duck typing.

ABCs can mix abstract and concrete methods: Define contract with abstract, provide utilities with concrete.

Multiple ABCs combine capabilities: Create rich interfaces through multiple inheritance.

Use ABCs for plugin systems: Enforce plugin contracts at definition time.

Don't overuse ABCs: Simple cases work better with duck typing or regular classes.

Type checkers verify both ABCs and Protocols: Catch missing methods before runtime.


Python's Contract Enforcement Mechanisms

Timothy had discovered Python's contract enforcement mechanisms—Abstract Base Classes for explicit inheritance hierarchies and Protocols for structural typing.

The Interface Gallery revealed that Python could be both flexible and safe—ABCs caught incomplete implementations at instantiation time (or earlier with type checkers), while Protocols enabled type-checked duck typing without inheritance.

He learned that abstract methods could be instance methods, class methods, or static methods, and that Python's own collections.abc used these patterns extensively.

He discovered the dangerous register() escape hatch for virtual subclasses and the trap of empty protocols that matched everything.

Most importantly, he learned when NOT to use ABCs—avoiding over-engineering when simple duck typing sufficed.

He could now design systems with guaranteed contracts, whether through explicit inheritance or structural matching, ensuring that objects fulfilled their promises before code ever ran—while keeping the design simple and Pythonic when complexity wasn't warranted.


Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.

Top comments (0)