Skillversitas

OOP Best Practices

After twenty-one chapters of mechanisms, the discipline that separates clean object code from a class sprawl is a set of design rules: choose composition over inheritance by default, keep interfaces small and honest, let the protocol layer (dunders, properties) do the work a method call would do, and make the failure modes loud. This chapter collects the rules, each tied to the chapter that introduced the mechanism it governs - because the practices are not new ideas, they are the decision criteria for the tools you now have.

Rule 1: Inherit for Is-a, Compose for Has-a

The single highest-leverage decision. Ask the is-a test before writing class Child(Parent): can the child be used everywhere the parent is expected, without the consumer special-casing it? If yes, inheritance is honest and the MRO and super() machinery (chapters 8-9) work for you. If the relation is "uses some of the parent's code" or "has one of the parent's parts," it is composition (chapter 18) - own the component, delegate the calls. The smells that betray a wrong inheritance: a subclass that overrides and never calls super() (it has decided the parent's setup is irrelevant - that is not is-a), a class that is two full kinds via multiple inheritance (compose them), and if isinstance(x, Child) scattered through the base's consumers (the Liskov contract is broken). The tiebreaker when unsure: compose - the forwarding lines are cheap, the wrong base class is a hierarchy you spend quarters unwinding.

Rule 2: Small Interfaces, Honest Names

A class's public surface is its attributes without underscores plus its methods, and the surface should be the minimum that does the job. Every public name is a promise someone can rely on, so adding one is a decision, not a default. The practices: keep __init__ parameters to what construction genuinely requires (optional state as defaults or a builder, chapter 3); name methods for what they give or do (area, deposit), not for how (compute_the_total_value); and use the encapsulation conventions (chapter 6) deliberately - public for the interface, _name for internal state, __name for state that must survive subclassing. A class that is mostly private state with three public methods is a well-shaped class; a class with fifteen public methods is a namespace that should be split. When a class grows past the point where its docstring is a paragraph, that is the signal to decompose - usually into a composed component (chapter 18) rather than a deeper hierarchy.

Rule 3: Use the Protocol Layer, Not Parallel Methods

Python's dunders (chapter 11) exist so your objects participate in the language's operations. The practice: when a user of your class would naturally write len(x), x == y, for item in x:, or print(x), define the dunder instead of a count()/equals()/items()/describe() method - the operator or built-in is the interface the caller already expects. The pairs must be defined together (__eq__ with __hash__, __repr__ always, __str__ when the human form differs), and operator overloading (chapter 12) is reserved for things that are genuinely mathematical - the moment + means "and then," it is a method in disguise. The test is the caller's perspective: if the natural call is a built-in or operator, the dunder is the honest interface; if the natural call is a named domain operation, the method is.

Rule 4: Validate at the Boundary, Fail Loud

Invariants are enforced where bad data enters: __init__ for constructor arguments (chapter 3), each method for its own arguments, and a property (chapter 14) for attributes that must be checked on every set. The practice is to fail immediately with a specific ValueError/TypeError that names the problem, not to store the bad value and fail three calls later where the cause is invisible. The custom exception hierarchy (chapter 19) is the public form of the same rule at a library boundary - a module base with specific children, structured context in attributes, the message in args, and from-chaining when translating a low-level error. And the context-manager rule (chapter 20) is the resource version: anything acquired is released structurally by a with, never by a remembered close(). The common thread: make the failure happen at the boundary, with a message that points at the cause, instead of letting it propagate as a wrong result.

Rule 5: Reach for the Modern Tools First

The language has a standing offer of less boilerplate, and the default should be to take it. @dataclass (chapter 17) for any object that is primarily data - the __init__, __repr__, __eq__ are generated, and frozen=True makes a value type for free. Type hints (chapter 15) on everything public, so the interface is checkable and the Self type keeps fluent chains honest. A Protocol (chapter 10) or an ABC (chapter 16) at the boundaries where the contract is read by others. And __init_subclass__ or a decorator (chapter 21) before a metaclass, @contextmanager before a hand-rolled manager (chapter 20), a property before a get_x/set_x pair (chapter 14). The practice is not "use the newest thing" - it is "the standard tool for a standard shape beats the hand-rolled one," because the standard tool is what every reader recognizes and every checker understands.

The Review Checklist

  1. Is each class one thing? A paragraph docstring means split it - compose the parts.
  2. Does every inheritance pass the is-a test? No isinstance special-casing in the base's consumers, no override that skips super() without a reason.
  3. Is the public surface minimal? Every public name earns its place; internals are _name.
  4. Are the dunders complete? __eq__ has a __hash__ (or is deliberately unhashable), __repr__ exists, operators are only on things that are that thing.
  5. Do invariants hold at every boundary? __init__ validates, setters validate, resources are with-managed, errors are specific and chained.
  6. Is the modern tool used where it fits? dataclass for data, hints on the interface, Protocol/ABC at the boundary, __init_subclass__ before metaclass.

Summary

The practices are the decision criteria for the mechanisms: inherit only for a genuine is-a and compose for everything else (the highest-leverage rule); keep the public surface minimal and the names honest; use the dunder protocol layer when the caller's natural call is a built-in or operator, and methods for domain operations; validate at every boundary and fail loud with specific, chained exceptions; and reach for the standard modern tools - dataclass, type hints, Protocol/ABC, __init_subclass__, @contextmanager - before hand-rolling the equivalent. The review checklist is the six questions that catch the violations: one thing per class, is-a that holds, minimal surface, complete dunders, invariants at the boundary, and the standard tool where it fits. Mastered together, they are what turn the twenty-one chapters of machinery into a codebase that stays readable as it grows - the object model working as a design system instead of a box of parts.