Skip to main content

Combining Descriptors, Decorators, and Metaclasses

Descriptors, decorators, and metaclasses are not competitors; they're complementary tools that work together synergistically. Descriptors handle attribute-level customization; decorators (especially class decorators) perform one-time transformations; metaclasses control class creation itself. Production frameworks use all three in concert: descriptors for field validation, decorators for cross-cutting concerns like caching and logging, and metaclasses for schema building and inheritance chains. Understanding when to reach for each tool—and how to compose them without creating maintenance nightmares—is the hallmark of a master Python developer. This article shows real patterns where all three work together in harmony.

Pattern 1: Descriptors + Metaclass for Declarative Models

Combine descriptors (per-field behavior) with metaclasses (schema generation) to build declarative models:

class ValidatedField:
"""A descriptor with type and range validation."""
def __init__(self, type_, min_val=None, max_val=None):
self.type_ = type_
self.min_val = min_val
self.max_val = max_val
self.name = None

def __set_name__(self, owner, name):
self.name = name

def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(f'_{self.name}')

def __set__(self, obj, value):
if not isinstance(value, self.type_):
raise TypeError(f'{self.name} must be {self.type_.__name__}')
if self.min_val is not None and value < self.min_val:
raise ValueError(f'{self.name} must be >= {self.min_val}')
if self.max_val is not None and value > self.max_val:
raise ValueError(f'{self.name} must be <= {self.max_val}')
obj.__dict__[f'_{self.name}'] = value


class DeclarativeModelMeta(type):
"""Metaclass that collects descriptors and generates __init__."""

def __new__(mcs, name, bases, namespace):
# Collect field descriptors
fields = {}
for key, value in namespace.items():
if isinstance(value, ValidatedField):
fields[key] = value

namespace['_fields'] = fields

# Auto-generate __init__
def __init__(self, **kwargs):
for field_name in self._fields:
if field_name in kwargs:
setattr(self, field_name, kwargs[field_name])

namespace['__init__'] = __init__

# Auto-generate __repr__
def __repr__(self):
fields_str = ', '.join(
f'{k}={getattr(self, k, None)!r}'
for k in self._fields
)
return f'{name}({fields_str})'

namespace['__repr__'] = __repr__

return super().__new__(mcs, name, bases, namespace)


class Model(metaclass=DeclarativeModelMeta):
pass


class Product(Model):
price = ValidatedField(float, min_val=0.01, max_val=999999)
quantity = ValidatedField(int, min_val=0, max_val=100000)


product = Product(price=29.99, quantity=50)
print(product) # Output: Product(price=29.99, quantity=50)

# Validation error
try:
product.price = -10
except ValueError as e:
print(f'Error: {e}')

The descriptor handles per-instance validation; the metaclass generates schema and initialization code. Neither tool alone is sufficient.

Pattern 2: Decorator + Descriptor for Caching

Use a decorator to add caching logic around methods that use descriptors:

from functools import wraps

class ComputedProperty:
"""A descriptor for expensive computed properties."""
def __init__(self, func):
self.func = func
self.name = func.__name__

def __get__(self, obj, objtype=None):
if obj is None:
return self
# Call the function to compute the value
return self.func(obj)


def cached_method(func):
"""A decorator that caches method results on the instance."""
@wraps(func)
def wrapper(self, *args, **kwargs):
cache_key = (func.__name__, args, tuple(sorted(kwargs.items())))
if not hasattr(self, '_method_cache'):
self._method_cache = {}

if cache_key not in self._method_cache:
self._method_cache[cache_key] = func(self, *args, **kwargs)

return self._method_cache[cache_key]

return wrapper


class User:
def __init__(self, first_name, last_name):
self.first_name = first_name
self.last_name = last_name

@property
def full_name(self):
"""A simple property (descriptor)."""
return f'{self.first_name} {self.last_name}'

@cached_method
def calculate_reputation(self, factor=1.0):
"""An expensive computation that gets cached."""
print(f'Computing reputation with factor {factor}...')
import time
time.sleep(0.1) # Simulate expensive operation
return 100 * factor

def invalidate_cache(self):
"""Clear the method cache."""
self._method_cache = {}


user = User("Alice", "Smith")
print(user.full_name) # Output: Alice Smith

# First call computes
result1 = user.calculate_reputation(1.5)
print(f'Reputation: {result1}') # Output includes "Computing reputation..."

# Second call uses cache
result2 = user.calculate_reputation(1.5)
print(f'Reputation: {result2}') # Output does NOT include "Computing reputation..."

# Different argument: recomputes
result3 = user.calculate_reputation(2.0)
print(f'Reputation: {result3}') # Output includes "Computing reputation..."

The decorator wraps methods to add caching; descriptors (like @property) define read-only attributes. Together they provide clean, DRY attribute access with intelligent caching.

Pattern 3: Metaclass + Class Decorator for Validation and Logging

Apply both a metaclass (for structure) and decorators (for behavior) to a model:

def log_changes(cls):
"""A class decorator that logs attribute changes."""
original_setattr = cls.__setattr__

def logged_setattr(self, name, value):
print(f'{cls.__name__}.{name} = {value!r}')
original_setattr(self, name, value)

cls.__setattr__ = logged_setattr
return cls


class ValidatingMeta(type):
"""A metaclass that ensures all methods are documented."""

def __new__(mcs, name, bases, namespace):
for attr_name, attr_value in namespace.items():
if callable(attr_value) and not attr_name.startswith('_'):
if not attr_value.__doc__:
raise TypeError(
f'{name}.{attr_name} must have a docstring'
)
return super().__new__(mcs, name, bases, namespace)


@log_changes
class ValidatedModel(metaclass=ValidatingMeta):
pass


class BankAccount(ValidatedModel):
def __init__(self, account_holder, initial_balance):
self.account_holder = account_holder
self.balance = initial_balance

def deposit(self, amount):
"""Record a deposit."""
self.balance += amount

def withdraw(self, amount):
"""Record a withdrawal."""
if amount <= self.balance:
self.balance -= amount


account = BankAccount("Alice", 1000)
# Output: BankAccount.account_holder = 'Alice'
# BankAccount.balance = 1000

account.deposit(500) # Output: BankAccount.balance = 1500
account.withdraw(200) # Output: BankAccount.balance = 1300

The metaclass enforces documentation standards at class definition time; the decorator logs all attribute changes at runtime. Neither alone provides both guarantees.

Pattern 4: Descriptor + Decorator + Metaclass: A Complete Framework

Combine all three for a flexible validation framework:

class TypeCheckedField:
"""Descriptor with optional custom validation."""
def __init__(self, type_, validator=None):
self.type_ = type_
self.validator = validator
self.name = None

def __set_name__(self, owner, name):
self.name = name

def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(f'_{self.name}')

def __set__(self, obj, value):
if not isinstance(value, self.type_):
raise TypeError(f'{self.name} must be {self.type_.__name__}')
if self.validator and not self.validator(value):
raise ValueError(f'{self.name} validation failed for {value!r}')
obj.__dict__[f'_{self.name}'] = value


def audit_access(cls):
"""Decorator that tracks all attribute access."""
def __getattribute__(self, name):
if not name.startswith('_'):
print(f'[AUDIT] Accessed {cls.__name__}.{name}')
return super(cls, self).__getattribute__(name)

cls.__getattribute__ = __getattribute__
return cls


class FrameworkMeta(type):
"""Metaclass that enforces field definitions."""

def __new__(mcs, name, bases, namespace):
# Require that all subclasses declare fields
has_fields = any(isinstance(v, TypeCheckedField) for v in namespace.values())

if name not in ('BaseModel',) and not has_fields and bases:
raise TypeError(f'{name} must define at least one field')

# Auto-generate __repr__
def __repr__(self):
field_reprs = []
for key, value in self.__dict__.items():
if not key.startswith('_'):
field_reprs.append(f'{key}={value!r}')
return f'{name}({", ".join(field_reprs)})'

namespace['__repr__'] = __repr__

return super().__new__(mcs, name, bases, namespace)


@audit_access
class BaseModel(metaclass=FrameworkMeta):
pass


class User(BaseModel):
name = TypeCheckedField(str)
age = TypeCheckedField(int, validator=lambda x: 0 < x < 150)


user = User()
user.name = "Alice" # Output: [AUDIT] Accessed User.name
user.age = 30 # Output: [AUDIT] Accessed User.age

print(user) # Output: User(name='Alice', age=30)

This three-layer approach provides (1) per-field validation (descriptor), (2) cross-cutting audit logging (decorator), and (3) schema-level enforcement (metaclass).

Decision Tree: Which Tool to Use

Need to customize attribute access on instances?
→ Use descriptors (__get__, __set__)

Need to transform a class one-time at definition?
→ Use class decorators

Need to control how subclasses are created?
→ Use __init_subclass__

Need full control over class creation and inheritance?
→ Use metaclasses

Need to wrap methods for cross-cutting concerns (logging, caching)?
→ Use function/method decorators

Need all of the above in one system?
→ Combine them: descriptors for fields, decorators for behavior, metaclasses for schema

Key Takeaways

  • Descriptors excel at attribute-level customization; metaclasses excel at class-level schema.
  • Decorators provide composable, reusable transformations without modifying source code.
  • Production frameworks layer all three: descriptors for validation, decorators for concerns, metaclasses for registration and schema.
  • Understand the role of each tool to avoid over-engineering; not every problem needs a metaclass.

Frequently Asked Questions

Which should I learn first: descriptors, decorators, or metaclasses?

Learn in this order: (1) decorators (simplest), (2) descriptors (powerful but contained), (3) metaclasses (builds on the previous two).

Can I use descriptors without a metaclass?

Absolutely. Many frameworks use descriptors alone for field validation (e.g., Marshmallow). Metaclasses are optional; use them only when you need class-level control.

Is combining three tools an anti-pattern?

No, if each tool solves a distinct problem. Django, Pydantic, and SQLAlchemy all combine descriptors, decorators, and metaclasses. The key is clarity: document which tool handles what responsibility.

How do I avoid infinite recursion when combining these tools?

Each tool has its own "core" responsibility: descriptors handle attribute access, decorators wrap functions, metaclasses control class creation. Keep concerns separated and test at each layer independently.

Further Reading