Modern Python OOP: When to Use Protocol, ABC, and Dataclass
Why This Topic Confuses People
Many Python developers learn object-oriented programming from old Java or C# examples.
The result is often:
- Deep inheritance trees
- Base classes everywhere
- Complex class hierarchies
- Code that is difficult to test
Modern Python tends to be simpler.
Instead of asking:
What should this class inherit from?
Ask:
What behavior do I need?
What data do I need?
Do I really need inheritance?
In most codebases, the answer is:
- Use a
Protocolfor behavior - Use a
dataclassfor data - Use an
ABConly when inheritance actually provides value
The Quick Answer
If you only remember one thing from this article, remember this:
| Tool | Use For |
|---|---|
| Protocol | Defining behavior |
| Dataclass | Storing data |
| ABC | Shared base logic and strict inheritance |
Most application code today uses:
Protocol + Dataclass
Far more often than:
ABC + inheritance
Protocol: “Can You Do The Job?”
A protocol describes behavior.
It does not care about inheritance.
It only cares whether an object has the required methods.
Think of a job interview.
The interviewer asks:
Can you write Python?
Can you debug systems?
Can you communicate clearly?
They do not ask:
Did you inherit from EmployeeBaseClass?
Protocols work the same way.
Example
from typing import Protocol
class Notifier(Protocol):
def send(self, message: str) -> None:
...
Now any class with a compatible send() method works.
class EmailNotifier:
def send(self, message: str) -> None:
print(f"Email: {message}")
class SMSNotifier:
def send(self, message: str) -> None:
print(f"SMS: {message}")
Neither class inherits from Notifier.
But both satisfy the protocol.
Why Protocols Are Useful
Protocols reduce coupling.
Without protocols:
class NotificationService:
def __init__(self, notifier: EmailNotifier):
self.notifier = notifier
Now the service depends on one specific implementation.
With protocols:
class NotificationService:
def __init__(self, notifier: Notifier):
self.notifier = notifier
Now the service accepts:
- EmailNotifier
- SMSNotifier
- SlackNotifier
- MockNotifier
without modification.
This makes testing easier.
It also makes future changes easier.
A Real Example
Imagine a payment system.
Define Behavior
from typing import Protocol
class PaymentProcessor(Protocol):
def pay(self, amount: float) -> None:
...
Implementations
class StripeProcessor:
def pay(self, amount: float) -> None:
print(f"Stripe charged {amount}")
class PaypalProcessor:
def pay(self, amount: float) -> None:
print(f"PayPal charged {amount}")
Use The Protocol
class CheckoutService:
def __init__(self, processor: PaymentProcessor):
self.processor = processor
def checkout(self, amount: float) -> None:
self.processor.pay(amount)
The checkout service never needs to know which payment provider is being used.
That flexibility is the main benefit of protocols.
ABC: “Join The Family”
ABC stands for Abstract Base Class.
Unlike a protocol, an ABC requires inheritance.
It says:
If you want to participate,
you must inherit from me.
Example
from abc import ABC, abstractmethod
class BaseNotifier(ABC):
@abstractmethod
def send(self, message: str) -> None:
pass
Subclasses must implement:
send()
Otherwise Python raises an error.
When ABC Makes Sense
ABC is useful when you want more than behavior.
ABC is useful when you also want shared implementation.
Example:
from abc import ABC, abstractmethod
class BaseStorage(ABC):
def connect(self):
print("Opening connection")
@abstractmethod
def save(self, data):
pass
Concrete implementations:
class S3Storage(BaseStorage):
def save(self, data):
print("Saving to S3")
class LocalStorage(BaseStorage):
def save(self, data):
print("Saving locally")
Both classes automatically inherit:
connect()
This is where ABCs shine.
When ABC Is A Bad Choice
Many inheritance hierarchies exist only because somebody thought OOP requires inheritance.
Example:
Animal
-> Mammal
-> Dog
-> Cat
This is usually unnecessary in application code.
The deeper the inheritance tree becomes:
- The harder it is to understand
- The harder it is to test
- The harder it is to change
Modern Python generally prefers composition over inheritance.
Protocol vs ABC
This is the question most developers actually ask.
Use Protocol When
You care about behavior.
Can it send messages?
Can it save files?
Can it process payments?
You do not care about inheritance.
Example:
class Notifier(Protocol):
def send(self, message: str) -> None:
...
Use ABC When
You need:
- Shared implementation
- Shared state
- Runtime enforcement
- Framework-style architecture
Example:
class BaseStorage(ABC):
...
Quick Rule
If you are unsure:
Start with Protocol.
Move to an ABC only when inheritance provides a real benefit.
Dataclass: “This Class Is Mostly Data”
Many classes exist only to store data.
Writing boilerplate for these classes is repetitive.
Without dataclass:
class User:
def __init__(self, name: str, age: int):
self.name = name
self.age = age
This becomes annoying very quickly.
Dataclass Version
from dataclasses import dataclass
@dataclass
class User:
name: str
age: int
Python automatically generates:
__init____repr____eq__
The result is shorter and easier to read.
Why Dataclasses Became Popular
Consider a configuration object.
Without dataclass:
class Config:
def __init__(
self,
host,
port,
timeout
):
self.host = host
self.port = port
self.timeout = timeout
With dataclass:
from dataclasses import dataclass
@dataclass
class Config:
host: str
port: int
timeout: int
Much less boilerplate.
The intent is clearer.
Immutable Dataclasses
Configuration objects often should not change after creation.
Use:
@dataclass(frozen=True)
Example:
from dataclasses import dataclass
@dataclass(frozen=True)
class Config:
host: str
port: int
Now this fails:
config.port = 9000
Python raises an error.
This helps prevent accidental changes.
When Not To Use Dataclasses
Dataclasses are best for data.
Avoid using them when the class mainly contains behavior.
Example:
class PaymentService:
def validate(self):
...
def charge(self):
...
def refund(self):
...
This is a service.
Not a data container.
A normal class is usually better.
Putting Everything Together
Modern Python often combines protocols and dataclasses.
Protocol
from typing import Protocol
class PaymentProcessor(Protocol):
def pay(self, amount: float) -> None:
...
Dataclass
from dataclasses import dataclass
@dataclass(frozen=True)
class PaymentService:
processor: PaymentProcessor
def checkout(self, amount: float) -> None:
self.processor.pay(amount)
Implementations
class StripeProcessor:
def pay(self, amount: float) -> None:
print(f"Stripe charged {amount}")
class PaypalProcessor:
def pay(self, amount: float) -> None:
print(f"PayPal charged {amount}")
Usage
service = PaymentService(
processor=StripeProcessor()
)
service.checkout(100)
Later:
service = PaymentService(
processor=PaypalProcessor()
)
No changes to PaymentService.
Only the dependency changes.
This is one of the most common patterns in modern Python applications.
Common Mistakes
Mistake 1: Using ABC For Everything
Bad:
BaseUser
BaseService
BaseController
BaseManager
Most of these should not exist.
Mistake 2: Using Dataclass For Services
Bad:
@dataclass
class UserService:
...
A service is behavior.
Not data.
Mistake 3: Ignoring Protocols
Many developers still type-hint concrete classes:
def send_email(
notifier: EmailNotifier
):
...
Better:
def send_email(
notifier: Notifier
):
...
Depend on behavior.
Not implementation.
A Simple Mental Model
Think of these tools like hiring people.
Protocol
A job description.
Can you do the work?
Skills matter.
ABC
Company membership.
Are you part of this family?
Inheritance matters.
Dataclass
An employee record.
Store information.
Mostly data.
The Practical Rule
When building modern Python applications:
- Start with normal classes.
- Use dataclasses for data models.
- Use protocols for abstractions.
- Use ABCs only when shared inheritance provides real value.
- Prefer composition over inheritance.
Most codebases become simpler, easier to test, and easier to maintain when you follow these rules.