Skip to main content
Scenario Article

Decorators Explained: From Functions to Frameworks

The Copy-Paste Crisis: When Timing Code Invades Twenty Functions

Abstract Algorithms•Apr 19, 2026
💡 Executive Summary

Sofia is three months into her first Python backend role.

🎯 System Scenario & Requirements

System Goal

Sofia is three months into her first Python backend role.

Actors & Components

  • Application or API tier
  • Core service under design
  • Operator or SRE observer

Functional Requirements

  • Sofia is three months into her first Python backend role.
  • The team runs a performance review and discovers the data-processing API is slow.
  • The tech lead asks her to add timing instrumentation to every endpoint handler — 20 functions spread across four files.

Constraints & Assumptions

  • Constraints are derived from the rebuilt scenario stages.

⚡ Execution Stages & Decision Trace

1

Stage 1 — Python Functions as First-Class Citizens: The Foundation Decorators Build On

Action / Decision:

Before a decorator can work, you need to see functions the way Python sees them — not as named blocks of code but as objects you can pass, store, and return just like integers or strings.

Component Behavior:

Functions Are Objects You Can Pass Around When Python executes def greet(name): it allocates a function object and binds the name greet to it.

Observable Output: greet is just a label pointing at that object.
Read Full Stage in Deep Dive ↓
📖

Deep Dive

Detailed Architectural Analysis

Scenario context

Sofia is three months into her first Python backend role. The team runs a performance review and discovers the data-processing API is slow. The tech lead asks her to add timing instrumentation to every endpoint handler — 20 functions spread across four files.

She does it the obvious way. She copies the same five lines to the top and bottom of each function:

import time

def process_orders(orders):
    st…

This walkthrough focuses on **The Copy-Paste Crisis: When Timing Code Invades Twenty Functions**.

Before making a design choice, clarify:

- Sofia is three months into her first Python backend role.
- The team runs a performance review and discovers the data-processing API is slow.
- The tech lead asks her to add timing instrumentation to every endpoint handler — 20 functions spread across four files.

**Concepts in play:** low-level-design-systems, design-patterns-gof, caching, stack-and-queue-data-structures

## Stage 1 — Python Functions as First-Class Citizens: The Foundation Decorators Build On

- Before a decorator can work, you need to see functions the way Python sees them — not as named blocks of code but as objects you can pass, store, and return just like integers or strings.
- Functions Are Objects You Can Pass Around When Python executes def greet(name): it allocates a function object and binds the name greet to it.
- greet is just a label pointing at that object.
- Assigning say hello = greet copies the reference — both names point at the same function object.

*Applies [**low-level-design-systems**](/concept/low-level-design-systems) to the decision above.*

```python
def greet(name):
    return f"Hello, {name}!"

# Assign to a variable — no call parentheses
say_hello = greet
print(say_hello("Alice"))   # Hello, Alice!

# Store in a list alongside other callables
actions = [greet, str.upper, len]
print([f("world") for f in actions])   # ['Hello, world!', 'WORLD', 5]
def make_multiplier(factor):
    def multiply(value):
        return value * factor   # factor is captured from make_multiplier's scope
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)

print(double(10))   # 20
print(triple(10))   # 30

# The captured variable lives in __closure__
print(double.__closure__[0].cell_contents)  # 2

Observable outcome: Assigning say hello = greet copies the reference — both names point at the same function object.

Stage 2 — Crafting Decorators by Hand: Timer, Logger, Retry, and functools.wraps

  • The Minimal Wrapper Pattern Every function-based decorator follows this three-part structure: accept the original function as an argument, define an inner wrapper that adds behavior before and after calling the original…
  • The args, kwargs signature in wrapper is essential.
  • It means the wrapper accepts any combination of positional and keyword arguments and forwards them transparently to func .
  • Without it, the decorator would only work with one specific signature.
def my_decorator(func):
    def wrapper(*args, **kwargs):
        # before: run code before the original function
        result = func(*args, **kwargs)
        # after: run code after the original function
        return result
    return wrapper
def bad_timer(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@bad_timer
def calculate_total(items):
    """Sum all item prices and return the total."""
    return sum(item.price for item in items)

print(calculate_total.__name__)   # wrapper  — wrong!
print(calculate_total.__doc__)    # None      — lost!

Observable outcome: Without it, the decorator would only work with one specific signature.

Stage 3 — How Python Applies Decorators Under the Hood

  • The Internals of Decorator Application Import time versus call time.
  • Decorators run at import time — when Python executes the def statement, not when the function is called.
  • This distinction has real consequences.
  • A module-level @app.route("/") registers the route with Flask the moment the module is imported, not when the first request arrives.
def loud_decorator(func):
    print(f"Decorating {func.__name__}")   # runs at import time
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")  # runs at call time
        return func(*args, **kwargs)
    return wrapper

@loud_decorator
def compute():
    return 42

# Output when the module is imported (before compute() is called):
# Decorating compute

# Output when compute() is called:
# Calling compute
import inspect

@timer
@retry(max_attempts=2)
def process(data):
    return data

# Unwrap to the original — ignores all decorator layers
original = inspect.unwrap(process)
print(original.__name__)   # process

Observable outcome: A module-level @app.route("/") registers the route with Flask the moment the module is imported, not when the first request arrives.

Stage 4 — Tracing a Stacked Decorator: Call Order, Execution, and Return

  • When you write @retry , @timer , and @log on the same function, understanding which runs first at call time versus return time is not obvious.
  • The diagram below traces the exact execution path for a three-decorator stack.
  • Read this diagram top to bottom for the call path and bottom to top for the return path.
  • retry is outermost: it starts first, receives the result last, and is the only layer that can re-invoke the inner stack on failure.
graph TD
    A[Caller invokes fetch_price] --> B[retry wrapper starts]
    B --> C[timer wrapper starts]
    C --> D[log wrapper starts]
    D --> E[original fetch_price executes]
    E --> F[log wrapper finishes and returns result]
    F --> G[timer records elapsed time and returns result]
    G --> H[retry checks for exceptions and returns result]
    H --> I[result returned to caller]
@retry(max_attempts=3)   # outermost
@timer                   # middle
@log                     # innermost
def fetch_price(product_id):
    ...

# Python internally does this at decoration time:
fetch_price = retry(max_attempts=3)(timer(log(fetch_price)))

Observable outcome: retry is outermost: it starts first, receives the result last, and is the only layer that can re-invoke the inner stack on failure.

Stage 5 — Where Decorators Run the Python Ecosystem

  • Decorators are not an advanced niche feature — they are the primary API surface of the most popular Python frameworks.
  • Recognizing the pattern makes every framework immediately more readable.
  • Flask and FastAPI: Routes Are Decorated Functions Flask's @app.route() is a decorator factory.
  • At import time, app.route("/products") returns a decorator that registers list products in the Flask URL map: FastAPI uses the same pattern but also reads the function's type annotations to generate OpenAPI documentatio…
from flask import Flask, jsonify
app = Flask(__name__)

@app.route("/products", methods=["GET"])
def list_products():
    return jsonify([{"id": 1, "name": "Widget"}])

# Equivalent to:
# list_products = app.route("/products", methods=["GET"])(list_products)
import pytest

@pytest.fixture
def db_connection():
    conn = create_test_database()
    yield conn          # setup done; test runs here
    conn.close()        # teardown runs after the test

def test_user_creation(db_connection):
    # db_connection is injected by pytest — no import needed
    user = db_connection.create_user("alice@example.com")
    assert user.id is not None

Observable outcome: At import time, app.route("/products") returns a decorator that registers list products in the Flask URL map: FastAPI uses the same pattern but also reads the function's type annotations to generate OpenAPI documentatio…

Stage 6 — Decorator Versus Subclass, Middleware, and Class-Based Wrappers

  • Not every cross-cutting concern belongs in a decorator.
  • Knowing when to use each approach prevents over-engineering.
  • A decorator adds behavior from the outside without requiring the original function to know about it.
  • A subclass adds behavior from the inside — the child class controls what changes.
import time
import functools

class RateLimit:
    def __init__(self, calls_per_second):
        self.calls_per_second = calls_per_second
        self._call_times = []

    def __call__(self, func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            now = time.monotonic()
            window_start = now - 1.0
            self._call_times = [t for t in self._call_times if t > window_start]
            if len(self._call_times) >= self.calls_per_second:
                raise RuntimeError(
                    f"Rate limit exceeded: {self.calls_per_second} calls/second"
                )
            self._call_times.append(now)
            return func(*args, **kwargs)
        return wrapper

rate_limit = RateLimit(calls_per_second=5)

@rate_limit
def send_notification(user_id, message):
    print(f"Notifying {user_id}: {message}")

Observable outcome: A subclass adds behavior from the inside — the child class controls what changes.

End-to-end outcome

After working through the stages, you should be able to explain:

  • functools.wraps is not optional.
  • Every decorator that omits it breaks stack traces, logging, help() , and any tool that reads name or doc .
  • TLDR: A decorator is syntactic sugar for func = decorator(func) .
  • The @ symbol applies at import time, not call time.

Alternatives considered

The source focuses on one primary design. A second architecture is not compared, so no alternative is invented here.

Failure and degraded modes

The source does not isolate a separate failure mode. Limits that matter for the scenario are called out in the stage decisions above.

Share this case study

Discuss architecture trade-offs and lessons with your team.

Share:XLinkedInReddit