Filter concepts by levelShowing all levels.

Python · Architecture and Design Patterns

Patterns

Concepts
10

The named object-oriented patterns a working engineer is expected to recognize and apply — creational (Factory, Abstract Factory, Builder), structural (Adapter, Decorator), behavioral (Strategy, Observer, Command), and the architectural seams (Repository, Service Layer, Dependency Injection) that connect layers.

This section

Creating and adapting objects

Patterns that control how an object gets built (Factory, Abstract Factory, Builder) or reshaped to match an interface (Adapter).

Factory and Abstract Factory

coreintermediate

A Factory is a function that picks which class to instantiate based on input, so callers never write ClassName(...) directly. An Abstract Factory is a family of factories that each produce a matching set of related objects.

Think of it as

Without a factory, the caller writes if channel == "email": EmailNotifier() else: SmsNotifier() itself — that branching gets copy-pasted everywhere a notifier is created. A factory function centralizes that decision once; callers just ask notifier_factory("email") and get the right object back. An Abstract Factory extends the same idea to a whole family: instead of one object, DarkThemeFactory produces a DarkButton AND a DarkCheckbox that are guaranteed to match, so a caller building a toolbar never accidentally mixes a dark button with a light checkbox.

python
def notifier_factory(channel: str):
    if channel == "email":
        return EmailNotifier()
    if channel == "sms":
        return SmsNotifier()
    raise ValueError(f"unknown channel: {channel}")

notifier = notifier_factory("email")
notifier.send("your order shipped")

What we're doing: Build a Factory that picks a notification channel, then an Abstract Factory that produces a matching family of themed UI widgets.

factory_demo.pypython
from abc import ABC, abstractmethod


class EmailNotifier:
    def send(self, message):
        return f"Email: {message}"


class SmsNotifier:
    def send(self, message):
        return f"SMS: {message}"


def notifier_factory(channel):
    if channel == "email":
        return EmailNotifier()
    if channel == "sms":
        return SmsNotifier()
    raise ValueError(f"unknown channel: {channel}")


class Button(ABC):
    @abstractmethod
    def render(self): ...

class Checkbox(ABC):
    @abstractmethod
    def render(self): ...

class DarkButton(Button):
    def render(self):
        return "[dark button]"

class DarkCheckbox(Checkbox):
    def render(self):
        return "[dark checkbox]"


class WidgetFactory(ABC):
    @abstractmethod
    def create_button(self) -> Button: ...
    @abstractmethod
    def create_checkbox(self) -> Checkbox: ...

class DarkThemeFactory(WidgetFactory):
    def create_button(self):
        return DarkButton()
    def create_checkbox(self):
        return DarkCheckbox()


def render_toolbar(factory: WidgetFactory):
    button = factory.create_button()
    checkbox = factory.create_checkbox()
    return f"{button.render()} {checkbox.render()}"


notifier = notifier_factory("email")
print(notifier.send("your order shipped"))
print(render_toolbar(DarkThemeFactory()))
10
notifier_factory is a plain function — the whole Factory pattern is this if/return branching in one place.
34
WidgetFactory declares one creation method per product in the family (button, checkbox).
35
DarkThemeFactory implements both methods so every widget it returns matches the same theme.
Output
Email: your order shipped
[dark button] [dark checkbox]

Why this works: render_toolbar never names DarkButton or DarkCheckbox — it only calls factory.create_button()/create_checkbox(), so swapping in a LightThemeFactory later changes nothing about render_toolbar itself. That is the payoff: new variants are new classes, not new branches scattered through calling code.

Reaching for Abstract Factory when a plain Factory already solves it

Wrong

python
# Only ONE product type (Notifier) — an Abstract Factory here is pure overhead
class NotifierFactory(ABC):
    @abstractmethod
    def create_notifier(self): ...

class EmailNotifierFactory(NotifierFactory):
    def create_notifier(self):
        return EmailNotifier()

Better

python
# One product type -> a plain function is enough
def notifier_factory(channel):
    if channel == "email":
        return EmailNotifier()
    if channel == "sms":
        return SmsNotifier()
    raise ValueError(f"unknown channel: {channel}")

What you see: Not a runtime error — a design smell: a class hierarchy with exactly one method and one implementation per branch, doing what an if/return already did in a third of the code.

Why: Abstract Factory earns its complexity when there is a FAMILY of related products that must stay consistent (a button that matches a checkbox). With a single product type, it just adds classes without adding any guarantee a function did not already provide.

A factory hides the concrete class behind one call

notifier_factory("email")

caller states intent, not a class name

picks EmailNotifier

the factory owns the if/elif branching

notifier.send(msg)

caller only ever sees the shared interface

  1. notifier_factory("email") — caller states intent, not a class name
  2. picks EmailNotifier — the factory owns the if/elif branching
  3. notifier.send(msg) — caller only ever sees the shared interface

Factory vs. Abstract Factory

Factory vs. Abstract Factory
PatternProducesAdding a new variant
FactoryOne object of a chosen typeAdd one branch or one new factory function
Abstract FactoryA matching family of related objectsAdd one new concrete factory implementing every creation method

Together

python
def notifier_factory(channel):
    if channel == "email":
        return EmailNotifier()
    if channel == "sms":
        return SmsNotifier()
    raise ValueError(f"unknown channel: {channel}")

class DarkThemeFactory(WidgetFactory):
    def create_button(self):
        return DarkButton()
    def create_checkbox(self):
        return DarkCheckbox()

Remember: Factory centralizes "which class do I instantiate?" behind one function; Abstract Factory centralizes it for a whole family of related objects that must stay consistent with each other.

See also: strategy · builder · classes and objects

Builder

coreintermediate

Builder constructs a complex object through a chain of method calls that each return self, ending in build(). HttpRequestBuilder().method("POST").url("/orders").build() reads as a sequence of decisions instead of one huge constructor call.

Think of it as

A constructor with ten optional parameters forces every caller to remember argument order and pass None for the ones they skip. A Builder instead exposes one method per piece of configuration — .method(...), .header(...) — and each one returns self so calls chain. Nothing is actually built until .build() runs, so the object can enforce "url is required" in one place instead of validating a giant argument list.

python
class HttpRequestBuilder:
    def __init__(self):
        self._request = HttpRequest()

    def method(self, method):
        self._request.method = method
        return self             # enables chaining

    def build(self):
        if self._request.url is None:
            raise ValueError("url is required")
        return self._request

What we're doing: Build an HttpRequest through a chain of configuration calls, validating a required field only at .build() time.

builder_demo.pypython
class HttpRequest:
    def __init__(self):
        self.method = "GET"
        self.url = None
        self.headers = {}
        self.body = None

    def __repr__(self):
        return f"HttpRequest({self.method} {self.url}, headers={self.headers}, body={self.body!r})"


class HttpRequestBuilder:
    def __init__(self):
        self._request = HttpRequest()

    def method(self, method):
        self._request.method = method
        return self

    def url(self, url):
        self._request.url = url
        return self

    def header(self, key, value):
        self._request.headers[key] = value
        return self

    def body(self, body):
        self._request.body = body
        return self

    def build(self):
        if self._request.url is None:
            raise ValueError("url is required")
        return self._request


request = (
    HttpRequestBuilder()
    .method("POST")
    .url("/orders")
    .header("Content-Type", "application/json")
    .body('{"sku": "sku-1"}')
    .build()
)
print(request)
28
Each configuration method (method, url, header, body) mutates the in-progress request and returns self, allowing the next call to chain directly onto it.
34
build() is the only place url is validated as required — every other field is optional and defaults sensibly.
Output
HttpRequest(POST /orders, headers={'Content-Type': 'application/json'}, body='{"sku": "sku-1"}')

Why this works: The chain reads like a sentence describing the request being assembled, and only .build() enforces that a url was actually provided — a caller who forgets .url(...) gets one clear ValueError instead of a request that silently has url=None.

Forgetting to return self from a builder method

Wrong

python
class HttpRequestBuilder:
    def method(self, method):
        self._request.method = method
        # no return — falls through to None

HttpRequestBuilder().method("POST").url("/orders")  # AttributeError: 'NoneType' object has no attribute 'url'

Better

python
class HttpRequestBuilder:
    def method(self, method):
        self._request.method = method
        return self   # required for the next call in the chain to work

What you see: AttributeError: 'NoneType' object has no attribute 'url' — chaining breaks at the exact method that forgot to return self.

Why: Every builder method must return self for the fluent chain (.method(...).url(...)) to keep working — a method that returns None (Python's implicit default) breaks the chain at that exact point.

Each chained call configures one piece, build() assembles the result

.method("POST")

returns self — chainable

.url(...).header(...)

each call sets one field, returns self

.build()

validates required fields, returns the finished object

  1. .method("POST") — returns self — chainable
  2. .url(...).header(...) — each call sets one field, returns self
  3. .build() — validates required fields, returns the finished object

Builder vs. a many-argument constructor

Builder vs. a many-argument constructor
ApproachAdding one more optional fieldReadability at the call site
Constructor with many kwargsEvery caller's call site risks silently keeping old defaultsHttpRequest("POST", "/orders", {"Content-Type": "json"}, None, ...)
BuilderAdd one chainable method; existing calls are unaffected.method("POST").url("/orders").header(...).build()

Together

python
request = (
    HttpRequestBuilder()
    .method("POST")
    .url("/orders")
    .header("Content-Type", "application/json")
    .build()
)

Remember: Builder assembles a complex object through chained, self-returning method calls, validating required fields only at .build() — it replaces a constructor with many optional, order-dependent arguments.

See also: factory and abstract factory · decorator

Adapter

coreintermediate

An Adapter wraps an object whose interface does not match what your code expects, translating calls between the two. LegacyXmlAdapter makes an old XML-returning client usable anywhere a get_items() list is expected.

Think of it as

A US laptop plug does not fit a UK socket — the travel adapter does not change the laptop or the socket, it sits between them translating one shape into the other. LegacyXmlAdapter is the same: LegacyXmlParser.fetch_data() returns an XML string and cannot be changed (third-party), but the rest of the code expects a get_items() call returning a plain list. The adapter implements get_items() and internally calls fetch_data(), parses the XML, and returns a list — every caller only ever sees get_items().

python
class JsonDataSource(ABC):
    @abstractmethod
    def get_items(self): ...

class LegacyXmlAdapter(JsonDataSource):
    def __init__(self, legacy_parser):
        self.legacy_parser = legacy_parser

    def get_items(self):
        raw = self.legacy_parser.fetch_data()
        return [parse(raw)]

What we're doing: Wrap a legacy XML-returning client behind the get_items() interface the rest of the code expects, without modifying the legacy client.

adapter_demo.pypython
from abc import ABC, abstractmethod


class LegacyXmlParser:
    """Third-party client we cannot modify — returns XML-flavored strings."""
    def fetch_data(self):
        return "<data><item>widget</item></data>"


class JsonDataSource(ABC):
    @abstractmethod
    def get_items(self):
        ...


class LegacyXmlAdapter(JsonDataSource):
    def __init__(self, legacy_parser: LegacyXmlParser):
        self.legacy_parser = legacy_parser

    def get_items(self):
        raw = self.legacy_parser.fetch_data()
        start = raw.index("<item>") + len("<item>")
        end = raw.index("</item>")
        return [raw[start:end]]


def print_items(source: JsonDataSource):
    for item in source.get_items():
        print(f"item: {item}")


adapter = LegacyXmlAdapter(LegacyXmlParser())
print_items(adapter)
9
JsonDataSource is the target interface — the only thing print_items is written against.
15
LegacyXmlAdapter holds the untouched LegacyXmlParser and implements get_items() on top of it.
16
get_items() calls fetch_data() internally and translates the XML string into the list callers expect.
Output
item: widget

Why this works: print_items only calls source.get_items() — it never touches LegacyXmlParser or knows XML is involved. The adapter absorbs that translation entirely, so LegacyXmlParser stays completely unmodified and print_items works with any future JsonDataSource implementation too.

Modifying the legacy class instead of adapting it

Wrong

python
# Editing a third-party/vendored class directly
class LegacyXmlParser:
    def fetch_data(self):
        return "<data><item>widget</item></data>"

    def get_items(self):          # bolted on — breaks on the next vendor update
        raw = self.fetch_data()
        start = raw.index("<item>") + len("<item>")
        return [raw[start:raw.index("</item>")]]

Better

python
# Legacy class stays untouched; translation lives in a separate adapter
class LegacyXmlAdapter(JsonDataSource):
    def __init__(self, legacy_parser):
        self.legacy_parser = legacy_parser

    def get_items(self):
        raw = self.legacy_parser.fetch_data()
        start = raw.index("<item>") + len("<item>")
        return [raw[start:raw.index("</item>")]]

What you see: No exception at first — but the next library upgrade overwrites the hand-added get_items() method, or a vendored copy silently drifts from upstream and stops receiving fixes.

Why: Third-party or legacy code gets replaced wholesale on update — anything added directly to it disappears. An adapter is a separate class you own, so it survives the adaptee being upgraded, swapped, or even deleted.

Adapter translates between two incompatible interfaces

caller wants get_items()

the interface the rest of the code expects

LegacyXmlAdapter

implements get_items(), calls fetch_data() internally

LegacyXmlParser

unchanged third-party class, returns XML

  1. caller wants get_items() — the interface the rest of the code expects
  2. LegacyXmlAdapter — implements get_items(), calls fetch_data() internally
  3. LegacyXmlParser — unchanged third-party class, returns XML

Adapter's three parts

Adapter's three parts
RoleIn the exampleResponsibility
Target interfaceJsonDataSource.get_items()What calling code actually expects to call
AdapteeLegacyXmlParser.fetch_data()The existing, incompatible interface being wrapped
AdapterLegacyXmlAdapterImplements the target, translates to/from the adaptee internally

Together

python
class LegacyXmlAdapter(JsonDataSource):
    def __init__(self, legacy_parser):
        self.legacy_parser = legacy_parser

    def get_items(self):                      # target interface
        raw = self.legacy_parser.fetch_data()  # calls the adaptee
        return [parse(raw)]                    # translates the result

Remember: Adapter wraps an incompatible interface behind the one your code expects, translating each call internally — the wrapped class never changes, and callers never know it is there.

See also: decorator · repository · abstract base classes

Advertisement

Behavior and application seams

Patterns that swap or react to behavior at runtime (Strategy, Observer, Command, Decorator), and the seams between application layers (Repository, Service Layer, Dependency Injection).

Strategy

coreintermediate

Strategy pulls an algorithm out into its own object and hands it to the class that needs it. Cart takes a shipping_strategy object instead of an if/elif on a shipping-method string.

Think of it as

Without Strategy, Cart.shipping_cost() has an if method == "standard": ... elif method == "express": ... — every new shipping option means editing Cart itself. Strategy moves each branch into its own class implementing the same method (calculate), and Cart just calls self.shipping_strategy.calculate(weight) without knowing which one it holds. Swapping strategies at runtime — cart.shipping_strategy = ExpressShipping() — changes behavior without touching Cart's code at all.

python
class ShippingStrategy(ABC):
    @abstractmethod
    def calculate(self, weight_kg): ...

class Cart:
    def __init__(self, shipping_strategy: ShippingStrategy):
        self.shipping_strategy = shipping_strategy

    def shipping_cost(self, weight_kg):
        return self.shipping_strategy.calculate(weight_kg)

What we're doing: Give Cart two interchangeable shipping-cost strategies and swap between them at runtime without changing Cart itself.

strategy_demo.pypython
from abc import ABC, abstractmethod


class ShippingStrategy(ABC):
    @abstractmethod
    def calculate(self, weight_kg):
        ...


class StandardShipping(ShippingStrategy):
    def calculate(self, weight_kg):
        return round(4.0 + weight_kg * 1.5, 2)


class ExpressShipping(ShippingStrategy):
    def calculate(self, weight_kg):
        return round(9.0 + weight_kg * 2.5, 2)


class Cart:
    def __init__(self, shipping_strategy: ShippingStrategy):
        self.shipping_strategy = shipping_strategy

    def shipping_cost(self, weight_kg):
        return self.shipping_strategy.calculate(weight_kg)


cart = Cart(StandardShipping())
print(cart.shipping_cost(3))
cart.shipping_strategy = ExpressShipping()
print(cart.shipping_cost(3))
17
Cart stores whichever strategy it is given — it never names StandardShipping or ExpressShipping itself.
22
shipping_cost delegates to self.shipping_strategy.calculate — identical call regardless of which strategy is set.
Output
8.5
16.5

Why this works: Reassigning cart.shipping_strategy changes the cost calculation used on the very next call, with no change to the Cart class. A third shipping option is a third class implementing calculate — Cart never needs editing again.

Keeping the type flag AND the strategy object

Wrong

python
class Cart:
    def __init__(self, method, shipping_strategy):
        self.method = method                      # redundant with the strategy
        self.shipping_strategy = shipping_strategy

    def shipping_cost(self, weight_kg):
        if self.method == "express":              # branching AND delegating — pick one
            return self.shipping_strategy.calculate(weight_kg) * 1.0
        return self.shipping_strategy.calculate(weight_kg)

Better

python
class Cart:
    def __init__(self, shipping_strategy):
        self.shipping_strategy = shipping_strategy

    def shipping_cost(self, weight_kg):
        return self.shipping_strategy.calculate(weight_kg)

What you see: Not an exception — a maintenance trap: the method flag and the strategy object can disagree (method="standard" holding an ExpressShipping instance), and nothing catches it.

Why: Strategy replaces the type flag; it does not sit alongside one. Keeping both means two sources of truth that can drift apart, and the whole benefit of delegation (Cart never branches) is lost the moment an if reappears.

Cart delegates to whichever strategy it holds

Cart(strategy)

holds one ShippingStrategy, does not branch itself

strategy.calculate(weight)

delegates — same call for any strategy

swap at runtime

cart.shipping_strategy = ExpressShipping()

  1. Cart(strategy) — holds one ShippingStrategy, does not branch itself
  2. strategy.calculate(weight) — delegates — same call for any strategy
  3. swap at runtime — cart.shipping_strategy = ExpressShipping()

Strategy vs. branching inline

Strategy vs. branching inline
ApproachAdding a new algorithmTesting one algorithm alone
if/elif inside the classEdit the class, risk breaking existing branchesMust exercise the whole class to reach it
Strategy objectAdd one new class implementing the interfaceInstantiate and test the strategy directly, in isolation

Together

python
class ExpressShipping(ShippingStrategy):
    def calculate(self, weight_kg):
        return round(9.0 + weight_kg * 2.5, 2)

# test the strategy alone — no Cart needed
assert ExpressShipping().calculate(3) == 16.5

Remember: Strategy moves an algorithm into its own object with a shared interface, so the class using it delegates instead of branching — swap behavior by swapping the object, not by editing an if/elif.

See also: factory and abstract factory · decorator · polymorphism

Observer

coreintermediate

Observer lets any number of objects register interest in a subject and get notified when it changes. StockTicker.notify() calls update() on every attached observer, without knowing what each one does.

Think of it as

A subject keeps a list of observers and calls notify() whenever something worth reporting happens — it never knows or cares what an observer does with that notification. attach() adds an observer to the list; notify() loops over the list calling update(event) on each one. Adding a new kind of reaction (a new observer class) never requires changing the subject — that decoupling is the entire point.

python
class Subject:
    def __init__(self):
        self._observers = []

    def attach(self, observer):
        self._observers.append(observer)

    def notify(self, event):
        for observer in self._observers:
            observer.update(event)

What we're doing: Attach two independent observers to a StockTicker subject and have both react to the same price-change event.

observer_demo.pypython
class Subject:
    def __init__(self):
        self._observers = []

    def attach(self, observer):
        self._observers.append(observer)

    def notify(self, event):
        for observer in self._observers:
            observer.update(event)


class StockTicker(Subject):
    def set_price(self, symbol, price):
        self.notify({"symbol": symbol, "price": price})


class PriceLogger:
    def update(self, event):
        print(f"[logger] {event['symbol']} is now {event['price']}")


class PriceAlert:
    def __init__(self, threshold):
        self.threshold = threshold

    def update(self, event):
        if event["price"] > self.threshold:
            print(f"[alert] {event['symbol']} exceeded {self.threshold}")


ticker = StockTicker()
ticker.attach(PriceLogger())
ticker.attach(PriceAlert(100))
ticker.set_price("ACME", 120)
15
StockTicker.set_price() only calls self.notify() — it has no idea PriceLogger or PriceAlert exist.
24
PriceAlert.update() only fires its own logic when price exceeds its threshold — each observer decides for itself whether to react.
25
Both observers are attached to the same ticker and both receive the identical event from one notify() call.
Output
[logger] ACME is now 120
[alert] ACME exceeded 100

Why this works: set_price() triggers both reactions from one call to notify(), and StockTicker's code never changes to add a third kind of reaction — only a new observer class and one more attach() call are needed.

The subject calling concrete observer methods directly

Wrong

python
class StockTicker:
    def __init__(self, logger, alert):
        self.logger = logger
        self.alert = alert

    def set_price(self, symbol, price):
        self.logger.update({"symbol": symbol, "price": price})   # hardcoded
        self.alert.update({"symbol": symbol, "price": price})    # adding a 3rd reaction means editing this method

Better

python
class StockTicker(Subject):
    def set_price(self, symbol, price):
        self.notify({"symbol": symbol, "price": price})   # any number of observers, zero changes here

What you see: Not an exception — a scaling problem: every new kind of reaction (an SMS alert, a dashboard update) means editing set_price() again and adding another hardcoded field.

Why: Hardcoding each observer as a named attribute defeats the pattern — the subject is supposed to depend on the observer LIST and the shared update() interface, not on a fixed, named set of concrete observers.

One subject, any number of independent observers

StockTicker.set_price()

state changes, calls notify()

notify(event)

loops over every attached observer

PriceLogger, PriceAlert

each reacts independently to update()

  1. StockTicker.set_price() — state changes, calls notify()
  2. notify(event) — loops over every attached observer
  3. PriceLogger, PriceAlert — each reacts independently to update()

Observer's two roles

Observer's two roles
RoleIn the exampleResponsibility
SubjectStockTickerOwns the state, keeps the observer list, calls notify() on change
ObserverPriceLogger, PriceAlertImplements update(event); reacts however it wants

Together

python
ticker = StockTicker()
ticker.attach(PriceLogger())
ticker.attach(PriceAlert(100))
ticker.set_price("ACME", 120)   # both observers react independently

Remember: A subject keeps a list of observers and calls a shared method on each when its state changes — attaching a new observer never requires changing the subject.

See also: microservices and event driven architecture · strategy

Command

coreintermediate

Command wraps a request as an object with an execute() method (and usually undo()) instead of calling the action directly. RemoteControl.submit(TurnOnCommand(light)) stores the command, so it can be undone later.

Think of it as

Calling light.turn_on() directly loses the request the moment it runs — there is nothing left to undo, replay, or queue. Wrapping it in a TurnOnCommand object turns the request itself into a value: RemoteControl can store it in a history list, undo it later by calling command.undo(), or queue several commands to run in sequence. The remote never needs to know what a command actually does — it only calls execute() and undo().

python
class Command(ABC):
    @abstractmethod
    def execute(self): ...
    @abstractmethod
    def undo(self): ...

class TurnOnCommand(Command):
    def __init__(self, light):
        self.light = light

    def execute(self):
        return self.light.turn_on()

    def undo(self):
        return self.light.turn_off()

What we're doing: Wrap a light-switching request as a Command object, submit it through an invoker that tracks history, and undo it.

command_demo.pypython
from abc import ABC, abstractmethod


class Light:
    def __init__(self):
        self.is_on = False

    def turn_on(self):
        self.is_on = True
        return "light on"

    def turn_off(self):
        self.is_on = False
        return "light off"


class Command(ABC):
    @abstractmethod
    def execute(self): ...
    @abstractmethod
    def undo(self): ...


class TurnOnCommand(Command):
    def __init__(self, light):
        self.light = light

    def execute(self):
        return self.light.turn_on()

    def undo(self):
        return self.light.turn_off()


class RemoteControl:
    def __init__(self):
        self._history = []

    def submit(self, command: Command):
        result = command.execute()
        self._history.append(command)
        return result

    def undo_last(self):
        command = self._history.pop()
        return command.undo()


light = Light()
remote = RemoteControl()
print(remote.submit(TurnOnCommand(light)))
print(remote.undo_last())
22
Command declares execute() and undo() — RemoteControl only ever calls these two methods, never Light directly.
27
submit() runs the command and appends it to history — the command object itself is the record of what happened.
31
undo_last() pops the most recent command and calls its undo() — RemoteControl does not need to know it was specifically a TurnOnCommand.
Output
light on
light off

Why this works: RemoteControl.submit and undo_last work with ANY Command implementation — a TurnOffCommand or a DimLightCommand would need zero changes to RemoteControl. Wrapping the request as an object is what makes storing and later reversing it possible.

The invoker calling the receiver directly, bypassing the command

Wrong

python
class RemoteControl:
    def submit(self, light):
        light.turn_on()   # no command object — nothing to undo later

Better

python
class RemoteControl:
    def __init__(self):
        self._history = []

    def submit(self, command):
        result = command.execute()
        self._history.append(command)   # the request itself is now storable
        return result

What you see: undo_last() has nothing to call — there is no way to know what the last action even was, since it was never captured as an object.

Why: The whole benefit of Command is that the request becomes a value that can be stored, queued, logged, or reversed. Calling the receiver directly throws that value away the instant the action runs.

The invoker runs commands without knowing what they do

RemoteControl.submit()

stores and calls command.execute()

TurnOnCommand

wraps the request against Light

Light.turn_on()

the receiver, doing the real work

  1. RemoteControl.submit() — stores and calls command.execute()
  2. TurnOnCommand — wraps the request against Light
  3. Light.turn_on() — the receiver, doing the real work

Command's three parts

Command's three parts
RoleIn the exampleResponsibility
ReceiverLightThe object that actually performs the work
CommandTurnOnCommandWraps one request against the receiver; implements execute()/undo()
InvokerRemoteControlStores and runs commands, without knowing what they do

Together

python
light = Light()
remote = RemoteControl()
remote.submit(TurnOnCommand(light))
remote.undo_last()   # reverses the last command, whatever it was

Remember: Command wraps a request as an object with execute() (and often undo()) — the invoker stores and runs commands without knowing what any of them actually do.

See also: strategy · observer

Decorator (structural pattern)

coreintermediate

The Decorator pattern wraps an object in another object implementing the same interface, adding behavior on top. WithMilk(Espresso()) still exposes cost() and description(), just with milk's price and text added in.

Think of it as

WithMilk does not modify Espresso — it wraps it, holds a reference to it, and implements the same Coffee interface (cost, description). Calling order.cost() on WithCaramel(WithMilk(Espresso())) triggers a chain: WithCaramel adds its own cost to whatever WithMilk.cost() returns, which itself adds its cost to Espresso.cost(). Each layer only knows about the layer directly inside it, so add-ons can be combined in any combination and any order without a new class per combination.

python
class CoffeeAddOn(Coffee):
    def __init__(self, coffee: Coffee):
        self._coffee = coffee

class WithMilk(CoffeeAddOn):
    def cost(self):
        return self._coffee.cost() + 0.50

    def description(self):
        return self._coffee.description() + " + milk"

What we're doing: Wrap a base Coffee object in two stacked add-on decorators, each adding cost and description without modifying the class beneath it.

decorator_demo.pypython
from abc import ABC, abstractmethod


class Coffee(ABC):
    @abstractmethod
    def cost(self): ...
    @abstractmethod
    def description(self): ...


class Espresso(Coffee):
    def cost(self):
        return 2.50

    def description(self):
        return "Espresso"


class CoffeeAddOn(Coffee):
    """Wraps a Coffee and adds behavior — same interface as what it wraps."""
    def __init__(self, coffee: Coffee):
        self._coffee = coffee


class WithMilk(CoffeeAddOn):
    def cost(self):
        return self._coffee.cost() + 0.50

    def description(self):
        return self._coffee.description() + " + milk"


class WithCaramel(CoffeeAddOn):
    def cost(self):
        return self._coffee.cost() + 0.75

    def description(self):
        return self._coffee.description() + " + caramel"


order = WithCaramel(WithMilk(Espresso()))
print(f"{order.description()}: ${order.cost():.2f}")
23
CoffeeAddOn implements the same Coffee interface it wraps — WithMilk and WithCaramel are themselves valid Coffee objects, so they can be wrapped again.
33
WithCaramel wraps a WithMilk which wraps an Espresso — three layers deep, and every layer only calls the one directly inside it.
Output
Espresso + milk + caramel: $3.75

Why this works: Each layer's cost() adds its own price to whatever the wrapped object returns, chaining down to Espresso's base cost — 2.50 + 0.50 + 0.75 = 3.75. Because every layer implements the same Coffee interface, add-ons combine in any order or count without a dedicated class per combination (no EspressoWithMilkAndCaramel class was needed).

A decorator subclass instead of an interface-preserving wrapper

Wrong

python
class EspressoWithMilk(Espresso):    # subclassing per combination
    def cost(self):
        return super().cost() + 0.50

class EspressoWithMilkAndCaramel(EspressoWithMilk):   # explodes combinatorially
    def cost(self):
        return super().cost() + 0.75

Better

python
order = WithCaramel(WithMilk(Espresso()))   # any combination, no new class needed

What you see: No error — a design smell: one new subclass is needed for every combination of add-ons (milk, caramel, milk+caramel, ...), growing exponentially with each new add-on.

Why: Subclassing bakes each combination in at class-definition time. Wrapping instead composes add-ons at RUNTIME — any combination is just nested constructor calls, with zero new classes needed for a new combination of existing add-ons.

Each layer wraps the one inside it, same interface throughout

Espresso

the base component — cost() = 2.50

WithMilk(Espresso())

wraps it, adds 0.50

WithCaramel(...)

wraps that, adds 0.75 — still just a Coffee

  1. Espresso — the base component — cost() = 2.50
  2. WithMilk(Espresso()) — wraps it, adds 0.50
  3. WithCaramel(...) — wraps that, adds 0.75 — still just a Coffee

Decorator pattern vs. Python @decorator syntax

Decorator pattern vs. Python @decorator syntax
AspectDecorator pattern (this concept)Python @decorator syntax
WrapsAn object, at runtimeA function/method, at definition time
MechanismA class implementing the same interface, holding a reference@wraps applies a higher-order function to a def
StackingWithCaramel(WithMilk(Espresso()))@retry\n@cache\ndef fetch(): ...

Together

python
order = WithCaramel(WithMilk(Espresso()))
order.cost()          # 2.50 + 0.50 + 0.75 = 3.75
order.description()   # "Espresso + milk + caramel"

Remember: Decorator wraps an object in another object implementing the same interface, adding behavior by delegating to the wrapped object — stack decorators to combine add-ons without a class per combination.

See also: adapter · strategy · builder

Repository

coreintermediate

A Repository hides how objects are stored behind methods that look like a collection — get_by_id, add — so calling code never writes a query directly. Swapping SQLite for Postgres means changing the repository, not every caller.

Think of it as

Without a repository, business logic calls a database driver directly — cursor.execute("SELECT * FROM users WHERE id = ?", ...) — scattered across every function that needs a user. A Repository puts one class between business logic and storage: UserRepository.get_by_id(1) looks like asking a dictionary for a value, and the repository is the only place that knows whether "storage" means a SQL table, a REST API, or a plain dict in memory. Tests can swap in an InMemoryUserRepository with zero database at all.

python
class UserRepository(ABC):
    @abstractmethod
    def get_by_id(self, user_id): ...
    @abstractmethod
    def add(self, user): ...

class InMemoryUserRepository(UserRepository):
    def __init__(self):
        self._users = {}

    def get_by_id(self, user_id):
        return self._users.get(user_id)

    def add(self, user):
        self._users[user["id"]] = user

What we're doing: Define a UserRepository interface and an in-memory implementation, so calling code never writes storage-specific logic directly.

repository_demo.pypython
from abc import ABC, abstractmethod


class UserRepository(ABC):
    @abstractmethod
    def get_by_id(self, user_id): ...
    @abstractmethod
    def add(self, user): ...


class InMemoryUserRepository(UserRepository):
    def __init__(self):
        self._users = {}

    def get_by_id(self, user_id):
        return self._users.get(user_id)

    def add(self, user):
        self._users[user["id"]] = user


repo = InMemoryUserRepository()
repo.add({"id": 1, "name": "Priya Shah"})
print(repo.get_by_id(1))
print(repo.get_by_id(2))
1
UserRepository is an abstract interface — code depending on it never knows which storage backend is behind it.
8
InMemoryUserRepository is one implementation; a real app would add a SqlUserRepository with the identical method signatures.
Output
{'id': 1, 'name': 'Priya Shah'}
None

Why this works: get_by_id(2) returns None instead of raising, matching plain dict.get() semantics that callers already expect from a collection lookup. A test suite can construct InMemoryUserRepository() directly with no database connection at all — that speed and isolation is the point of the pattern.

Leaking query-building details through the repository interface

Wrong

python
class UserRepository(ABC):
    @abstractmethod
    def find(self, where_clause: str, params: tuple): ...   # leaks SQL to every caller

# caller now has to know SQL syntax
user = repo.find("id = %s", (1,))

Better

python
class UserRepository(ABC):
    @abstractmethod
    def get_by_id(self, user_id): ...   # storage-agnostic — reads like a dict lookup

user = repo.get_by_id(1)

What you see: Not an exception — a design leak: switching InMemoryUserRepository for a SqlUserRepository now requires every caller to change too, because the interface itself assumes SQL.

Why: A repository interface must describe WHAT the caller wants (a user by id), never HOW to fetch it (a WHERE clause). A parameter shaped like a query only works for a SQL backend, so an in-memory or API-backed implementation can never satisfy the same interface cleanly.

Business logic depends on the repository interface, not on SQL

OrderService

calls repository.get(order_id) — never SQL directly

UserRepository

the interface: get_by_id, add

storage

SQL table, dict, or API — swappable behind the interface

  1. OrderService — calls repository.get(order_id) — never SQL directly
  2. UserRepository — the interface: get_by_id, add
  3. storage — SQL table, dict, or API — swappable behind the interface

Repository interface vs. two implementations

Repository interface vs. two implementations
MethodInMemoryUserRepositoryA real SqlUserRepository (not shown)
get_by_id(id)self._users.get(id)SELECT * FROM users WHERE id = %s
add(user)self._users[user["id"]] = userINSERT INTO users (...) VALUES (...)

Together

python
repo = InMemoryUserRepository()
repo.add({"id": 1, "name": "Priya Shah"})
found = repo.get_by_id(1)
missing = repo.get_by_id(2)  # None — no exception, matches dict.get semantics

Remember: A repository exposes collection-like methods (get_by_id, add) instead of queries — business logic depends on that interface, so swapping the storage backend, or testing with an in-memory one, never touches the caller.

See also: service layer · layered modular monolith clean hexagonal · adapter

Service Layer

standardintermediate

A service layer is one class per use case — OrderPlacementService.place_order() — that coordinates repositories and domain rules for that one operation. Views and CLI commands call the service; the service never talks to a database driver directly.

Think of it as

Without a service layer, a use case's logic (check stock, decrement it, save the order) either lives in the view function, or gets duplicated between the web view and a CLI command that does the same thing. A service layer gives that use case exactly one home: OrderPlacementService.place_order(sku, quantity). Both the view and the CLI command call the same service method, so the logic exists once. The service coordinates — it asks the repository for data and asks domain objects to enforce their own rules — rather than doing storage or validation itself.

python
class OrderPlacementService:
    def __init__(self, product_repository):
        self.product_repository = product_repository

    def place_order(self, sku, quantity):
        available = self.product_repository.get_stock(sku)
        if quantity > available:
            raise InsufficientStockError(f"only {available} left of {sku}")
        self.product_repository.decrement_stock(sku, quantity)
        return {"sku": sku, "quantity": quantity, "status": "placed"}

What we're doing: Coordinate a stock check and a stock decrement behind one service method representing the "place an order" use case.

service_layer_demo.pypython
class InsufficientStockError(Exception):
    pass


class ProductRepository:
    def __init__(self):
        self._stock = {"sku-1": 5}

    def get_stock(self, sku):
        return self._stock.get(sku, 0)

    def decrement_stock(self, sku, quantity):
        self._stock[sku] -= quantity


class OrderPlacementService:
    def __init__(self, product_repository):
        self.product_repository = product_repository

    def place_order(self, sku, quantity):
        available = self.product_repository.get_stock(sku)
        if quantity > available:
            raise InsufficientStockError(f"only {available} left of {sku}")
        self.product_repository.decrement_stock(sku, quantity)
        return {"sku": sku, "quantity": quantity, "status": "placed"}


service = OrderPlacementService(ProductRepository())
print(service.place_order("sku-1", 2))
try:
    service.place_order("sku-1", 10)
except InsufficientStockError as e:
    print(f"InsufficientStockError: {e}")
16
OrderPlacementService represents exactly one use case — placing an order — and takes the repository it needs by injection.
19
place_order coordinates: it asks the repository for stock, checks the rule, then tells the repository to decrement — the repository itself has no idea what "placing an order" means.
Output
{'sku': 'sku-1', 'quantity': 2, 'status': 'placed'}
InsufficientStockError: only 3 left of sku-1

Why this works: A CLI command and a web view can both call service.place_order(sku, quantity) and get identical stock-checking behavior — the rule lives once, in the service, rather than being copied into every entry point that needs to place an order.

Remember: A service layer is one class per use case, coordinating repositories and domain objects — every entry point (web, CLI, background job) calls the same service method instead of re-implementing the use case.

See also: repository · layered modular monolith clean hexagonal · dependency injection

Dependency Injection (pattern catalog entry)

referenceintermediate

Dependency Injection means a class receives the objects it depends on from outside (usually through its constructor) instead of creating them itself. ReportGenerator(logger) takes a logger; it never instantiates one internally.

Think of it as

A class that does self.logger = ConsoleLogger() inside its own __init__ is locked to ConsoleLogger forever — a test cannot swap in a fake one. Passing the logger in as a constructor argument instead means the caller decides which implementation to use, and swapping it for a test double costs nothing.

python
class ReportGenerator:
    def __init__(self, logger):   # injected, not created internally
        self.logger = logger

    def generate(self):
        self.logger.log("report generated")

ReportGenerator(ConsoleLogger()).generate()

Remember: Dependency Injection passes an object's dependencies in from outside (typically via the constructor) instead of letting it construct them itself — full treatment is the roadmap's dedicated Dependency Injection section, not here.

See also: service layer · repository

Advertisement