Specifications
Reglas de negocio como objetos componibles: and, or, not aplicados a criterios del dominio.
Una Specification encapsula una regla de negocio como un objeto independiente con un método `is_satisfied_by(candidate)`. Su propósito principal es hacer las reglas de negocio explícitas, nombradas y reutilizables en lugar de dispersarlas como condicionales anónimos en aggregates, servicios o consultas.
El poder de las Specifications está en su composabilidad. Tres operaciones básicas — `and_`, `or_`, `not_` — permiten combinar specifications simples para construir reglas complejas sin duplicar código. Una `ActiveAndVerifiedCustomerSpecification` puede construirse combinando `ActiveCustomerSpecification` y `VerifiedCustomerSpecification` con un `AndSpecification`.
Las Specifications tienen tres usos principales: validación (verificar si un objeto cumple una regla antes de una operación), selección en memoria (filtrar colecciones de aggregates) y construcción de criterios de consulta (traducir la regla a SQL o filtros de repositorio). La misma clase puede cumplir los tres roles.
En Python, la base se implementa con una clase abstracta genérica que define `is_satisfied_by(candidate: T) -> bool`. Las implementaciones de `AndSpecification`, `OrSpecification` y `NotSpecification` usan composición y hacen innecesario heredar para combinar.
Las Specifications viven en la capa de dominio (`domain/specifications/`). No tienen dependencias de infraestructura. Si una Specification necesita datos externos para evaluarse (por ejemplo, comprobar si un cliente tiene deudas activas), esos datos deben llegar como parámetros o a través de un puerto inyectado, nunca accediendo directamente a la BD.
No todas las reglas de negocio merecen una Specification. Para reglas simples y de un solo uso, un método booleano en el aggregate o Value Object es suficiente y más directo. Las Specifications brillan cuando la regla se reutiliza en múltiples contextos (validación + selección + consulta) o cuando la composición de reglas es necesaria.
# domain/specifications/base_specification.py
# Contrato base: toda Specification implementa is_satisfied_by.
# La composición (and/or/not) es gratuita al heredar de esta base.
from abc import ABC, abstractmethod
from typing import TypeVar, Generic
T = TypeVar('T')
class Specification(ABC, Generic[T]):
@abstractmethod
def is_satisfied_by(self, candidate: T) -> bool:
"""Evalúa si el candidato cumple la regla. Sin efectos secundarios."""
...
def and_(self, other: 'Specification[T]') -> 'Specification[T]':
return _AndSpecification(self, other)
def or_(self, other: 'Specification[T]') -> 'Specification[T]':
return _OrSpecification(self, other)
def not_(self) -> 'Specification[T]':
return _NotSpecification(self)
class _AndSpecification(Specification[T]):
def __init__(self, left: Specification[T], right: Specification[T]) -> None:
self._left = left
self._right = right
def is_satisfied_by(self, candidate: T) -> bool:
return self._left.is_satisfied_by(candidate) and self._right.is_satisfied_by(candidate)
class _OrSpecification(Specification[T]):
def __init__(self, left: Specification[T], right: Specification[T]) -> None:
self._left = left
self._right = right
def is_satisfied_by(self, candidate: T) -> bool:
return self._left.is_satisfied_by(candidate) or self._right.is_satisfied_by(candidate)
class _NotSpecification(Specification[T]):
def __init__(self, wrapped: Specification[T]) -> None:
self._wrapped = wrapped
def is_satisfied_by(self, candidate: T) -> bool:
return not self._wrapped.is_satisfied_by(candidate)
# domain/specifications/<rule>_specification.py
# Regla concreta del dominio con nombre en el lenguaje ubicuo.
from .<base_specification> import Specification
from ..<model>.<aggregate> import <Aggregate>
class <BusinessRule>Specification(Specification[<Aggregate>]):
"""<Describe la regla en lenguaje de negocio.>"""
def is_satisfied_by(self, candidate: <Aggregate>) -> bool:
# Lógica pura de dominio: sin I/O, sin efectos secundarios
return candidate.<attribute> <condition>
# ── Composición ──────────────────────────────────────────────────────────────
# application o domain service que construye reglas compuestas:
eligible_spec = (
<BusinessRule>Specification()
.and_(<AnotherRule>Specification())
.and_(<ThirdRule>Specification().not_())
)
# Uso para validación:
if not eligible_spec.is_satisfied_by(candidate):
raise <DomainError>('<reason>')
# Uso para selección en memoria:
eligible_items = [item for item in items if eligible_spec.is_satisfied_by(item)]Debugging lab
Detecta y corrige el error o la violación de diseño.
- 4.5.5.1
# domain/specifications/premium_customer_specification.py class PremiumCustomerSpecification(Specification[Customer]): def is_satisfied_by(self, candidate: Customer) -> bool: self._db.query(...) # ← error: I/O en Specification raise ValueError('not premium') # ← error: lanza excepción en is_satisfied_by
- 4.5.5.2
# domain/model/order.py (fragmento) class Order: def can_be_shipped(self) -> bool: return ( self.status == OrderStatus.PAID and self.items and not self.is_flagged_for_review and self.shipping_address is not None and self.weight_kg <= MAX_WEIGHT ) # ← regla compleja y no reutilizable dispersa en el aggregate
- 4.5.5.3
# domain/specifications/active_account_specification.py class ActiveAccountSpecification(Specification[Account]): def is_satisfied_by(self, candidate: Account) -> bool: return candidate.is_active # Uso en el handler: if not ActiveAccountSpecification().is_satisfied_by(account): pass # ← no hace nada cuando falla; no lanza error de dominio
- 4.5.5.4
# Intento de composición sin usar and_/or_/not_: class EligibleForLoanSpecification(Specification[Customer]): def is_satisfied_by(self, candidate: Customer) -> bool: # ← reimplementa la lógica de ActiveCustomerSpec y GoodCreditSpec return candidate.is_active and candidate.credit_score >= 650
- 4.5.5.5
# domain/specifications/recent_order_specification.py from datetime import datetime class RecentOrderSpecification(Specification[Order]): def is_satisfied_by(self, candidate: Order) -> bool: # ← datetime.now() sin timezone: comportamiento no determinista return (datetime.now() - candidate.created_at).days <= 30