Building Strong Object-Oriented Design Skills

Object-oriented design is the craft of choosing which data and behavior belong together, how classes talk to each other, and where the seams in a system should be. Most real programs have at least a few classes; designing them well is what keeps the code understandable at 10,000 lines instead of drowning at 1,000.

The core guideline is one simple question asked in a dozen forms: does this behaviour belong to this concept? If User needs to know how to save itself to the database, maybe a UserRepository deserves to exist. If Order needs to calculate tax from three country-specific rulesets, maybe TaxPolicy is its own class. Always keep classes small and their responsibilities clear.

The SOLID principles are a classic summary of what “well-designed OO” looks like. Single Responsibility: one reason to change per class. Open/Closed: extend via new classes, not by editing old ones. Liskov Substitution: subclasses should honor the base class's contract. Interface Segregation: prefer small, focused interfaces. Dependency Inversion: depend on abstractions, not concretions. In Python, Protocol gives you the fifth almost for free.

Python-specific guidance: lean on @dataclass for plain records, use composition before inheritance, use Protocol for structural interfaces, keep __init__ simple, reserve __dunder__ methods for things that really are part of the object's language (iteration, comparison, representation). Avoid deep inheritance chains — two levels is usually plenty.

Classes earn their keep

Use a class when you have state + behavior that naturally belong together, when several functions would otherwise share a config or session, or when you want to enforce invariants on construction. A module of functions is often simpler and should be the default.

Keep constructors boring: assign arguments to attributes, maybe normalize them, do nothing with side effects. All real work belongs in explicit methods that can be tested.

Composition, abstraction, tests

Prefer wrapping another class as an attribute (“has-a”) to inheriting from it (“is-a”). Inheritance couples the child to the parent's internals; composition forces a clean interface.

Tests are the feedback loop for design. If a class is hard to test, it is probably doing too much. Split responsibilities until each class has one thing to prove.

OO design references.

ToolPurpose
SOLID
principles
Classic OO design guidelines.
@dataclass
decorator
Records with auto-generated methods.
Protocol
class
Structural interface (duck-typed).
abc.ABC
class
Abstract base classes.
refactoring.guru
site
Clear design-pattern catalogues.
PEP 3119
spec
ABCs in Python.
dunder methods
docs
__eq__, __iter__, __repr__, ...
Composition over inheritance
article
Canonical design advice.

Building Strong Object-Oriented Design Skills code example

The script refactors a “god class” into three focused classes using dataclasses, composition, and a Protocol.

# Lesson: Building Strong Object-Oriented Design Skills
from dataclasses import dataclass
from typing import Protocol


# ---- before: one class does everything ----
class OrderGod:
    def __init__(self, items, country):
        self.items = items
        self.country = country

    def subtotal(self):
        return sum(p * q for p, q in self.items)

    def tax(self):
        return self.subtotal() * (0.25 if self.country == "NO" else 0.20)

    def render_receipt(self):
        s = self.subtotal(); t = self.tax()
        return f"subtotal {s:.2f} tax {t:.2f} total {s+t:.2f}"


# ---- after: three focused classes + a Protocol ----
@dataclass(frozen=True)
class Order:
    items: list[tuple[float, int]]   # (price, qty)

    def subtotal(self) -> float:
        return sum(p * q for p, q in self.items)


class TaxPolicy(Protocol):
    def tax(self, subtotal: float) -> float: ...


@dataclass(frozen=True)
class FlatTaxPolicy:
    rate: float

    def tax(self, subtotal: float) -> float:
        return subtotal * self.rate


@dataclass(frozen=True)
class ReceiptFormatter:
    def render(self, order: Order, policy: TaxPolicy) -> str:
        s = order.subtotal()
        t = policy.tax(s)
        return f"subtotal {s:.2f} tax {t:.2f} total {s+t:.2f}"


order = Order(items=[(100.0, 1), (50.0, 2)])
policy_no = FlatTaxPolicy(rate=0.25)
policy_eu = FlatTaxPolicy(rate=0.20)

fmt = ReceiptFormatter()
print("NO :", fmt.render(order, policy_no))
print("EU :", fmt.render(order, policy_eu))


# Same service works if a new policy class appears tomorrow
class ProgressiveTaxPolicy:
    def tax(self, subtotal: float) -> float:
        if subtotal > 500:
            return 100 + (subtotal - 500) * 0.3
        return subtotal * 0.2


print("prog:", fmt.render(order, ProgressiveTaxPolicy()))

Observe the design moves:

1) Data (Order) is separate from policy (TaxPolicy) and from presentation (ReceiptFormatter).
2) Protocol enables plugging in any policy class without inheritance.
3) Each class has one reason to change.
4) Testing each piece in isolation is straightforward.

Add a DiscountPolicy the same way.

from typing import Protocol
class DiscountPolicy(Protocol):
    def apply(self, subtotal: float) -> float: ...
class NoDiscount:
    def apply(self, s): return s
class PercentOff:
    def __init__(self, pct): self.pct = pct
    def apply(self, s): return s * (1 - self.pct)
# Service can now depend on Order, TaxPolicy, DiscountPolicy — each swappable.

Behavior preserved across refactor.

o = Order([(100.0, 1), (50.0, 2)])
assert abs(o.subtotal() - 200.0) < 1e-6
assert abs(FlatTaxPolicy(0.25).tax(o.subtotal()) - 50.0) < 1e-6

Running prints:

NO : subtotal 200.00 tax 50.00 total 250.00
EU : subtotal 200.00 tax 40.00 total 240.00
prog: subtotal 200.00 tax 40.00 total 240.00