Decoupling Python Code: A Modern Guide to Abstraction and Protocols | Chandrashekhar Kachawa | Tech Blog

Decoupling Python Code: A Modern Guide to Abstraction and Protocols

Python

Have you ever written a class that is permanently tied to another specific class? For example, a ReportGenerator that only knows how to talk to a PostgresDatabase. This is called tight coupling, and it makes your code rigid and hard to change. What if you want to switch to a different database? You’d have to rewrite ReportGenerator.

Abstraction is the solution. It’s the practice of hiding implementation details and exposing only a necessary, common interface. In Python, we have two primary, modern tools for this: Abstract Base Classes (ABCs) and Protocols. Let’s explore them.

The Classic Approach: Abstract Base Classes (ABCs)

An ABC is a class that defines a set of methods and properties that its subclasses are forced to implement. It’s a formal contract. This is called nominal typing, meaning a class is considered a certain type only if it explicitly inherits from it.

To create an ABC, you use the abc module.

Example: Let’s define a Database abstraction.

from abc import ABC, abstractmethod

class Database(ABC):
    """An abstract interface for a database."""
    @abstractmethod
    def connect(self) -> None:
        ...

    @abstractmethod
    def query(self, query: str) -> list[dict[str, any]]:
        ...

# Now, we create concrete implementations
class PostgresDatabase(Database):
    def connect(self) -> None:
        print("Connecting to PostgreSQL...")

    def query(self, query: str) -> list[dict[str, any]]:
        print(f"Querying Postgres: {query}")
        return [{"data": "some postgres data"}]

# Our report generator depends only on the abstraction
class ReportGenerator:
    def __init__(self, db: Database):
        self.db = db
        self.db.connect()

    def create_report(self) -> None:
        data = self.db.query("SELECT * FROM sales")
        print(f"Creating report with: {data}")

# We can now easily switch implementations!
postgres_db = PostgresDatabase()
report_gen = ReportGenerator(postgres_db)

Our ReportGenerator is now decoupled. It can work with any class that inherits from Database and implements its contract.

The Modern Alternative: Protocols

Protocols, introduced in Python 3.8, take a different approach. They follow the principle of structural typing (or “static duck typing”). Instead of requiring explicit inheritance, a class conforms to a protocol if it simply has the right methods and properties with the correct signatures.

Example: Let’s redefine our interface as a Protocol.

from typing import Protocol

class Queryable(Protocol):
    """A protocol for objects that can be connected to and queried."""
    def connect(self) -> None:
        ...

    def query(self, query: str) -> list[dict[str, any]]:
        ...

# Note: This class does NOT inherit from Queryable!
class NewMongoDatabase:
    def connect(self) -> None:
        print("Connecting to MongoDB...")

    def query(self, query: str) -> list[dict[str, any]]:
        print(f"Querying Mongo: {query}")
        return [{"data": "some mongo data"}]

# Our report generator is typed with the protocol
class ReportGenerator:
    def __init__(self, db: Queryable):
        self.db = db
        self.db.connect()

    def create_report(self) -> None:
        data = self.db.query("SELECT * FROM sales")
        print(f"Creating report with: {data}")

# It works, because NewMongoDatabase has the right "structure"
mongo_db = NewMongoDatabase()
report_gen = ReportGenerator(mongo_db)

Even though NewMongoDatabase knows nothing about Queryable, a static type checker like mypy understands that it satisfies the protocol. This is incredibly flexible.

ABCs vs. Protocols: Which Should You Choose?

FeatureAbstract Base Classes (ABCs)Protocols
TypingNominal: Must inherit (is-a relationship).Structural: Must have matching methods (behaves-like-a).
InheritanceRequired.Not required.
Shared CodeYes, you can add concrete methods to the base class.No, protocols only define the interface.
FlexibilityLess flexible. Can’t be applied to existing classes.More flexible. Can be applied to any class, even third-party ones.
Runtime Checkisinstance() works reliably out of the box.Requires @runtime_checkable decorator, has limitations.

When should you use an ABC?

  • When you are building a framework and want users to explicitly inherit from your base classes.
  • When you want to provide common, shared functionality in the base class (e.g., a __init__ method).
  • When you need reliable isinstance() checks at runtime.

When should you use a Protocol?

  • When you care only about an object’s behavior (its methods), not its type.
  • When you want to apply an interface to classes you don’t control (e.g., from a third-party library).
  • When you want the most decoupled and flexible design possible.

When should you use neither?

Abstraction adds a layer of complexity. If your application is very simple, you only have one concrete implementation, and you don’t foresee that changing, it might be over-engineering. Start simple and introduce abstraction when your code becomes rigid and hard to change.

Conclusion

Both ABCs and Protocols are powerful tools for writing decoupled, maintainable, and testable Python code. The choice depends on your needs:

  • ABCs enforce an explicit, inheritance-based contract (is-a).
  • Protocols verify a structural, behavior-based contract (behaves-like-a).

For modern, application-level code, Protocols are often the more flexible and Pythonic choice, but ABCs remain invaluable for building frameworks and class hierarchies. Understanding both will make you a more effective software designer.

Latest Posts

Enjoyed this article? Follow me on X for more content and updates!

Follow @Ctrixdev