A look at object-oriented patterns that show up in everyday Python, not abstract factory diagrams. The intro tutorials introduced classes; here we focus on structures that keep code readable.
Dataclasses for plain data
If a class mostly holds data, use @dataclass instead of writing __init__, __repr__, and equality by hand.
from dataclasses import dataclass
@dataclass
class Contact:
name: str
email: str
active: bool = True
alice = Contact("Alice", "alice@example.com")
print(alice) # Contact(name='Alice', email='alice@example.com', active=True)
Composition over deep inheritance
Python supports inheritance, but long chains of base classes get hard to follow. Prefer building behaviour from smaller objects.
class Mailer:
def send(self, to: str, subject: str, body: str) -> None:
print(f"Sending to {to}: {subject}")
class UserNotifier:
def __init__(self, mailer: Mailer) -> None:
self.mailer = mailer
def welcome(self, email: str) -> None:
self.mailer.send(email, "Welcome", "Thanks for signing up.")
You can swap Mailer for a test double in unit tests without subclassing half your codebase.
Properties for lightweight validation
Use @property when an attribute needs a guard or computed value, not for every field ‘because objects’.
class Account:
def __init__(self, balance: float) -> None:
self.balance = balance
@property
def balance(self) -> float:
return self._balance
@balance.setter
def balance(self, value: float) -> None:
if value < 0:
raise ValueError("balance cannot be negative")
self._balance = value
Dunder methods worth knowing
You do not need to implement every magic method. These three appear often:
__str__– human-readable string for logging and print__repr__– unambiguous debug representation__len__– when your object wraps a collection
class TagList:
def __init__(self, tags: list[str]) -> None:
self._tags = tags
def __len__(self) -> int:
return len(self._tags)
def __repr__(self) -> str:
return f"TagList({self._tags!r}"
Protocols and duck typing
Python cares what an object can do, not its class name. If it has a write() method, it can act like a file-like object.
def save_report(output, rows: list[dict]) -> None:
for row in rows:
output.write(f"{row['id']},{row['name']}n")
# works with a real file or io.StringIO in tests
When not to use a class
Functions and modules are underrated. If you have no state to manage, a function is simpler than a class with one method. Save classes for data plus behaviour that genuinely belong together.
Next: type hints that help your editor and teammates without turning Python into another language.

