ARQUITECTURA DOMAIN-DRIVEN DESIGN O DDD
La arquitectura Domain-Driven Design, o DDD, es un enfoque de diseño de software creado por Eric Evans que pone el dominio del negocio en el centro de la aplicación. En lugar de empezar por la base de datos o por los controladores, se modela primero el lenguaje y las reglas del negocio.El objetivo principal es que el código refleje de la forma más fiel posible el lenguaje que usan los expertos del dominio (el Ubiquitous Language).Principios clave de DDD
- Ubiquitous Language: Todo el equipo, los desarrolladores y los expertos de negocio, usa el mismo vocabulario.
- Bounded Context: Cada parte del sistema tiene su propio modelo y límites claros.
- Separación de capas: Domain, Application, Infrastructure y Presentation.
- El dominio no depende de nada externo, ni de frameworks, ni de bases de datos, ni de HTTP.
Capas típicas en una arquitectura DDD
- Capa de Presentation / UI: Controllers, API, CLI.
- Capa de Aplicación: Casos de uso y Servicios de aplicacion.
- Capa de dominio: Entidades, Value Objects, Aggregates, Servicios de dominio.
- Capa de insfraestructa: Repositorios,, ORM, Email, Colas, etc.
Conceptos fundamentales
Value Objects
Son objetos inmutables que se definen por su valor, no por su identidad.
declare(strict_types=1);
namespace App\Domain\Shared\ValueObject;
final class Email
{
private string $value;
public function __construct(string $value)
{
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Email inválido');
}
$this->value = strtolower($value);
}
public function value(): string
{
return $this->value;
}
public function equals(Email $other): bool
{
return $this->value === $other->value;
}
}
Entities
Tienen identidad única y pueden cambiar de estado a lo largo del tiempo.
namespace App\Domain\User;
use App\Domain\Shared\ValueObject\Email;
use App\Domain\User\ValueObject\UserId;
final class User
{
private UserId $id;
private string $name;
private Email $email;
private \DateTimeImmutable $createdAt;
private function __construct(
UserId $id,
string $name,
Email $email
) {
$this->id = $id;
$this->name = $name;
$this->email = $email;
$this->createdAt = new \DateTimeImmutable();
}
public static function create(UserId $id, string $name, Email $email): self
{
// Aquí pueden ir reglas de negocio
if (strlen($name) < 2) {
throw new \DomainException('El nombre debe tener al menos 2 caracteres');
}
return new self($id, $name, $email);
}
public function changeEmail(Email $newEmail): void
{
$this->email = $newEmail;
// Aquí se podría disparar un Domain Event
}
// Getters...
public function id(): UserId { return $this->id; }
public function name(): string { return $this->name; }
public function email(): Email { return $this->email; }
}
Aggregate Root
Es la entidad principal que controla la consistencia de un grupo de objetos. Solo se accede al agregado a través del Aggregate Root. Ejemplo: un Pedido (Order) que contiene OrderItems.
namespace App\Domain\Order;
final class Order
{
private OrderId $id;
private CustomerId $customerId;
/** @var OrderItem[] */
private array $items = [];
private OrderStatus $status;
private Money $total;
public function __construct(OrderId $id, CustomerId $customerId)
{
$this->id = $id;
$this->customerId = $customerId;
$this->status = OrderStatus::pending();
$this->total = Money::zero();
}
public function addItem(ProductId $productId, int $quantity, Money $unitPrice): void
{
if ($this->status->isNotPending()) {
throw new \DomainException('No se pueden añadir items a un pedido que no está pendiente');
}
$this->items[] = new OrderItem($productId, $quantity, $unitPrice);
$this->recalculateTotal();
}
private function recalculateTotal(): void
{
$this->total = array_reduce(
$this->items,
fn(Money $carry, OrderItem $item) => $carry->add($item->subtotal()),
Money::zero()
);
}
public function confirm(): void
{
if (empty($this->items)) {
throw new \DomainException('No se puede confirmar un pedido vacío');
}
$this->status = OrderStatus::confirmed();
// Disparar evento: OrderConfirmed
}
}
Dominio
El dominio define la interfaz.
namespace App\Domain\User;
interface UserRepository
{
public function save(User $user): void;
public function findById(UserId $id): ?User;
public function findByEmail(Email $email): ?User;
public function nextIdentity(): UserId;
}
Aplicación
Orquesta el flujo de la aplicación. No contiene lógica de negocio compleja.
namespace App\Application\User;
use App\Domain\User\User;
use App\Domain\User\UserRepository;
use App\Domain\Shared\ValueObject\Email;
use App\Domain\User\ValueObject\UserId;
final class RegisterUser
{
public function __construct(
private UserRepository $userRepository
) {}
public function __invoke(string $name, string $email): void
{
$emailVO = new Email($email);
if ($this->userRepository->findByEmail($emailVO) !== null) {
throw new \DomainException('El email ya está registrado');
}
$user = User::create(
$this->userRepository->nextIdentity(),
$name,
$emailVO
);
$this->userRepository->save($user);
// Aquí se podrían disparar eventos de aplicación o notificaciones
}
}
Infraestructura
namespace App\Infrastructure\Persistence\Doctrine;
use App\Domain\User\User;
use App\Domain\User\UserRepository;
use App\Domain\User\ValueObject\UserId;
use App\Domain\Shared\ValueObject\Email;
use Doctrine\ORM\EntityManagerInterface;
final class DoctrineUserRepository implements UserRepository
{
public function __construct(
private EntityManagerInterface $em
) {}
public function save(User $user): void
{
$this->em->persist($user);
$this->em->flush();
}
public function findById(UserId $id): ?User
{
return $this->em->find(User::class, $id->value());
}
public function findByEmail(Email $email): ?User
{
return $this->em->getRepository(User::class)
->findOneBy(['email.value' => $email->value()]);
}
public function nextIdentity(): UserId
{
return UserId::generate(); // UUID por ejemplo
}
}
Estructura de carpetas recomendada
src/
├── Domain/
│ ├── User/
│ │ ├── User.php
│ │ ├── UserRepository.php
│ │ ├── ValueObject/
│ │ │ └── UserId.php
│ │ └── Event/
│ │ └── UserRegistered.php
│ ├── Order/
│ └── Shared/
│ └── ValueObject/
│ ├── Email.php
│ └── Money.php
├── Application/
│ ├── User/
│ │ ├── RegisterUser.php
│ │ └── ChangeUserEmail.php
│ └── Order/
│ └── CreateOrder.php
├── Infrastructure/
│ ├── Persistence/
│ │ └── Doctrine/
│ ├── Messaging/
│ └── ...
└── Presentation/
├── Http/
│ └── Controller/
└── Console/
Cuándo usar DDD
Recomendado
- El dominio es complejo con muchas reglas de negocio.
- El equipo trabaja junto con expertos de negocio.
- El proyecto tiene larga vida y va a crecer.
No recomendado
- CRUD simple.
- Aplicaciones muy pequeñas o prototipos.
- El dominio es trivial.
declare(strict_types=1);
namespace App\Domain\Shared\ValueObject;
final class Email
{
private string $value;
public function __construct(string $value)
{
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Email inválido');
}
$this->value = strtolower($value);
}
public function value(): string
{
return $this->value;
}
public function equals(Email $other): bool
{
return $this->value === $other->value;
}
}
namespace App\Domain\User;
use App\Domain\Shared\ValueObject\Email;
use App\Domain\User\ValueObject\UserId;
final class User
{
private UserId $id;
private string $name;
private Email $email;
private \DateTimeImmutable $createdAt;
private function __construct(
UserId $id,
string $name,
Email $email
) {
$this->id = $id;
$this->name = $name;
$this->email = $email;
$this->createdAt = new \DateTimeImmutable();
}
public static function create(UserId $id, string $name, Email $email): self
{
// Aquí pueden ir reglas de negocio
if (strlen($name) < 2) {
throw new \DomainException('El nombre debe tener al menos 2 caracteres');
}
return new self($id, $name, $email);
}
public function changeEmail(Email $newEmail): void
{
$this->email = $newEmail;
// Aquí se podría disparar un Domain Event
}
// Getters...
public function id(): UserId { return $this->id; }
public function name(): string { return $this->name; }
public function email(): Email { return $this->email; }
}
namespace App\Domain\Order;
final class Order
{
private OrderId $id;
private CustomerId $customerId;
/** @var OrderItem[] */
private array $items = [];
private OrderStatus $status;
private Money $total;
public function __construct(OrderId $id, CustomerId $customerId)
{
$this->id = $id;
$this->customerId = $customerId;
$this->status = OrderStatus::pending();
$this->total = Money::zero();
}
public function addItem(ProductId $productId, int $quantity, Money $unitPrice): void
{
if ($this->status->isNotPending()) {
throw new \DomainException('No se pueden añadir items a un pedido que no está pendiente');
}
$this->items[] = new OrderItem($productId, $quantity, $unitPrice);
$this->recalculateTotal();
}
private function recalculateTotal(): void
{
$this->total = array_reduce(
$this->items,
fn(Money $carry, OrderItem $item) => $carry->add($item->subtotal()),
Money::zero()
);
}
public function confirm(): void
{
if (empty($this->items)) {
throw new \DomainException('No se puede confirmar un pedido vacío');
}
$this->status = OrderStatus::confirmed();
// Disparar evento: OrderConfirmed
}
}
namespace App\Domain\User;
interface UserRepository
{
public function save(User $user): void;
public function findById(UserId $id): ?User;
public function findByEmail(Email $email): ?User;
public function nextIdentity(): UserId;
}
namespace App\Application\User;
use App\Domain\User\User;
use App\Domain\User\UserRepository;
use App\Domain\Shared\ValueObject\Email;
use App\Domain\User\ValueObject\UserId;
final class RegisterUser
{
public function __construct(
private UserRepository $userRepository
) {}
public function __invoke(string $name, string $email): void
{
$emailVO = new Email($email);
if ($this->userRepository->findByEmail($emailVO) !== null) {
throw new \DomainException('El email ya está registrado');
}
$user = User::create(
$this->userRepository->nextIdentity(),
$name,
$emailVO
);
$this->userRepository->save($user);
// Aquí se podrían disparar eventos de aplicación o notificaciones
}
}
namespace App\Infrastructure\Persistence\Doctrine;
use App\Domain\User\User;
use App\Domain\User\UserRepository;
use App\Domain\User\ValueObject\UserId;
use App\Domain\Shared\ValueObject\Email;
use Doctrine\ORM\EntityManagerInterface;
final class DoctrineUserRepository implements UserRepository
{
public function __construct(
private EntityManagerInterface $em
) {}
public function save(User $user): void
{
$this->em->persist($user);
$this->em->flush();
}
public function findById(UserId $id): ?User
{
return $this->em->find(User::class, $id->value());
}
public function findByEmail(Email $email): ?User
{
return $this->em->getRepository(User::class)
->findOneBy(['email.value' => $email->value()]);
}
public function nextIdentity(): UserId
{
return UserId::generate(); // UUID por ejemplo
}
}
src/
├── Domain/
│ ├── User/
│ │ ├── User.php
│ │ ├── UserRepository.php
│ │ ├── ValueObject/
│ │ │ └── UserId.php
│ │ └── Event/
│ │ └── UserRegistered.php
│ ├── Order/
│ └── Shared/
│ └── ValueObject/
│ ├── Email.php
│ └── Money.php
├── Application/
│ ├── User/
│ │ ├── RegisterUser.php
│ │ └── ChangeUserEmail.php
│ └── Order/
│ └── CreateOrder.php
├── Infrastructure/
│ ├── Persistence/
│ │ └── Doctrine/
│ ├── Messaging/
│ └── ...
└── Presentation/
├── Http/
│ └── Controller/
└── Console/
Cuándo usar DDD
Recomendado
- El dominio es complejo con muchas reglas de negocio.
- El equipo trabaja junto con expertos de negocio.
- El proyecto tiene larga vida y va a crecer.
No recomendado
- CRUD simple.
- Aplicaciones muy pequeñas o prototipos.
- El dominio es trivial.