Course contents

Decorator — behavior wrapping, stacked

Sign in to run this lab and save your progress

Learn

Before you start

Decorator is easy to spot once you see the pattern: any wrapper class that shares the interface it wraps.

Two things to keep separate:

  • Decorator pattern (this lesson) — an object-oriented wrapping technique
  • Python's @decorator syntax — a function-wrapping shortcut

They share a name and an idea (wrap and add). They're otherwise different tools. This lesson is about the pattern.

When to use Decorator

Signs:

  • You want to add behavior AROUND an object without changing its class. (You can't or shouldn't touch it — third party, shared, or intentionally kept small.)
  • You want to STACK behaviors — logging + retry + rate limit, in different combinations.
  • Different callers want different combinations. No fixed set of "features" — mixed and matched per caller.
  • The behaviors are independent — they don't need to know about each other to work.

If all four fire, Decorator is the clean answer. If only one or two fire, look at alternatives first.

Where Decorator shows up in real code

  • HTTP middleware. Every web framework (Django, Express, ASP.NET, Rails) has middleware that wraps requests. That's Decorator.
  • Notification wrappers (this lesson) — logging, retry, rate limit around a send.
  • File I/O wrappers. BufferedReader(FileReader(...)) in Java. Same shape in Python's io module.
  • Cache wrappersCacheReader(DatabaseReader(...)). Read from cache; on miss, fall through to the database and store the result.
  • Auth checks — wrap a service with an AuthorizedNotifier that raises unless the caller has permission.

Anywhere you catch yourself thinking "I want to add X to Y without changing Y" — Decorator is the tool.

The order-matters trap

Decorators stack, and the order changes the meaning:

  • LoggingNotifier(RetryingNotifier(EmailNotifier())) — logging sees ONE send (retries happen inside, invisible to logging).
  • RetryingNotifier(LoggingNotifier(EmailNotifier())) — retries wrap logging, so a failed send retries the ENTIRE log-plus-send. Log entries get duplicated per retry.

Both are correct — for different intents. Pick the order on purpose. In this lesson, LoggingNotifier(RetryingNotifier(...)) is usually what you want.

The extensibility payoff

Look at the test_deep_stack test. To wire up "log + retry + rate limit around email", we don't write a new class. We compose four existing objects. The next customer wants "just retry around Slack"? Same pattern, different composition, zero new classes.

That's why Decorator scales so well when you have many independent features. Each is one class. Every combination is free.

Decorator vs the other "wrap-something" patterns

Pattern Same interface? Adds what?
Adapter No — the target shape differs from the adaptee Nothing; translates only
Facade New interface (usually simpler) Nothing; coordinates only
Decorator Yes — wrapper matches the wrapped interface New behavior AROUND the call
Proxy Yes Access control, lazy loading, caching (doesn't change observable behavior)

Decorator is the "same interface, added behavior" one.

Common mistakes

  1. Decorators that reach into fields the wrapped class owns. If LoggingNotifier reaches for _inner.some_field, that's a leak. Decorators should only use the wrapped interface.

  2. Decorators that CHANGE the return type. send() returns str — decorators should still return str. If a decorator returns a LoggedResult object, it's no longer a drop-in replacement.

  3. Decorators that DON'T call the inner method. A "logging" decorator that just logs and never sends is a silent bug. Every decorator should either: (a) call inner exactly once, OR (b) explicitly refuse to (like rate-limit's "rate-limited" case)

  4. Deeply nested constructor calls that are unreadable. Pull the composition into a function:

    def build_enterprise_notifier(email):
        return LoggingNotifier(
            RetryingNotifier(
                RateLimitedNotifier(email, 100),
                max_attempts=3,
            )
        )
    

    Now callers see build_enterprise_notifier(EmailNotifier()).

The decision cheat-sheet

Situation Pick
Add ONE behavior around an object, forever Inheritance is fine
Add multiple independent behaviors Decorator
Combinations chosen at runtime Decorator
Every combination is fixed at code time Inheritance or single class with flags
Wrap a third-party object Decorator (composition)
Add UNRELATED abilities to different objects Plain composition (not Decorator specifically)

Your task

Decorator (the pattern, not Python's @decorator syntax) is how you add behavior AROUND an existing object without changing it and without inheriting from it.

Think of a Russian nesting doll. The innermost doll is your real object. Each outer doll adds one behavior. Callers hold the outermost doll and see the combined behavior.

The problem

You have a Notifier:

class Notifier(ABC):
    @abstractmethod
    def send(self, message): ...

Right now EmailNotifier sends emails. Fine.

Over time you need:

  • Logging — record every send attempt (before + after)
  • Retries — if send fails, try again up to N times
  • Rate limiting — if we've sent more than K messages lately, drop this one

Different customers want different combinations:

  • Enterprise → logging + retries + rate limit
  • Trial → just logging
  • Internal dashboard alerts → just retries

If you cram all three into EmailNotifier, every customer gets all three whether they want them or not. And you can't reuse any of it for SlackNotifier without copying the code.

Decorator fixes this cleanly.

Questions to ask about the requirements

  1. Do the behaviors STACK? Should you be able to combine "logging + retry" in one place? Yes → Decorator.
  2. Are the behaviors INDEPENDENT (don't need to coordinate with each other)? Yes → Decorator. If they interact (retry only certain errors from certain loggers), Decorator can still work but with more coupling.
  3. Should the choice be at RUNTIME (per-customer config) or fixed at code time (all customers get the same combination)? Runtime → Decorator fits; fixed → inheritance could work too.
  4. Do you need to wrap objects you don't control (third-party classes)? Decorator via composition works; the inheritance version doesn't.
  5. How many wrappers do you typically stack? 1 → maybe skip. 2-4 → Decorator's sweet spot. 10+ → something's wrong, refactor.

For notifications: yes stack, independent, runtime, wrap any Notifier. Decorator fits.

Different designs, compared

Design A — subclass to add behavior

class LoggingEmailNotifier(EmailNotifier):
    def send(self, message):
        print(f"sending: {message}")
        super().send(message)
        print(f"sent")

class RetryingEmailNotifier(EmailNotifier):
    def send(self, message):
        for attempt in range(3):
            try:
                return super().send(message)
            except Exception:
                ...
  • Good: Familiar OO shape.
  • Bad: Explodes in class count. Logging + Retry → RetryingLoggingEmailNotifier. Add Rate Limit → 8 classes for 3 behaviors. Now do it for SlackNotifier. Now do it for the 5 notifier types you'll add over time. Number of classes = 2^N × M.
  • When it wins: Only if you're sure there's exactly one wrapper per notifier, and it never changes.

Design B — feature flags on the base class

class EmailNotifier:
    def __init__(self, log=False, retry=None, rate_limit=None):
        ...
    def send(self, message):
        if self.log: ...
        for attempt in range(self.retry or 1):
            if self.rate_limit and rate_limited(): return
            ...
  • Good: One class, flags configure it.
  • Bad: Base class gets bloated. Every new behavior adds a parameter and a branch. Behaviors get tangled through the parameter meanings. Tests explode. Breaks SRP.
  • When it wins: Never for real systems.

Design C — Decorator via composition

class Notifier(ABC):
    @abstractmethod
    def send(self, message): ...

class EmailNotifier(Notifier):
    def send(self, message):
        # actually send

class LoggingNotifier(Notifier):
    def __init__(self, inner):
        self._inner = inner
    def send(self, message):
        print(f"→ {message}")
        result = self._inner.send(message)
        print(f"✓ sent")
        return result

class RetryingNotifier(Notifier):
    def __init__(self, inner, max_attempts=3):
        self._inner = inner
        self._max = max_attempts
    def send(self, message):
        for attempt in range(self._max):
            try:
                return self._inner.send(message)
            except Exception:
                if attempt == self._max - 1:
                    raise

# Wire it up per customer:
enterprise = LoggingNotifier(
    RetryingNotifier(
        RateLimitedNotifier(EmailNotifier())
    )
)
  • Good: Each wrapper is one small class. Stacking is just constructor calls. Order is clear (LoggingNotifier on the outside logs BEFORE retries fire). Works with any Notifier. Per-customer combinations are easy.
  • Bad: Deeply nested constructor calls are a bit awkward to read. Debugging traces are longer.
  • When it wins: Any time you have 2+ independent behaviors to add.

Design D — middleware pipeline (functional version)

def with_logging(fn):
    def wrapped(message):
        print(f"→ {message}")
        return fn(message)
    return wrapped

send = with_logging(with_retry(email_send))
  • Good: Very Pythonic. No new classes. Composes with functools.reduce.
  • Bad: Loses the object interface — callers now hold a function, not a Notifier. Doesn't play well with systems that expect objects (like dependency injection). Harder to inspect ("what behaviors are stacked?").
  • When it wins: Small internal codebases. Same idea as HTTP middleware in Django/Flask.

The locked-in choice: Design C

Reviewing the requirements:

  • Behaviors stack, independent, chosen at runtime → every design supports this to some degree.
  • Multiple notifier types to wrap. → Rules out A badly.
  • Test isolation matters. → Rules out B (bloated tests).
  • We're in an object-oriented app that expects Notifier objects. → Rules out D on ergonomics.

Design C — object Decorator — wins.

Mechanics

from abc import ABC, abstractmethod


class Notifier(ABC):
    @abstractmethod
    def send(self, message): ...


class EmailNotifier(Notifier):
    def __init__(self):
        self.sent = []
    def send(self, message):
        self.sent.append(message)
        return f"emailed: {message}"


class LoggingNotifier(Notifier):
    def __init__(self, inner):
        self._inner = inner
        self.log = []
    def send(self, message):
        self.log.append(f"→ {message}")
        result = self._inner.send(message)
        self.log.append(f"✓ {message}")
        return result


class RetryingNotifier(Notifier):
    def __init__(self, inner, max_attempts):
        self._inner = inner
        self._max = max_attempts
    def send(self, message):
        last_exc = None
        for _ in range(self._max):
            try:
                return self._inner.send(message)
            except Exception as e:
                last_exc = e
        raise last_exc

Your task

In main.py build:

  1. Abstract Notifier(ABC) with send(self, message) → str.
  2. EmailNotifier(Notifier):
    • __init__ sets self.sent = [] and self.fail_next = 0 (used to simulate failures — see below)
    • send(message) appends to sent, returns f"emailed: {message}". If self.fail_next > 0, decrement it and raise RuntimeError("network") instead of sending.
  3. LoggingNotifier(Notifier):
    • __init__(self, inner) stores inner + initializes self.log = []
    • send(message) appends f"→ {message}" before calling, and f"✓ {message}" after successful send. Returns inner's result.
  4. RetryingNotifier(Notifier):
    • __init__(self, inner, max_attempts) stores both
    • send(message) tries up to max_attempts times. Returns inner's result on success. If all attempts fail, raises the last exception.
  5. RateLimitedNotifier(Notifier):
    • __init__(self, inner, max_per_call) — no time window for simplicity; just count total sends
    • Tracks total sends. If a call would exceed the limit, returns the string "rate-limited" WITHOUT calling inner. Otherwise delegates and returns inner's result.

Structural tests enforce:

  • Every decorator IS-A Notifier.
  • Wrappers can stack in any order.
  • Retry only calls inner up to max_attempts times.
  • LoggingNotifier's log captures both "→" and "✓" per successful send, only "→" if inner raises.

Click Submit to run the tests.

Starter files

Preview only — open the lab to edit and run.

files_dir
08-decorator-starter