Codetail

Article 8 of 8

Composition and Modern OOP

The tools that make OOP actually pleasant.

22 min read

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:

Python
1class Logger:
2 def log(self, msg):
3 print(f"[LOG] {msg}")
4
5class Emailer:
6 def email(self, to, body):
7 print(f"Email to {to}: {body}")
8
9# Multiple inheritance to "get" both
10class UserService(Logger, Emailer):
11 def create_user(self, name, email):
12 self.log(f"Creating {name}")
13 self.email(email, f"Welcome, {name}!")
14
15svc = 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:

Python
1class Logger:
2 def log(self, msg):
3 print(f"[LOG] {msg}")
4
5class Emailer:
6 def email(self, to, body):
7 print(f"Email to {to}: {body}")
8
9class UserService:
10 def __init__(self, logger: Logger, emailer: Emailer):
11 self._logger = logger
12 self._emailer = emailer
13
14 def create_user(self, name, email):
15 self._logger.log(f"Creating {name}")
16 self._emailer.email(email, f"Welcome, {name}!")
17
18# Production
19svc = UserService(Logger(), Emailer())
20svc.create_user("Alice", "alice@example.com")
21
22# In tests: pass a silent logger, stub emailer
23class SilentLogger:
24 def log(self, msg): pass
25
26class FakeEmailer:
27 def __init__(self):
28 self.sent = []
29 def email(self, to, body):
30 self.sent.append((to, body))
31
32fake_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.

Python
1from dataclasses import dataclass
2
3@dataclass
4class Point:
5 x: float
6 y: float
7
8# __init__, __repr__, and __eq__ are all generated
9p1 = Point(1.0, 2.0)
10p2 = Point(1.0, 2.0)
11p3 = Point(3.0, 4.0)
12
13print(p1) # Point(x=1.0, y=2.0)
14print(p1 == p2) # True
15print(p1 == p3) # False

Default values and field()

Python
1from dataclasses import dataclass, field
2
3@dataclass
4class Config:
5 host: str = "localhost"
6 port: int = 8080
7 tags: list[str] = field(default_factory=list) # mutable defaults need field()
8
9c1 = Config()
10c2 = Config(host="prod.example.com", port=443)
11
12print(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

Python
1from dataclasses import dataclass
2
3@dataclass(frozen=True) # instances are immutable and hashable
4class Color:
5 r: int
6 g: int
7 b: int
8
9 def to_hex(self):
10 return f"#{self.r:02x}{self.g:02x}{self.b:02x}"
11
12red = Color(255, 0, 0)
13print(red.to_hex()) # #ff0000
14
15# Immutable: cannot change attributes
16try:
17 red.r = 128
18except Exception as e:
19 print(e)
20
21# Hashable: can use in sets and as dict keys
22palette = {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):

Python
1from dataclasses import dataclass
2from datetime import datetime
3
4@dataclass
5class Event:
6 title: str
7 start: datetime
8 end: datetime
9
10 @classmethod
11 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 )
18
19 @classmethod
20 def all_day(cls, title: str, date: str) -> "Event":
21 from datetime import time
22 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 )
28
29# Three ways to create an Event
30e1 = 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")
33
34print(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.

Python
1class PasswordPolicy:
2 MIN_LENGTH = 12
3
4 @staticmethod
5 def is_strong(password: str) -> bool:
6 if len(password) < PasswordPolicy.MIN_LENGTH:
7 return False
8 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_digit
11
12 @staticmethod
13 def generate(length: int = 16) -> str:
14 import secrets, string
15 alphabet = string.ascii_letters + string.digits
16 return "".join(secrets.choice(alphabet) for _ in range(length))
17
18print(PasswordPolicy.is_strong("short")) # False
19print(PasswordPolicy.is_strong("LongPass123456")) # True
20print(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.

Python
1from __future__ import annotations
2from abc import ABC, abstractmethod
3from dataclasses import dataclass, field
4from typing import Protocol
5
6# --- Value objects with @dataclass ---
7
8@dataclass(frozen=True)
9class Money:
10 amount: float
11 currency: str = "USD"
12
13 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)
17
18 def __repr__(self):
19 return f"{self.currency} {self.amount:.2f}"
20
21@dataclass
22class LineItem:
23 name: str
24 unit_price: Money
25 quantity: int
26
27 @property
28 def total(self) -> Money:
29 return Money(self.unit_price.amount * self.quantity, self.unit_price.currency)
30
31# --- Protocol: anything that can process a payment ---
32
33class PaymentProcessor(Protocol):
34 def charge(self, amount: Money) -> bool: ...
35
36# --- Concrete processors (no shared base class needed) ---
37
38class CreditCardProcessor:
39 def __init__(self, card_number: str):
40 self._card = card_number
41
42 def charge(self, amount: Money) -> bool:
43 print(f"Charging {amount} to card ending {self._card[-4:]}")
44 return True
45
46class WalletProcessor:
47 def __init__(self, balance: Money):
48 self._balance = balance
49
50 def charge(self, amount: Money) -> bool:
51 if self._balance.amount < amount.amount:
52 print("Insufficient wallet balance")
53 return False
54 self._balance = Money(self._balance.amount - amount.amount)
55 print(f"Charged {amount} from wallet. Remaining: {self._balance}")
56 return True
57
58# --- Order: composition, @classmethod, @property ---
59
60@dataclass
61class Order:
62 customer_name: str
63 items: list[LineItem] = field(default_factory=list)
64
65 @classmethod
66 def new(cls, customer_name: str) -> Order:
67 return cls(customer_name)
68
69 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 chaining
72
73 @property
74 def total(self) -> Money:
75 if not self.items:
76 return Money(0.0)
77 result = self.items[0].total
78 for item in self.items[1:]:
79 result = result + item.total
80 return result
81
82 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)
87
88# --- Usage ---
89
90order = (
91 Order.new("Alice")
92 .add_item("Coffee", 4.50, 2)
93 .add_item("Croissant", 3.25, 1)
94)
95
96cc = 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._balance is internal. The charge() method controls how it changes.
  • Magic methods: Money.__add__ and Money.__repr__ make Money objects behave like first-class values.
  • @dataclass with frozen=True: Money is immutable. Adding two Money values returns a new one.
  • @property: Order.total and LineItem.total are computed, not stored.
  • @classmethod: Order.new() is a named factory method.
  • Protocol: PaymentProcessor defines the contract. checkout() accepts any object that matches it.
  • Composition: Order does 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.