Composition over inheritance
Inheritance looks appealing because it lets you reuse code without copying it. But when used purely for code reuse, rather than to model genuine IS-A relationships, it creates fragile hierarchies that are painful to change.
Here is a concrete example. You want a UserService that can log and send emails. You reach for inheritance:
1class Logger:2 def log(self, msg):3 print(f"[LOG] {msg}")45class Emailer:6 def email(self, to, body):7 print(f"Email to {to}: {body}")89# Multiple inheritance to "get" both10class UserService(Logger, Emailer):11 def create_user(self, name, email):12 self.log(f"Creating {name}")13 self.email(email, f"Welcome, {name}!")1415svc = UserService()16svc.create_user("Alice", "alice@example.com")
It works. But UserService is not a Logger and is not an Emailer. It uses them. Now every instance of UserService is permanently tied to those specific implementations. You cannot switch to a file-based logger in tests, or stub out the emailer, without changing the class itself.
The composition version
Composition means your class holds a reference to another object instead of inheriting from it. The dependency is passed in from outside:
1class Logger:2 def log(self, msg):3 print(f"[LOG] {msg}")45class Emailer:6 def email(self, to, body):7 print(f"Email to {to}: {body}")89class UserService:10 def __init__(self, logger: Logger, emailer: Emailer):11 self._logger = logger12 self._emailer = emailer1314 def create_user(self, name, email):15 self._logger.log(f"Creating {name}")16 self._emailer.email(email, f"Welcome, {name}!")1718# Production19svc = UserService(Logger(), Emailer())20svc.create_user("Alice", "alice@example.com")2122# In tests: pass a silent logger, stub emailer23class SilentLogger:24 def log(self, msg): pass2526class FakeEmailer:27 def __init__(self):28 self.sent = []29 def email(self, to, body):30 self.sent.append((to, body))3132fake_emailer = FakeEmailer()33test_svc = UserService(SilentLogger(), fake_emailer)34test_svc.create_user("Bob", "bob@example.com")35print(fake_emailer.sent) # [('bob@example.com', 'Welcome, Bob!')]
UserService now has no hardcoded dependencies. You swap implementations by passing different objects. This is dependency injection, and it does not require a framework. It is just composition.
@dataclass: stop writing boilerplate
A huge portion of classes in real code are just containers for data. You write __init__ to store the arguments, __repr__ so it prints nicely, __eq__ so two instances with the same data compare equal. The @dataclass decorator generates all of that automatically.
1from dataclasses import dataclass23@dataclass4class Point:5 x: float6 y: float78# __init__, __repr__, and __eq__ are all generated9p1 = Point(1.0, 2.0)10p2 = Point(1.0, 2.0)11p3 = Point(3.0, 4.0)1213print(p1) # Point(x=1.0, y=2.0)14print(p1 == p2) # True15print(p1 == p3) # False
Default values and field()
1from dataclasses import dataclass, field23@dataclass4class Config:5 host: str = "localhost"6 port: int = 80807 tags: list[str] = field(default_factory=list) # mutable defaults need field()89c1 = Config()10c2 = Config(host="prod.example.com", port=443)1112print(c1) # Config(host='localhost', port=8080, tags=[])13print(c2) # Config(host='prod.example.com', port=443, tags=[])
Gotcha: never use a mutable object (list, dict) as a plain default value in a dataclass. Use field(default_factory=list) instead. The reason is the same shared-mutable-class-attribute problem from Article 2.
frozen=True for immutable value objects
1from dataclasses import dataclass23@dataclass(frozen=True) # instances are immutable and hashable4class Color:5 r: int6 g: int7 b: int89 def to_hex(self):10 return f"#{self.r:02x}{self.g:02x}{self.b:02x}"1112red = Color(255, 0, 0)13print(red.to_hex()) # #ff00001415# Immutable: cannot change attributes16try:17 red.r = 12818except Exception as e:19 print(e)2021# Hashable: can use in sets and as dict keys22palette = {Color(255, 0, 0), Color(0, 255, 0), Color(0, 0, 255)}23print(len(palette)) # 3
@classmethod and @staticmethod
Regular methods receive the instance as self. Two other kinds of methods do not.
@classmethod: alternative constructors
A class method receives the class itself as cls instead of an instance. The main use case is providing alternative ways to create instances (factory methods):
1from dataclasses import dataclass2from datetime import datetime34@dataclass5class Event:6 title: str7 start: datetime8 end: datetime910 @classmethod11 def from_strings(cls, title: str, start: str, end: str) -> "Event":12 fmt = "%Y-%m-%d %H:%M"13 return cls(14 title=title,15 start=datetime.strptime(start, fmt),16 end=datetime.strptime(end, fmt),17 )1819 @classmethod20 def all_day(cls, title: str, date: str) -> "Event":21 from datetime import time22 day = datetime.strptime(date, "%Y-%m-%d")23 return cls(24 title=title,25 start=day.replace(hour=0, minute=0),26 end=day.replace(hour=23, minute=59),27 )2829# Three ways to create an Event30e1 = Event("Launch", datetime(2024, 6, 1, 9, 0), datetime(2024, 6, 1, 17, 0))31e2 = Event.from_strings("Launch", "2024-06-01 09:00", "2024-06-01 17:00")32e3 = Event.all_day("Holiday", "2024-12-25")3334print(e2.title, e2.start)35print(e3.title, e3.start)
@staticmethod: utility that belongs on the class
A static method receives neither self nor cls. It is a plain function that lives in the class namespace because it is conceptually related to the class, even though it does not need access to instance or class state.
1class PasswordPolicy:2 MIN_LENGTH = 1234 @staticmethod5 def is_strong(password: str) -> bool:6 if len(password) < PasswordPolicy.MIN_LENGTH:7 return False8 has_upper = any(c.isupper() for c in password)9 has_digit = any(c.isdigit() for c in password)10 return has_upper and has_digit1112 @staticmethod13 def generate(length: int = 16) -> str:14 import secrets, string15 alphabet = string.ascii_letters + string.digits16 return "".join(secrets.choice(alphabet) for _ in range(length))1718print(PasswordPolicy.is_strong("short")) # False19print(PasswordPolicy.is_strong("LongPass123456")) # True20print(PasswordPolicy.generate()) # random 16-char password
Use @classmethod for factory methods. Use @staticmethod for helpers that belong conceptually to the class but need no access to state. If neither applies, write a module-level function.
Putting it together: a small order system
Here is a minimal but realistic order system that uses everything from this series. Read through it and match each concept to where it appears.
1from __future__ import annotations2from abc import ABC, abstractmethod3from dataclasses import dataclass, field4from typing import Protocol56# --- Value objects with @dataclass ---78@dataclass(frozen=True)9class Money:10 amount: float11 currency: str = "USD"1213 def __add__(self, other: Money) -> Money:14 if self.currency != other.currency:15 raise ValueError("currency mismatch")16 return Money(self.amount + other.amount, self.currency)1718 def __repr__(self):19 return f"{self.currency} {self.amount:.2f}"2021@dataclass22class LineItem:23 name: str24 unit_price: Money25 quantity: int2627 @property28 def total(self) -> Money:29 return Money(self.unit_price.amount * self.quantity, self.unit_price.currency)3031# --- Protocol: anything that can process a payment ---3233class PaymentProcessor(Protocol):34 def charge(self, amount: Money) -> bool: ...3536# --- Concrete processors (no shared base class needed) ---3738class CreditCardProcessor:39 def __init__(self, card_number: str):40 self._card = card_number4142 def charge(self, amount: Money) -> bool:43 print(f"Charging {amount} to card ending {self._card[-4:]}")44 return True4546class WalletProcessor:47 def __init__(self, balance: Money):48 self._balance = balance4950 def charge(self, amount: Money) -> bool:51 if self._balance.amount < amount.amount:52 print("Insufficient wallet balance")53 return False54 self._balance = Money(self._balance.amount - amount.amount)55 print(f"Charged {amount} from wallet. Remaining: {self._balance}")56 return True5758# --- Order: composition, @classmethod, @property ---5960@dataclass61class Order:62 customer_name: str63 items: list[LineItem] = field(default_factory=list)6465 @classmethod66 def new(cls, customer_name: str) -> Order:67 return cls(customer_name)6869 def add_item(self, name: str, unit_price: float, quantity: int) -> Order:70 self.items.append(LineItem(name, Money(unit_price), quantity))71 return self # builder pattern: enables chaining7273 @property74 def total(self) -> Money:75 if not self.items:76 return Money(0.0)77 result = self.items[0].total78 for item in self.items[1:]:79 result = result + item.total80 return result8182 def checkout(self, processor: PaymentProcessor) -> bool:83 print(f"Order for {self.customer_name}: {self.total}")84 for item in self.items:85 print(f" {item.quantity}x {item.name} @ {item.unit_price} = {item.total}")86 return processor.charge(self.total)8788# --- Usage ---8990order = (91 Order.new("Alice")92 .add_item("Coffee", 4.50, 2)93 .add_item("Croissant", 3.25, 1)94)9596cc = CreditCardProcessor("4111111111111234")97ok = order.checkout(cc)98print(f"Payment {'succeeded' if ok else 'failed'}")
Every concept from this series appears somewhere in that code:
- Encapsulation:
WalletProcessor._balanceis internal. Thecharge()method controls how it changes. - Magic methods:
Money.__add__andMoney.__repr__make Money objects behave like first-class values. - @dataclass with frozen=True:
Moneyis immutable. Adding two Money values returns a new one. - @property:
Order.totalandLineItem.totalare computed, not stored. - @classmethod:
Order.new()is a named factory method. - Protocol:
PaymentProcessordefines the contract.checkout()accepts any object that matches it. - Composition:
Orderdoes not inherit from anything. It takes a processor as a dependency and delegates to it.
That is OOP in Python. Not a rigid architecture. A set of tools you reach for when they make the code clearer, more changeable, and easier to test.