OOP, design & patterns
SeniorComposition vs inheritance, SOLID with Python-specific teeth, Singleton/Strategy/Observer/Command/Adapter the Pythonic way, DI, mixins, and when a GoF pattern is just ceremony.
Created 12 September 2026 · Updated 11 October 2026
13 concepts · detail hidden
01Composition over inheritance in Python — and how duck typing changes the calculus
#c-composition-over-inheritance-in-python-and-how-duck-typing-cComposition means building a class by holding references to other objects and delegating to them, rather than inheriting their behavior directly (inheritance) — the general software-design wisdom “favor composition over inheritance” applies in Python too, and for largely the same core reason as anywhere else: inheritance creates tight coupling between a subclass and its base class’s implementation details, and deep hierarchies become hard to reason about (this connects directly to the cooperative-
super()complexity that multiple inheritance can introduce). Where Python specifically changes the calculus: duck typing (“if it walks like a duck and quacks like a duck, treat it as a duck” — i.e., Python cares whether an object behaves correctly for a given use, not what class it formally descends from) means you often don’t need inheritance at all just to make an object usable somewhere a certain interface is expected — a statically-typed language might require formally implementing an interface (or inheriting from an abstract base) purely to satisfy the type checker/compiler, even when composition would otherwise be the natural design; Python doesn’t impose that requirement, so composition is even more consistently viable as a first choice, and reaching for inheritance in Python is more often a genuine design decision (e.g., a true “is-a” relationship, or wanting to reuse a well-established behavioral contract) rather than a language-imposed necessity.02SOLID with Python-specific teeth — a concrete example per principle
#c-solid-with-python-specific-teeth-a-concrete-example-per-prin-
Single Responsibility — a class should have one reason to change. Mixing genuine business logic together with serialization or logging concerns is a real, common, everyday smell in practice:
# violates SRP: order logic AND how it's logged/serialized all live in one place class Order: def total(self) -> float: ... def to_json(self) -> str: ... def log_to_file(self) -> None: ... # split by reason to change: pricing rules, serialization format, and logging # destination can now each evolve independently class Order: def total(self) -> float: ... class OrderSerializer: def to_json(self, order: Order) -> str: ... class OrderLogger: def log(self, order: Order) -> None: ... -
Open/Closed — adding new behavior shouldn’t require modifying existing, already-working branches. A dict-based dispatch table (or
functools.singledispatch) lets new cases register themselves instead of growing anif/elifchain:# violates OCP: supporting a new shape means editing this function again def area(shape): if shape.kind == "circle": return 3.14159 * shape.radius ** 2 elif shape.kind == "square": return shape.side ** 2 # every new shape edits this same function # open for extension: a new shape just registers a handler, no existing # code is touched _handlers = {} def register(kind): def decorator(fn): _handlers[kind] = fn return fn return decorator @register("circle") def _circle_area(shape): return 3.14159 * shape.radius ** 2 @register("square") def _square_area(shape): return shape.side ** 2 def area(shape): return _handlers[shape.kind](shape) -
Liskov Substitution — a subclass must honor its base class’s contract well enough to be used anywhere the base class is expected. The classic violation:
Squareinheriting fromRectanglebut breaking the assumption that settingwidthandheightindependently is safe:class Rectangle: def set_width(self, w: float) -> None: self.width = w def set_height(self, h: float) -> None: self.height = h def area(self) -> float: return self.width * self.height class Square(Rectangle): def set_width(self, w: float) -> None: self.width = self.height = w # silently changes height too def set_height(self, h: float) -> None: self.width = self.height = h # silently changes width too def resize(rect: Rectangle) -> None: rect.set_width(4) rect.set_height(5) assert rect.area() == 20 # passes for Rectangle, fails for Square (area == 25)Squareis a validRectanglemathematically, but not behaviorally — any code written againstRectangle’s contract (independent width/height) breaks when handed aSquare, which is exactly what LSP forbids. -
Interface Segregation — don’t force a class to implement methods it doesn’t need just to satisfy one “fat” interface. Python’s duck typing, plus
Protocol(structural typing — see the Type system & static typing subtopic for the fullProtocolvsABCcomparison), makes narrow, per-use-case interfaces the natural default:from typing import Protocol # violates ISP: a read-only consumer is forced to also implement .write() class Storage(Protocol): def read(self, key: str) -> bytes: ... def write(self, key: str, data: bytes) -> None: ... # segregated: callers depend only on the slice of behavior they actually use class Readable(Protocol): def read(self, key: str) -> bytes: ... class Writable(Protocol): def write(self, key: str, data: bytes) -> None: ... def load_config(storage: Readable) -> bytes: # never needs .write() return storage.read("config") -
Dependency Inversion — depend on an abstraction (a
Protocolor an ABC), not directly on a concrete class, so the concrete implementation can be swapped — most usefully, for a test double:class EmailSender(Protocol): def send(self, to: str, body: str) -> None: ... class Notifier: def __init__(self, sender: EmailSender) -> None: self._sender = sender # depends on the abstraction, not SmtpSender def notify(self, user_email: str) -> None: self._sender.send(user_email, "Hello!") class FakeSender: # satisfies EmailSender structurally, no inheritance needed def __init__(self) -> None: self.sent: list[tuple[str, str]] = [] def send(self, to: str, body: str) -> None: self.sent.append((to, body)) notifier = Notifier(FakeSender()) # swap in a test double, no production code changes
-
03Which SOLID principle Python makes easiest to honor, and which easiest to silently violate
#c-which-solid-principle-python-makes-easiest-to-honor-and-whicInterface Segregation comes largely for free in Python — you’re simply never forced to implement every single method of some overly “fat” interface just to satisfy a formal type contract, the way a statically-typed language requiring explicit interface implementation might genuinely tempt you into doing. Liskov Substitution, by contrast, is genuinely the one Python makes it easiest to silently violate without any real guardrail catching it — absolutely nothing in the language itself stops a subclass from overriding a method with a genuinely incompatible signature or fundamentally different behavior, and without a type checker actively, consistently enforcing genuine signature compatibility (which mypy does check to some meaningful degree, but is very much not universally run across every real codebase), a genuine LSP violation can quietly sit entirely undetected for a long time, until it eventually causes a genuinely confusing runtime surprise somewhere downstream.
04Singleton in Python — the implementations, and why a module-level instance beats them
#c-singleton-in-python-the-implementations-and-why-a-module-levTwo real implementation approaches: a metaclass that intercepts
__call__specifically to return an already-existing instance rather than genuinely constructing a fresh new one each time, or overriding__new__to check for, and return, an existing class-level stored instance reference if one already exists. The more genuinely idiomatic Python alternative, though: modules are already, inherently singletons in Python — imported exactly once per process, cached automatically insys.modules— so simply defining a plain module-level instance (_instance = SomeClass(), then imported and used directly wherever needed) gives you exactly the same “there will only ever be exactly one instance” guarantee, with dramatically less ceremony involved, and it’s genuinely the pattern most experienced Python developers reach for first, well before building any kind of formal Singleton-pattern machinery at all.06The Strategy pattern in idiomatic Python — usually just a function
#c-the-strategy-pattern-in-idiomatic-python-usually-just-a-funcIn idiomatic Python, “Strategy” very often reduces down to simply passing a plain function as the varying behavior — since functions are already genuinely first-class objects in Python, you rarely need to construct a formal interface plus a whole family of dedicated wrapper classes purely to make some piece of behavior swappable; a simple dict mapping names to callables, or a parameter that straightforwardly accepts any callable at all, typically achieves the exact same underlying flexibility with dramatically less code and ceremony. The full, class-based version genuinely still earns its keep specifically when a given “strategy” needs to carry genuinely non-trivial internal state, or needs to expose multiple, meaningfully related methods beyond just a single callable interface — outside of that specific case, it’s frequently just unnecessary ceremony, imported wholesale from a different language culture that genuinely needed classes specifically to achieve the same first-class-function-like flexibility Python already provides natively, for free.
07The GoF Decorator pattern vs Python's `@decorator` syntax — the actual distinction
#c-the-gof-decorator-pattern-vs-pythons-decorator-syntax-the-acThe original Decorator design pattern (from the Gang of Four) wraps an object in another object that implements the very same interface, adding behavior around the wrapped object’s calls without changing its code. The defining trait is that this wrapping is composed dynamically, at runtime — you can stack decorators in whatever combination and order the running program decides, not just whatever was written at the call site.
from abc import ABC, abstractmethod class Coffee(ABC): # the shared interface @abstractmethod def cost(self) -> float: ... @abstractmethod def description(self) -> str: ... class Espresso(Coffee): # the base object being decorated def cost(self) -> float: return 2.0 def description(self) -> str: return "Espresso" class CoffeeDecorator(Coffee): # implements the SAME interface as what it wraps def __init__(self, wrapped: Coffee) -> None: self._wrapped = wrapped def cost(self) -> float: return self._wrapped.cost() def description(self) -> str: return self._wrapped.description() class MilkDecorator(CoffeeDecorator): def cost(self) -> float: return self._wrapped.cost() + 0.5 def description(self) -> str: return self._wrapped.description() + " + milk" class SugarDecorator(CoffeeDecorator): def cost(self) -> float: return self._wrapped.cost() + 0.2 def description(self) -> str: return self._wrapped.description() + " + sugar" order: list[str] = get_user_extras() # e.g. ["milk", "sugar"], decided at runtime drink: Coffee = Espresso() for extra in order: # which decorators, and how many, is a RUNTIME decision if extra == "milk": drink = MilkDecorator(drink) elif extra == "sugar": drink = SugarDecorator(drink) print(drink.description(), drink.cost()) # "Espresso + milk + sugar" 2.7Each decorator holds a reference to the object it wraps and implements the exact same
Coffeeinterface, so decorators can be layered in any order, any number of times, chosen by whatever the program decides while it’s running (orderabove could come from user input, a database, anything) —Espresso()doesn’t need to know it’s being decorated at all.Python’s
@decoratorsyntax, by contrast, is specifically about wrapping functions or classes at definition time, syntactically, as source code is being written — it’s a genuinely close conceptual relative, and can absolutely be used to implement the GoF pattern’s underlying spirit specifically for functions, but a genuine runtime object-composition Decorator pattern like the one above is a genuinely different, if clearly related, construction that Python’s@syntax, on its own, simply doesn’t directly give you — there’s no fixed, small number of@-decorated definitions corresponding to “however many extras the user picked at runtime.” Treating these two as “literally the same thing” misses the distinction between the specific language feature and the broader design pattern it happens to share a name with.08The Observer pattern in Python — what it is, and a full implementation
#c-the-observer-pattern-in-python-what-it-is-and-a-full-implemeObserver defines a one-to-many dependency between objects: when one object (the Subject, sometimes called the Observable) changes state, every object that has registered interest in it (its Observers) gets notified automatically, without the subject needing to know anything concrete about who’s listening or what they’ll do with the notification. The classic GoF shape names three roles: the Subject (holds the list of observers, exposes
subscribe/unsubscribe, and calls back into them on a change), the Observer (an interface with someupdatemethod the subject calls), and a ConcreteObserver (an actual listener implementing that interface — a logger, a UI widget, a metrics counter).from typing import Callable class Subject: def __init__(self) -> None: self._observers: list[Callable[[str], None]] = [] def subscribe(self, observer: Callable[[str], None]) -> None: self._observers.append(observer) def unsubscribe(self, observer: Callable[[str], None]) -> None: self._observers.remove(observer) def notify(self, event: str) -> None: for observer in self._observers: observer(event) class TemperatureSensor(Subject): def set_temperature(self, value: float) -> None: self.notify(f"temperature changed to {value}") def log_to_console(event: str) -> None: print(f"[LOG] {event}") class Display: def __call__(self, event: str) -> None: print(f"[DISPLAY] {event}") sensor = TemperatureSensor() sensor.subscribe(log_to_console) # a plain function sensor.subscribe(Display()) # an object with __call__ sensor.set_temperature(21.5) # both observers fireThe genuinely Pythonic detail here is that
Subjectnever needs a formalObserverinterface at all — anything callable (a plain function, alambda, a bound method, or an instance with__call__) satisfies “is an observer” purely through duck typing, sinceSubjectonly ever doesobserver(event). A stricter, more formally-typed version would define an explicitObserverProtocol/ABCwith anupdatemethod instead, closer to the traditional GoF shape — worth doing if you want a type checker to enforce the contract, but not required for the pattern to work at all.A genuinely hand-rolled version like this is entirely fine for simple, purely in-process, single-application event notification, with no need for persistence, retry logic, or cross-process delivery. Reach instead for a real message broker (Kafka, RabbitMQ, Redis pub-sub) or a framework-level signal system (Django’s own signal system being a common, concrete example) specifically once you genuinely need durability (events reliably surviving even a process crash), genuine cross-process or cross-service delivery, or real delivery guarantees (at-least-once delivery, strict ordering guarantees) that a simple, purely in-memory list of registered callbacks structurally cannot provide at all. This decision ultimately comes down to real distributed-systems tradeoffs, not just whether the hand-rolled version technically works. (Java’s take on the same pattern, with a formal
Observerinterface required rather than optional, is covered in the Java topic’s Design patterns subtopic.)09The Command pattern in Python — encapsulating a request as an object
#c-the-command-pattern-in-python-encapsulating-a-request-as-an-Command turns a request — “call this method, with these arguments, on that object” — into a standalone object of its own, rather than a bare method call disappearing the instant it happens. That reification is what makes the request something you can hand to other code, queue up, log, replay, or undo. The classic roles: the Command interface (typically just an
execute()method, oftenundo()too), a ConcreteCommand (binds together a specific action and the arguments/receiver it needs), the Receiver (the object that actually knows how to perform the underlying work), and the Invoker (holds and triggers commands without knowing anything about what any particular one actually does).from abc import ABC, abstractmethod class Command(ABC): @abstractmethod def execute(self) -> None: ... @abstractmethod def undo(self) -> None: ... class Light: # the Receiver — does the real work def turn_on(self) -> None: print("Light on") def turn_off(self) -> None: print("Light off") class TurnOnCommand(Command): # a ConcreteCommand def __init__(self, light: Light) -> None: self._light = light def execute(self) -> None: self._light.turn_on() def undo(self) -> None: self._light.turn_off() class RemoteControl: # the Invoker def __init__(self) -> None: self._history: list[Command] = [] def submit(self, command: Command) -> None: command.execute() self._history.append(command) def undo_last(self) -> None: if self._history: self._history.pop().undo() light = Light() remote = RemoteControl() remote.submit(TurnOnCommand(light)) # "Light on" remote.undo_last() # "Light off"RemoteControlnever once referencesLightorturn_ondirectly — it only ever calls.execute()/.undo()on whateverCommandit was handed, which is exactly what lets it queue, log, or replay arbitrary commands uniformly. As with Strategy elsewhere in this subtopic, Python’s first-class functions mean a lot of “Command” use cases collapse down to just storing a closure orfunctools.partial— no formal class hierarchy needed — whenever all you actually need is “call this later.” The full class-based version shown above still earns its keep specifically when you need undo/redo (state to reverse an action needs somewhere to live — here, theLightreference itself), queuing or logging commands before/instead of running them immediately, or composing several commands into one (aMacroCommandholding a list of child commands and callingexecute()on each) — capabilities a bare closure can’t cleanly carry the way an object with its own state and multiple methods can.10The Adapter pattern in Python — wrapping a third-party client behind a stable internal interface
#c-the-adapter-pattern-in-python-wrapping-a-third-party-client-A genuinely common, real-world case: wrapping a third-party library’s client — which exposes some particular method signature or return format you don’t fully control — behind your own stable, internal abstraction/interface, so that the rest of your own codebase depends on that stable internal shape, rather than directly on the third-party library’s own actual, potentially-changing shape. This design choice also makes swapping out that particular third-party dependency later (or cleanly mocking it out entirely in tests) meaningfully less invasive across the rest of the codebase.
11Why heavy DI containers are rare in Python, and what idiomatic Python DI looks like
#c-why-heavy-di-containers-are-rare-in-python-and-what-idiomatiPython’s dynamic typing, combined with genuinely first-class functions and pervasive duck typing, generally makes simple, explicit, constructor-level “just directly pass the dependency in as a plain parameter” (manual, explicit dependency injection) fully sufficient for the vast majority of real cases — a heavy DI container framework, performing reflection-based automatic wiring, exists specifically to solve a problem (verbose, ceremony-heavy explicit wiring code) that’s genuinely much smaller to begin with in Python, since you’re not simultaneously fighting against a rigid static type system just to keep dependencies conveniently swappable. A formal DI framework does start to genuinely earn its keep in large applications with genuinely deep, complex dependency graphs, where purely manual wiring itself becomes a real, ongoing maintenance burden in its own right — a more nuanced position than a blanket “Python never needs DI frameworks at all.”
12Mixins vs Strategy/Decorator for composing behavior — and the risk of deep mixin chains
#c-mixins-vs-strategydecorator-for-composing-behavior-and-the-rA mixin is a small class that’s never meant to be instantiated on its own — it exists purely to be combined with other classes via multiple inheritance, contributing one piece of reusable behavior each. It’s a genuinely Python/Ruby-flavored technique, meaningfully less prominent in the classic GoF pattern catalog (which leans much more heavily toward composition-based patterns overall).
class LoggingMixin: def save(self) -> None: print("logging: about to save") super().save() # hands off to whatever's next in the MRO class CachingMixin: def save(self) -> None: print("caching: invalidating cache") super().save() class Base: def save(self) -> None: print("actually saving to the database") class Model(LoggingMixin, CachingMixin, Base): pass Model().save() # logging: about to save # caching: invalidating cache # actually saving to the databaseEach mixin calls
super().save()to pass control along, trusting that something further down the chain will eventually do the real work — but readingLoggingMixinalone never tells you what that “something” is, or in what order it runs relative to other mixins. That order comes entirely from the base-class list onModel, resolved by Python’s MRO (C3 linearization):Model.__mro__here isModel → LoggingMixin → CachingMixin → Base → object, sosave()runs logging first, then caching, then the real save — purely because of the order they were listed inclass Model(LoggingMixin, CachingMixin, Base).This is the concrete risk: swap the order, and behavior silently changes, with no error from either mixin’s own code:
class Model(CachingMixin, LoggingMixin, Base): # order flipped pass Model().save() # caching: invalidating cache # logging: about to save # actually saving to the databaseNothing in
CachingMixinorLoggingMixinchanged — only the list onModeldid — but the runtime behavior flipped. With two mixins this is easy to spot; with five or six stacked on a real class, figuring out what actually runs, and in what order, means mentally computing the MRO rather than reading any single class in isolation. That’s the “deep mixin chain” problem: behavior becomes a property of the combination, not of any one piece.Strategy (a plain callable, swapped in explicitly) or the Decorator pattern (explicit object wrapping, see above) make that same ordering visible at the point of construction instead of hidden inside a base-class list:
class Saveable: def save(self) -> None: print("actually saving to the database") class LoggingDecorator: def __init__(self, wrapped) -> None: self._wrapped = wrapped def save(self) -> None: print("logging: about to save") self._wrapped.save() class CachingDecorator: def __init__(self, wrapped) -> None: self._wrapped = wrapped def save(self) -> None: print("caching: invalidating cache") self._wrapped.save() # the order is right here, explicit, and easy to read top to bottom — # no MRO computation required to know what runs first model = LoggingDecorator(CachingDecorator(Saveable())) model.save()Same layered behavior, but the order is a plain, local fact at the call site (
LoggingDecorator(CachingDecorator(Saveable()))) rather than something that falls out of howclass Model(...)happened to list its bases. Preferring composition over deep multiple-inheritance mixin chains past a certain complexity threshold is genuine, hard-earned production judgment, not just a stylistic preference — mixins stay reasonable for one or two small, independent, well-named pieces of behavior; past that, the hidden-ordering cost tends to outweigh the brevity.13Design patterns a stricter OOP background reaches for that idiomatic Python skips
#c-design-patterns-a-stricter-oop-background-reaches-for-that-iIdiomatic Python consistently trades formal OOP machinery for simpler alternatives, echoing the specific pattern-by-pattern alternatives discussed throughout this section: a plain function instead of a formal Strategy hierarchy, a simple module-level instance instead of a formal Singleton, or a straightforward dict-based dispatch table instead of an elaborate Factory class hierarchy. The throughline across all of these is that Python’s first-class functions, duck typing, and dynamic typing remove much of the ceremony that a more rigidly class-based OOP language needs formal patterns to work around.