Class Decorators, Stacking and Decorator Factories

As your decorator library grows, you need to understand how multiple decorators interact when stacked, how to build stateful decorators using classes, and how to write decorator factories that accept arguments cleanly. These advanced patterns unlock the full power of Python’s decorator system. Understanding decorator stacking order is also critical when working with FastAPI โ€” route handlers often have multiple decorators from different sources (FastAPI, authentication, caching) and the order determines how they interact. Class-based decorators add state without closures, making certain patterns cleaner and more explicit.

Stacking Multiple Decorators

import functools

def bold(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return f"<b>{func(*args, **kwargs)}</b>"
    return wrapper

def italic(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return f"<i>{func(*args, **kwargs)}</i>"
    return wrapper

def underline(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return f"<u>{func(*args, **kwargs)}</u>"
    return wrapper

# โ”€โ”€ Stacking order: applied bottom-up, executed top-down โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@bold        # applied third (outermost wrapper)
@italic      # applied second
@underline   # applied first (innermost wrapper)
def greet(name: str) -> str:
    return f"Hello, {name}"

# Equivalent to:
# greet = bold(italic(underline(greet_original)))

print(greet("Alice"))
# <b><i><u>Hello, Alice</u></i></b>

# Execution order (call greet("Alice")):
# bold wrapper  โ†’ calls italic wrapper
# italic wrapper โ†’ calls underline wrapper
# underline wrapper โ†’ calls original greet
# underline result โ†’ "<u>Hello, Alice</u>"
# italic result   โ†’ "<i><u>Hello, Alice</u></i>"
# bold result     โ†’ "<b><i><u>Hello, Alice</u></i></b>"

# โ”€โ”€ Key rule: decorators are applied bottom to top โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# The decorator closest to 'def' is applied first (innermost)
# The decorator furthest from 'def' is applied last (outermost)
# When called, the outermost decorator runs first
Note: In FastAPI, the order of stacked decorators matters for authentication and caching. If you stack @cache above @require_auth, the cache wrapper runs first โ€” it may serve cached responses to unauthenticated users (bypassing auth). The correct order is @require_auth above @cache, so auth runs first (outermost), then the cache layer, then the route handler. Always think about which wrapper should run first when stacking decorators in FastAPI.
Tip: Class-based decorators are cleaner than closure-based decorators when you need to store state across calls (call count, cache entries, circuit breaker state). They use __init__ to accept arguments (replacing the outer factory function) and __call__ to implement the wrapper logic. The instance of the class is the decorator, and its attributes store the state.
Warning: When using class-based decorators on class methods (not standalone functions), the self parameter of the method is passed as the first positional argument to the decorator’s __call__ method. This means the decorator receives (self_of_decorated_class, *args, **kwargs) โ€” the first argument is the class instance, not the decorated class’s self. Use functools.wraps and test carefully when applying class-based decorators to methods.

Class-Based Decorators

import functools
import time

class RateLimit:
    """Class-based decorator that enforces a rate limit on function calls."""

    def __init__(self, max_calls: int, period_seconds: float):
        self.max_calls     = max_calls
        self.period        = period_seconds
        self.call_times    = []

    def __call__(self, func):
        """Return a wrapper function that enforces the rate limit."""
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            now    = time.time()
            cutoff = now - self.period

            # Remove calls outside the window
            self.call_times = [t for t in self.call_times if t > cutoff]

            if len(self.call_times) >= self.max_calls:
                raise RuntimeError(
                    f"Rate limit exceeded: {self.max_calls} calls per {self.period}s"
                )

            self.call_times.append(now)
            return func(*args, **kwargs)
        return wrapper

# Usage โ€” RateLimit(10, 60) creates an instance, __call__ makes it a decorator
@RateLimit(max_calls=10, period_seconds=60)
def send_email(recipient: str, subject: str) -> bool:
    print(f"Sending '{subject}' to {recipient}")
    return True

# โ”€โ”€ CallCount โ€” simpler stateful decorator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class CallCount:
    def __init__(self, func):
        functools.update_wrapper(self, func)   # same as @wraps for classes
        self.func  = func
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        return self.func(*args, **kwargs)

@CallCount
def greet(name): return f"Hello, {name}"

greet("Alice")
greet("Bob")
print(greet.count)   # 2

Decorator Factories with Optional Arguments

import functools

# Pattern: make decorator work both with and without arguments
def log(func=None, *, level: str = "INFO", prefix: str = ""):
    """Log function calls. Works as @log or @log(level="DEBUG")."""
    if func is None:
        # Called with arguments: @log(level="DEBUG")
        # Return an actual decorator
        return lambda f: log(f, level=level, prefix=prefix)

    # Called without arguments: @log
    tag = f"[{prefix}] " if prefix else ""

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"{tag}{level}: Calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"{tag}{level}: {func.__name__} returned")
        return result
    return wrapper

# Both forms work:
@log
def quick():
    pass

@log(level="DEBUG", prefix="DB")
def database_query():
    pass

# Called with wrong argument type triggers the right path
@log()   # func=None, uses defaults
def another():
    pass

Common Mistakes

Mistake 1 โ€” Wrong stacking order for auth + cache

โŒ Wrong โ€” cache runs before auth, serves cached responses to unauthenticated users:

@require_auth   # runs second (inner) โ€” too late!
@cache          # runs first (outer) โ€” cached before auth check!

โœ… Correct โ€” auth (outermost) runs first:

@cache          # runs second โ€” only reached after auth passes โœ“
@require_auth   # runs first (outermost) โ€” checks auth before caching

Mistake 2 โ€” Shared state between decorated instances

โŒ Wrong โ€” single class instance shared across all decorated functions:

rate_limiter = RateLimit(10, 60)

@rate_limiter   # ALL uses of this decorator share the same call_times list!
def api_a(): pass

@rate_limiter   # api_a and api_b share the rate limit pool
def api_b(): pass

โœ… Correct โ€” create a new instance per decoration:

@RateLimit(10, 60)   # new RateLimit instance for each โœ“
def api_a(): pass

@RateLimit(10, 60)   # separate rate limit pool โœ“
def api_b(): pass

Mistake 3 โ€” Applying class-based decorator to a method without testing

โŒ Wrong โ€” the class instance is passed as the first arg, not the method’s self:

class MyClass:
    @CallCount   # CallCount.__call__ receives (MyClass_instance, *args)
    def method(self):   # 'self' here is the MyClass instance, NOT the CallCount instance
        pass   # Descriptor protocol needed for this to work correctly

โœ… Correct โ€” use function-based decorators for methods, class-based for module-level functions.

Quick Reference โ€” Stacking Order

Position Applied Runs When Called
Top (furthest from def) Last First (outermost)
Bottom (closest to def) First Last (innermost)
FastAPI auth + cache @cache then @require_auth auth first, cache second

🧠 Test Yourself

You stack three decorators: @A, @B, @C (top to bottom) on a function. In what order are the wrappers applied (built up), and in what order do they execute when the function is called?