Good object-oriented code is not about applying every feature the language offers; it is about applying a few principles that keep large programs maintainable. The most widely quoted are the SOLID principles: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. They are guidelines, not laws, but breaking them tends to hurt eventually.
Single Responsibility: a class should have one reason to change. If a User class does persistence, formatting, and validation, any change to storage drags the other responsibilities along. Splitting into User, UserRepository, and UserFormatter is usually cheaper in the long run.
Open/Closed and Liskov Substitution are two sides of extensibility. Your classes should be open to extension (new behavior via subclasses or strategies) and closed to modification (you rarely edit the core). Substitution reminds you that a subclass must really be usable wherever the base class is expected, without surprising callers.
Interface Segregation and Dependency Inversion keep the coupling between modules low. Small, focused interfaces beat large monolithic ones; depending on an abstract interface beats depending on a concrete implementation. In Python, typing.Protocol expresses both cheaply, without the overhead of a full abstract base class.
SOLID in Pythonic style
Single Responsibility: one class, one job. Open/Closed: add new behavior by composing or subclassing, not by editing the base. Liskov: subclasses promise “I am usable wherever the base is”. Interface Segregation: many small Protocols beat one giant ABC. Dependency Inversion: pass in a Protocol, let callers choose the implementation.
These are defaults, not rules. A small CLI script can violate every one of them and still be fine. They matter most for code that lives for years.
Favor composition over inheritance
Inheritance is fine for genuine is-a relationships. For everything else, composition — holding collaborators as attributes — leads to flatter, more testable code. class Order: def __init__(self, pricing: Pricing): ... makes the dependency obvious and easy to swap in tests.
When you do inherit, keep hierarchies shallow. Two levels is usually enough; beyond that, navigating the code becomes painful. Mixins can help but are also easy to overuse; treat them as a last resort, not a first choice.
Design principles and their Python tools.
| Tool | Purpose |
|---|---|
SOLIDacronym | Five OO design principles. |
typing.Protocolclass | Small structural interfaces. |
abc.ABCclass | Nominal interface with required overrides. |
@dataclassdecorator | Keeps data classes small and explicit. |
@cached_propertydecorator | Avoids subclass hooks for simple memoization. |
@singledispatchdecorator | Type-based function dispatch. |
Design Patternsguide | Classical pattern catalog with Python examples. |
PEP 8style | Naming and structure conventions. |
Applying Object-Oriented Design Principles code example
The script below refactors a monolithic class into three smaller ones, showing Single Responsibility and Dependency Inversion.
# Lesson: Applying Object-Oriented Design Principles
from dataclasses import dataclass
from typing import Iterable, Protocol
@dataclass
class Order:
id: int
items: list[tuple[str, float]] # [(name, price)]
class Pricing(Protocol):
def total(self, order: Order) -> float: ...
class SimplePricing:
def total(self, order: Order) -> float:
return sum(price for _, price in order.items)
class DiscountedPricing:
def __init__(self, rate: float):
self.rate = rate
def total(self, order: Order) -> float:
base = sum(price for _, price in order.items)
return round(base * (1 - self.rate), 2)
class Formatter(Protocol):
def format(self, order: Order, total: float) -> str: ...
class PlainFormatter:
def format(self, order: Order, total: float) -> str:
lines = [f"#{order.id}"]
for name, price in order.items:
lines.append(f" {name:10s} {price:6.2f}")
lines.append(f" total {total:6.2f}")
return "\n".join(lines)
class Checkout:
def __init__(self, pricing: Pricing, formatter: Formatter):
self.pricing = pricing
self.formatter = formatter
def run(self, orders: Iterable[Order]) -> list[str]:
return [
self.formatter.format(order, self.pricing.total(order))
for order in orders
]
order_a = Order(1, [("apple", 1.20), ("bread", 3.50)])
order_b = Order(2, [("book", 19.90)])
full_price = Checkout(SimplePricing(), PlainFormatter())
on_sale = Checkout(DiscountedPricing(0.1), PlainFormatter())
for out in full_price.run([order_a, order_b]):
print(out); print()
print("-- on sale --")
for out in on_sale.run([order_a]):
print(out)
Notice the separation of concerns:
1) `Order` holds data, nothing else — Single Responsibility.
2) `Pricing` and `Formatter` are Protocols; concrete classes satisfy them structurally.
3) `Checkout` depends on Protocols, not concrete classes — Dependency Inversion.
4) Adding a new pricing strategy does not modify existing classes — Open/Closed.
Refactor a monolithic class into collaborators.
class Repository(Protocol): # type: ignore[misc]
def save(self, payload: dict) -> int: ...
class InMemoryRepo:
def __init__(self): self._data: dict[int, dict] = {}; self._id = 0
def save(self, payload: dict) -> int:
self._id += 1
self._data[self._id] = payload
return self._id
class Service:
def __init__(self, repo: Repository): self.repo = repo
def add(self, payload: dict) -> int:
return self.repo.save(payload)
svc = Service(InMemoryRepo())
print(svc.add({"name": "ana"}))
print(svc.add({"name": "ben"}))
Structural checks tying it all together.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Totaller(Protocol):
def total(self) -> float: ...
class A:
def total(self) -> float: return 1.0
class B:
pass
assert isinstance(A(), Totaller)
assert not isinstance(B(), Totaller)
Running prints:
#1
apple 1.20
bread 3.50
total 4.70
#2
book 19.90
total 19.90
-- on sale --
#1
apple 1.20
bread 3.50
total 4.23