Introducción
Hasta ahora has aprendido a escribir código organizado en clases y objetos. Pero cuando los proyectos crecen, surge una pregunta: ¿Cómo organizamos todo el código?
El Problema del Código Desorganizado
Imagina que en TaskFlow tienes:
- Módulos que importan SQLAlchemy en todas partes
- Lógica de negocio mezclada con consultas a base de datos
- Tests que necesitan una BD real para funcionar
- Código imposible de reutilizar en otro proyecto
DDD (Domain Driven Design) nos da principios y patrones para organizar el código de forma que el dominio (lógica de negocio) sea independiente de la tecnología.
Video Recomendado
Curso completo de DDD en español (13 videos):
Ver Playlist "DDD - Diseño Guiado por el Dominio" 13 videos | Software Architecture and DevelopmentObjetivos de Aprendizaje
Al finalizar esta clase, serás capaz de:
- Comprender los principios fundamentales de Domain Driven Design
- Distinguir Entidades de Value Objects y saber cuándo usar cada uno
- Implementar Entidades con identidad única y comportamiento
- Crear Value Objects inmutables con validación
- Aplicar el patrón Repository para abstraer la persistencia
- Usar inyección de dependencias para desacoplar código
1. ¿Qué es DDD? (15 min)
Definición
DDD (Domain-Driven Design / Diseño Guiado por el Dominio) es un enfoque de desarrollo de software que pone el foco en el dominio del problema y la lógica de negocio, manteniéndolos aislados de detalles técnicos como bases de datos, frameworks o interfaces de usuario.
Autor: Eric Evans (libro "Domain-Driven Design", 2003)
1.1 Principios Fundamentales
Lenguaje compartido entre expertos del dominio y desarrolladores.
Ejemplo: Si en tu empresa hablan de "Sprint" y "Story Points", tu código debe usar esos términos, no "Iteración" y "Puntos de Tarea".
Fronteras claras entre diferentes partes del dominio.
Ejemplo: "Usuario" en autenticación es diferente a "Usuario" en facturación. Son contextos separados.
El modelo incluye comportamiento, no solo datos (getter/setter anémicos).
Ejemplo: usuario.cambiar_email() en lugar de usuario.email = "nuevo"
1.2 Arquitectura en Capas DDD
DDD organiza el código en capas con dependencias claras:
Entidades, Value Objects, Servicios de Dominio, Eventos
Casos de uso, Servicios de aplicación, DTOs
Repositorios, Base de datos, APIs externas, Frameworks
Diagrama de Capas DDD
1.3 Bloques Constructivos de DDD
| Elemento | Definición | Ejemplo |
|---|---|---|
| Entity | Objeto con identidad única que persiste en el tiempo | Usuario, Tarea, Proyecto |
| Value Object | Objeto definido por sus atributos, inmutable | Email, Prioridad, Monto, Dirección |
| Repository | Abstracción de persistencia, colección en memoria | UsuarioRepository, TareaRepository |
| Service | Lógica que no pertenece a una sola entidad | AuthService, NotificationService |
| Aggregate | Cluster de entidades/value objects tratados como unidad | Orden con sus Items de Orden |
2. Entidades - Objetos con Identidad
¿Qué es una Entidad?
Una Entidad es un objeto que se define por su identidad, no por sus atributos. Dos entidades pueden tener exactamente los mismos atributos pero ser diferentes si tienen IDs diferentes.
Ejemplo real: Dos personas pueden llamarse "Juan Pérez", tener la misma edad y dirección, pero son personas diferentes (tienen DNI/passaportes diferentes).
2.1 Características de las Entidades
- Identidad única: Cada entidad tiene un ID que la identifica
- Ciclo de vida: Puede cambiar a lo largo del tiempo (mutable)
- Comportamiento: Encapsula lógica de negocio (no solo getters/setters)
- Igualdad por ID: Dos entidades son iguales si tienen el mismo ID
Entidad vs Value Object
2.2 Implementando una Entidad en Python
from dataclasses import dataclass
from typing import Optional
@dataclass
class Usuario:
"""
Entidad Usuario.
Se define por su ID, no por sus atributos.
Dos usuarios son el mismo si tienen el mismo ID,
aunque tengan diferentes emails o usernames.
"""
# ID puede ser None para usuarios nuevos (aún no persistidos)
id: Optional[int]
username: str
email: str
nombre_completo: str
activo: bool = True
def __eq__(self, other):
"""
Dos usuarios son iguales si tienen el mismo ID.
Importante: Si ambos tienen id=None, son diferentes
(usuarios aún no guardados son siempre diferentes)
"""
if not isinstance(other, Usuario):
return False
# Si alguno no tiene ID, son diferentes
if self.id is None or other.id is None:
return False
return self.id == other.id
def __hash__(self):
"""Necesario para usar en sets y como keys de dict."""
return hash(self.id) if self.id else id(self)
# ============================================
# COMPORTAMIENTO DE DOMINIO (no solo getters/setters)
# ============================================
def cambiar_email(self, nuevo_email: str):
"""
Regla de negocio: cambiar email con validación.
En lugar de: usuario.email = "nuevo" (anémico)
Hacemos: usuario.cambiar_email("nuevo") (rico)
"""
if "@" not in nuevo_email:
raise ValueError("Email inválido: debe contener @")
dominio = nuevo_email.split("@")[1]
if "." not in dominio:
raise ValueError("Email inválido: dominio incorrecto")
self.email = nuevo_email
def desactivar(self):
"""Regla de negocio: desactivar usuario."""
if not self.activo:
raise ValueError("El usuario ya está desactivado")
self.activo = False
def activar(self):
"""Regla de negocio: activar usuario."""
if self.activo:
raise ValueError("El usuario ya está activo")
self.activo = True
def puede_crear_proyectos(self) -> bool:
"""
Regla de negocio: solo usuarios activos pueden crear proyectos.
"""
return self.activo
def actualizar_perfil(self, nombre: Optional[str] = None,
email: Optional[str] = None):
"""
Regla de negocio: actualizar perfil con validaciones.
"""
if nombre:
if len(nombre) < 2:
raise ValueError("Nombre muy corto")
self.nombre_completo = nombre
if email:
self.cambiar_email(email)
# Ejemplo de uso
if __name__ == "__main__":
# Crear usuarios
usuario1 = Usuario(id=1, username="juan", email="juan@email.com", nombre_completo="Juan Pérez")
usuario2 = Usuario(id=1, username="juan2", email="juan2@email.com", nombre_completo="Juan Otro")
usuario3 = Usuario(id=2, username="juan", email="juan@email.com", nombre_completo="Juan Pérez")
# Comparación
print(usuario1 == usuario2) # True (mismo ID)
print(usuario1 == usuario3) # False (diferente ID)
# Comportamiento de dominio
usuario1.cambiar_email("nuevo@email.com") # OK
# usuario1.cambiar_email("invalido") # ValueError
usuario1.desactivar()
print(usuario1.puede_crear_proyectos()) # False
Anémico: Solo datos y getters/setters. La lógica está en servicios externos.
Rico: Datos + comportamiento. La entidad protege sus invariantes y reglas de negocio.
3. Value Objects - Objetos por Valor
¿Qué es un Value Object?
Un Value Object es un objeto que se define por sus atributos, no por identidad. Es inmutable (no cambia después de crearse) y se valida al momento de la creación.
Ejemplo real: Dos billetes de $20 son intercambiables. No importa cuál tengas, ambos valen lo mismo. No tienen "identidad".
3.1 Características de los Value Objects
- Sin identidad: Se define por sus valores (atributos)
- Inmutable: No cambia después de crearse (frozen)
- Validado: Se valida al crearse, si es inválido no se crea
- Igualdad por atributos: Dos VOs son iguales si tienen los mismos valores
- Reemplazable: Si necesitas cambiarlo, creas uno nuevo
3.2 Implementando Value Objects
from dataclasses import dataclass
@dataclass(frozen=True) # frozen=True hace que sea inmutable
class Email:
"""
Value Object para emails.
Se valida al crearse y es inmutable.
"""
valor: str
def __post_init__(self):
"""
Validación automática después de __init__.
En dataclasses frozen, necesitamos usar object.__setattr__
"""
# Validar formato
if "@" not in self.valor:
raise ValueError(f"Email inválido (falta @): {self.valor}")
partes = self.valor.split("@")
if len(partes) != 2:
raise ValueError(f"Email inválido (múltiples @): {self.valor}")
usuario, dominio = partes
if not usuario or not dominio:
raise ValueError(f"Email inválido (vacío): {self.valor}")
if "." not in dominio:
raise ValueError(f"Email inválido (sin dominio): {self.valor}")
# Normalizar a minúsculas
object.__setattr__(self, 'valor', self.valor.lower())
def dominio(self) -> str:
"""Retorna el dominio del email."""
return self.valor.split("@")[1]
def usuario(self) -> str:
"""Retorna la parte antes del @."""
return self.valor.split("@")[0]
def __str__(self) -> str:
return self.valor
@dataclass(frozen=True)
class Prioridad:
"""
Value Object para prioridades de tareas.
"""
valor: str
def __post_init__(self):
valores_validos = {"baja", "media", "alta", "urgente"}
if self.valor not in valores_validos:
raise ValueError(
f"Prioridad inválida: {self.valor}. "
f"Valores válidos: {valores_validos}"
)
def es_urgente(self) -> bool:
"""Retorna True si es urgente."""
return self.valor == "urgente"
def __lt__(self, other: 'Prioridad') -> bool:
"""Permite comparar prioridades: baja < media < alta < urgente"""
orden = {"baja": 1, "media": 2, "alta": 3, "urgente": 4}
return orden[self.valor] < orden[other.valor]
@dataclass(frozen=True)
class Monto:
"""
Value Object para montos monetarios.
"""
valor: float
moneda: str = "USD"
def __post_init__(self):
if self.valor < 0:
raise ValueError("Monto no puede ser negativo")
if not self.moneda or len(self.moneda) != 3:
raise ValueError("Moneda debe ser código ISO de 3 letras")
def __add__(self, otro: 'Monto') -> 'Monto':
"""Suma dos montos (deben ser misma moneda)."""
if self.moneda != otro.moneda:
raise ValueError(f"No se pueden sumar {self.moneda} y {otro.moneda}")
return Monto(self.valor + otro.valor, self.moneda)
def __sub__(self, otro: 'Monto') -> 'Monto':
"""Resta dos montos."""
if self.moneda != otro.moneda:
raise ValueError(f"No se pueden restar {self.moneda} y {otro.moneda}")
resultado = self.valor - otro.valor
if resultado < 0:
raise ValueError("Resultado negativo no permitido")
return Monto(resultado, self.moneda)
def __str__(self) -> str:
return f"{self.moneda} {self.valor:.2f}"
# Ejemplo de uso
if __name__ == "__main__":
# Emails
email1 = Email("Juan@Example.COM") # Normaliza a minúsculas
print(email1) # juan@example.com
print(email1.dominio()) # example.com
# email1.valor = "otro" # Error: frozen
# Para "cambiar" un VO, creas uno nuevo
email2 = Email("nuevo@example.com")
# Comparación por valor
email3 = Email("juan@example.com")
print(email1 == email3) # True (mismo valor)
# Prioridades
p_alta = Prioridad("alta")
p_media = Prioridad("media")
print(p_alta > p_media) # True
# Montos
m1 = Monto(100.50, "USD")
m2 = Monto(50.25, "USD")
m3 = m1 + m2
print(m3) # USD 150.75
3.3 Entidad vs Value Object: Cuándo Usar Cada Uno
| Aspecto | Entidad | Value Object |
|---|---|---|
| Identidad | Por ID único | Por atributos (sin ID) |
| Mutable | Sí (tiene estado) | No (inmutable/frozen) |
| Comparación | Por ID | Por todos los atributos |
| Ciclo de vida | Largo, evoluciona en el tiempo | Corto, se reemplaza, no cambia |
| Ejemplos | Usuario, Tarea, Proyecto, Pedido | Email, Dirección, Monto, Prioridad, Color |
- Entidad: Cambia el email de un usuario, sigue siendo el mismo usuario → Es Entidad
- VO:>/strong> Cambias un dígito de un email, es un email diferente → Es Value Object
4. Patrón Repository - Abstracción de Persistencia
¿Qué es el Patrón Repository?
Un Repository es una abstracción que encapsula la lógica de persistencia. Actúa como una colección en memoria de entidades, ocultando si los datos vienen de una base de datos, archivos, API externa, etc.
Beneficio clave: Tu dominio NO sabe CÓMO se guardan los datos. Puede usar SQLite, PostgreSQL, MongoDB, o incluso un diccionario en memoria para tests.
4.1 Interfaz Repository Genérica
# domain/repositories/base.py
from abc import ABC, abstractmethod # ABC = Abstract Base Class
from typing import TypeVar, Generic, List, Optional
T = TypeVar('T') # Tipo genérico para cualquier entidad
class Repository(ABC, Generic[T]):
"""
Interfaz base para todos los repositorios.
Define las operaciones CRUD básicas que cualquier
repositorio debe implementar.
T es el tipo de entidad (Usuario, Tarea, etc.)
"""
@abstractmethod
def guardar(self, entidad: T) -> T:
"""
Guarda una entidad.
Si la entidad no tiene ID, la asigna.
Si ya tiene ID, actualiza.
Returns:
La entidad guardada (con ID si era nueva)
"""
pass
@abstractmethod
def obtener_por_id(self, id: int) -> Optional[T]:
"""
Busca una entidad por su ID.
Returns:
La entidad si existe, None si no
"""
pass
@abstractmethod
def obtener_todos(self) -> List[T]:
"""
Obtiene todas las entidades.
Returns:
Lista de entidades (vacía si no hay)
"""
pass
@abstractmethod
def eliminar(self, id: int) -> bool:
"""
Elimina una entidad por ID.
Returns:
True si se eliminó, False si no existía
"""
pass
@abstractmethod
def existe(self, id: int) -> bool:
"""
Verifica si existe una entidad con ese ID.
Returns:
True si existe, False si no
"""
pass
4.2 Implementación en Memoria (para Tests)
# infrastructure/repositories/usuario_repo.py
from typing import Dict, List, Optional
from domain.repositories.base import Repository
from domain.models.usuario import Usuario
class UsuarioMemoryRepository(Repository[Usuario]):
"""
Implementación en memoria del repositorio de usuarios.
Útil para:
- Tests unitarios (rápido, no necesita BD)
- Desarrollo inicial
- Prototipos
"""
def __init__(self):
# Almacenamiento en memoria
self._datos: Dict[int, Usuario] = {}
self._siguiente_id = 1
def guardar(self, usuario: Usuario) -> Usuario:
"""
Guarda un usuario.
Si no tiene ID, asigna uno nuevo.
"""
if usuario.id is None:
# Es un usuario nuevo, asignar ID
usuario.id = self._siguiente_id
self._siguiente_id += 1
# Guardar (crear o actualizar)
self._datos[usuario.id] = usuario
return usuario
def obtener_por_id(self, id: int) -> Optional[Usuario]:
"""Obtiene usuario por ID."""
return self._datos.get(id)
def obtener_todos(self) -> List[Usuario]:
"""Obtiene todos los usuarios."""
return list(self._datos.values())
def obtener_por_email(self, email: str) -> Optional[Usuario]:
"""
Busca usuario por email.
Método específico de este repositorio.
"""
for usuario in self._datos.values():
if usuario.email == email:
return usuario
return None
def eliminar(self, id: int) -> bool:
"""Elimina usuario por ID."""
if id in self._datos:
del self._datos[id]
return True
return False
def existe(self, id: int) -> bool:
"""Verifica si existe el usuario."""
return id in self._datos
def existe_email(self, email: str) -> bool:
"""Verifica si existe un usuario con ese email."""
return any(u.email == email for u in self._datos.values())
def limpiar(self):
"""Limpia todos los datos (útil para tests)."""
self._datos.clear()
self._siguiente_id = 1
4.3 Implementación con SQLAlchemy (producción)
# infrastructure/repositories/usuario_sqlalchemy_repo.py
from sqlalchemy.orm import Session
from typing import List, Optional
from domain.repositories.base import Repository
from domain.models.usuario import Usuario
from infrastructure.database.models import UsuarioDB # Modelo SQLAlchemy
class UsuarioSQLAlchemyRepository(Repository[Usuario]):
"""
Implementación con SQLAlchemy del repositorio.
Usa una base de datos real (PostgreSQL, SQLite, etc.)
"""
def __init__(self, session: Session):
self._session = session
def guardar(self, usuario: Usuario) -> Usuario:
"""Guarda usuario en base de datos."""
if usuario.id is None:
# Crear nuevo
db_usuario = UsuarioDB(
username=usuario.username,
email=usuario.email,
nombre_completo=usuario.nombre_completo,
activo=usuario.activo
)
self._session.add(db_usuario)
else:
# Actualizar existente
db_usuario = self._session.query(UsuarioDB).get(usuario.id)
db_usuario.username = usuario.username
db_usuario.email = usuario.email
db_usuario.activo = usuario.activo
self._session.commit()
# Actualizar el ID en la entidad de dominio
usuario.id = db_usuario.id
return usuario
def obtener_por_id(self, id: int) -> Optional[Usuario]:
"""Obtiene usuario de la BD."""
db_usuario = self._session.query(UsuarioDB).get(id)
if db_usuario:
return self._to_domain(db_usuario)
return None
def _to_domain(self, db_usuario: UsuarioDB) -> Usuario:
"""Convierte modelo DB a entidad de dominio."""
return Usuario(
id=db_usuario.id,
username=db_usuario.username,
email=db_usuario.email,
nombre_completo=db_usuario.nombre_completo,
activo=db_usuario.activo
)
# ... otros métodos ...
5. Inyección de Dependencias
El patrón Repository se combina con Inyección de Dependencias para desacoplar completamente el dominio de la infraestructura.
5.1 Servicio que usa Repository
# application/services/usuario_service.py
from domain.models.usuario import Usuario
from domain.repositories.base import Repository
class UsuarioService:
"""
Servicio de aplicación para gestión de usuarios.
NO sabe CÓMO se persisten los datos.
Solo sabe QUE se pueden guardar/obtener mediante el repository.
"""
def __init__(self, repo: Repository[Usuario]):
"""
Constructor recibe el repositorio inyectado.
Args:
repo: Cualquier implementación de Repository[Usuario]
(puede ser MemoryRepository para tests
o SQLAlchemyRepository para producción)
"""
self._repo = repo
def crear_usuario(self, username: str, email: str,
nombre_completo: str) -> Usuario:
"""
Crea un nuevo usuario con validaciones.
"""
# Validaciones de negocio
if len(username) < 3:
raise ValueError("Username debe tener al menos 3 caracteres")
if self._repo.existe_email(email):
raise ValueError(f"Ya existe un usuario con email {email}")
# Crear entidad
usuario = Usuario(
id=None, # Sin ID, se asignará al guardar
username=username,
email=email,
nombre_completo=nombre_completo
)
# Guardar usando el repositorio
return self._repo.guardar(usuario)
def obtener_usuario(self, id: int) -> Usuario:
"""
Obtiene un usuario por ID.
"""
usuario = self._repo.obtener_por_id(id)
if not usuario:
raise ValueError(f"Usuario con ID {id} no encontrado")
return usuario
def desactivar_usuario(self, id: int) -> Usuario:
"""
Desactiva un usuario.
"""
usuario = self.obtener_usuario(id)
usuario.desactivar()
return self._repo.guardar(usuario)
def listar_usuarios_activos(self) -> List[Usuario]:
"""
Lista todos los usuarios activos.
"""
todos = self._repo.obtener_todos()
return [u for u in todos if u.activo]
5.2 Configuración de Dependencias
# config/dependencies.py
from infrastructure.repositories.usuario_repo import UsuarioMemoryRepository
from infrastructure.repositories.usuario_sqlalchemy_repo import UsuarioSQLAlchemyRepository
from application.services.usuario_service import UsuarioService
def create_usuario_service_for_tests():
"""
Crea servicio con repositorio en memoria.
Útil para tests unitarios (rápido, aislado).
"""
repo = UsuarioMemoryRepository()
return UsuarioService(repo)
def create_usuario_service_for_production(db_session):
"""
Crea servicio con repositorio de base de datos.
Úsalo en la aplicación real.
"""
repo = UsuarioSQLAlchemyRepository(db_session)
return UsuarioService(repo)
# Ejemplo de uso
if __name__ == "__main__":
# Para tests
service_test = create_usuario_service_for_tests()
# Crear usuarios
usuario1 = service_test.crear_usuario(
"juan123", "juan@email.com", "Juan Pérez"
)
print(f"Creado: {usuario1}")
# Obtener
usuario2 = service_test.obtener_usuario(usuario1.id)
print(f"Obtenido: {usuario2}")
# Desactivar
usuario3 = service_test.desactivar_usuario(usuario1.id)
print(f"Activos: {len(service_test.listar_usuarios_activos())}")
- Testeabilidad: Puedes usar repositorios "fake" en tests
- Flexibilidad: Cambias SQLite por PostgreSQL sin tocar el servicio
- Desacoplamiento: El servicio no depende de detalles de infraestructura
- Cumple S.O.L.I.D: Dependency Inversion Principle
5.3 Comparación: Antes vs Después DDD
class UsuarioService:
def crear_usuario(self, username, email):
# ¡ACOPLADO a SQLite!
import sqlite3
conn = sqlite3.connect('taskflow.db')
cursor = conn.cursor()
# Lógica mezclada con SQL
cursor.execute(
"INSERT INTO usuarios ...",
(username, email)
)
conn.commit()
conn.close()
# Problemas:
# - No se puede testear sin BD real
# - No se puede cambiar a PostgreSQL
# - Lógica de negocio mezclada con SQL
# - Difícil de mantener
class UsuarioService:
def __init__(self, repo: Repository[Usuario]):
self._repo = repo # Inyectado
def crear_usuario(self, username, email):
# Solo lógica de negocio
if len(username) < 3:
raise ValueError("Username muy corto")
usuario = Usuario(
id=None,
username=username,
email=email
)
return self._repo.guardar(usuario)
# Beneficios:
# - Testeable con repo fake
# - Fácil cambiar implementación
# - Lógica limpia y pura
# - Mantenible
Ejercicio: Refactorizar TaskFlow con DDD
Aplica los conceptos de DDD al módulo de Tareas de TaskFlow.
Tareas a Realizar
- Crear Entidad Tarea:
- Atributos: id, titulo, descripcion, estado, fecha_creacion, fecha_limite
- Métodos: completar(), cambiar_prioridad(), esta_atrasada()
- Crear Value Objects:
- Prioridad (baja, media, alta, urgente)
- EstadoTarea (pendiente, en_progreso, completada)
- Crear Repositorio:
- TareaRepository con operaciones CRUD
- Implementación en memoria
- Crear Servicio:
- TareaService con lógica de negocio
- Inyección del repositorio
- Tests: Crear tests unitarios para todo
Estructura de Archivos Sugerida
taskflow/
├── domain/
│ ├── models/
│ │ ├── __init__.py
│ │ ├── usuario.py # Entidad Usuario
│ │ └── tarea.py # Entidad Tarea
│ ├── value_objects/
│ │ ├── __init__.py
│ │ ├── email.py
│ │ ├── prioridad.py
│ │ └── estado_tarea.py
│ └── repositories/
│ ├── __init__.py
│ └── base.py # Interfaz Repository
├── application/
│ ├── __init__.py
│ └── services/
│ ├── __init__.py
│ ├── usuario_service.py
│ └── tarea_service.py
├── infrastructure/
│ ├── repositories/
│ │ ├── __init__.py
│ │ ├── usuario_memory_repo.py
│ │ └── tarea_memory_repo.py
│ └── database/
│ └── ... # SQLAlchemy models (más adelante)
└── tests/
├── test_usuario.py
├── test_tarea.py
├── test_usuario_service.py
└── test_tarea_service.py
Ejemplo de Implementación
# domain/models/tarea.py
from dataclasses import dataclass
from datetime import datetime, date
from typing import Optional
from domain.value_objects.prioridad import Prioridad
from domain.value_objects.estado_tarea import EstadoTarea
@dataclass
class Tarea:
id: Optional[int]
titulo: str
descripcion: str
prioridad: Prioridad
estado: EstadoTarea = EstadoTarea("pendiente")
fecha_creacion: datetime = None
fecha_limite: Optional[date] = None
def __post_init__(self):
if self.fecha_creacion is None:
self.fecha_creacion = datetime.now()
def completar(self):
"""Marca la tarea como completada."""
self.estado = EstadoTarea("completada")
def esta_atrasada(self) -> bool:
"""Verifica si la tarea está atrasada."""
if self.fecha_limite is None:
return False
return (
self.estado.valor != "completada" and
date.today() > self.fecha_limite
)
def cambiar_prioridad(self, nueva_prioridad: Prioridad):
"""Cambia la prioridad de la tarea."""
self.prioridad = nueva_prioridad
# domain/value_objects/prioridad.py
from dataclasses import dataclass
@dataclass(frozen=True)
class Prioridad:
valor: str
def __post_init__(self):
validas = {"baja", "media", "alta", "urgente"}
if self.valor not in validas:
raise ValueError(f"Prioridad inválida: {self.valor}")
# application/services/tarea_service.py
from domain.models.tarea import Tarea
from domain.repositories.base import Repository
from domain.value_objects.prioridad import Prioridad
class TareaService:
def __init__(self, repo: Repository[Tarea]):
self._repo = repo
def crear_tarea(self, titulo: str, descripcion: str,
prioridad_str: str) -> Tarea:
if len(titulo) < 3:
raise ValueError("Título muy corto")
prioridad = Prioridad(prioridad_str)
tarea = Tarea(
id=None,
titulo=titulo,
descripcion=descripcion,
prioridad=prioridad
)
return self._repo.guardar(tarea)
def completar_tarea(self, tarea_id: int) -> Tarea:
tarea = self._repo.obtener_por_id(tarea_id)
if not tarea:
raise ValueError("Tarea no encontrada")
tarea.completar()
return self._repo.guardar(tarea)
def listar_tareas_pendientes(self):
todas = self._repo.obtener_todos()
return [t for t in todas if t.estado.valor == "pendiente"]
- Implementar modelo Usuario con Entidad + VOs + Repository + Service
- Implementar modelo Tarea con Entidad + VOs + Repository + Service
- Tests unitarios con cobertura > 80%
- Documentar arquitectura en README.md
Resumen y Checklist
Conceptos Clave
- DDD: Diseño centrado en el dominio
- Ubiquitous Language: Lenguaje compartido
- Arquitectura en capas: Dominio → Aplicación → Infraestructura
- Entidad: Identidad por ID, mutable, con comportamiento
- Value Object: Identidad por atributos, inmutable
- Repository: Abstrae persistencia del dominio
- Inyección de dependencias: El dominio no conoce infraestructura
Reglas de Oro
❌ Importar SQLAlchemy en el dominio
❌ Llamar a la BD desde entidades
❌ Acoplar servicios a implementaciones específicas
❌ Usar modelos anémicos (solo getters/setters)
✅ Entidades con comportamiento de negocio
✅ Value Objects inmutables y validados
✅ Repositorios que abstraen persistencia
✅ Inyectar dependencias desde afuera