DDD: Value Objects e Entities - Pilares do modelo de domínio (Parte 4)
Este é o quarto artigo da série sobre Domain-Driven Design. Nos artigos anteriores, exploramos os conceitos fundamentais do DDD, a importância da Linguagem Ubíqua e como os Bounded Contexts nos ajudam a delimitar contextos. Agora vamos mergulhar nos blocos de construção mais fundamentais do modelo de domínio: Value Objects e Entities.
O que são Value Objects e Entities?
No coração de qualquer modelo de domínio rico estão dois tipos principais de objetos: Entities (Entidades) e Value Objects (Objetos de Valor). A distinção entre eles é fundamental para criar um modelo de domínio expressivo e bem estruturado.
A Questão da Identidade
A diferença fundamental entre Entities e Value Objects está na forma como determinamos sua identidade:
Entities são objetos que possuem uma identidade única que persiste ao longo do tempo. Mesmo que todos os seus atributos mudem, uma Entity continua sendo a mesma Entity se sua identidade permanecer.
Value Objects são definidos pelos seus atributos e não possuem identidade conceitual. Dois Value Objects com os mesmos atributos são considerados idênticos.
Como Eric Evans define no livro original de DDD:
"Muitos objetos não são fundamentalmente definidos por seus atributos, mas sim por um fio de continuidade e identidade."
Entities: Identidade e Ciclo de Vida
Características das Entities
- Identidade única: Cada Entity possui um identificador único que não muda durante sua vida útil
- Ciclo de vida: Entities são criadas, podem ser modificadas ao longo do tempo e eventualmente podem ser destruídas
- Rastreabilidade: É importante saber se duas instances representam a mesma Entity
- Mutabilidade: Entities podem ter seus atributos alterados mantendo sua identidade
Exemplos Práticos de Entities
Usuário em um sistema:
class Usuario
{
private int $id;
private string $nome;
private string $email;
private DateTimeImmutable $dataCriacao;
public function __construct(int $id, string $nome, string $email)
{
$this->id = $id;
$this->nome = $nome;
$this->email = $email;
$this->dataCriacao = new DateTimeImmutable();
}
// Um usuário mantém sua identidade mesmo se mudar nome ou email
public function equals(Usuario $other): bool
{
return $this->id === $other->id;
}
public function alterarEmail(string $novoEmail): void
{
$this->email = $novoEmail;
}
public function getId(): int
{
return $this->id;
}
}
Pedido em um e-commerce:
class Pedido
{
private string $numeroPedido;
private StatusPedido $status;
private array $itens;
private float $valorTotal;
public function __construct(string $numeroPedido)
{
$this->numeroPedido = $numeroPedido;
$this->status = StatusPedido::CRIADO;
$this->itens = [];
$this->valorTotal = 0.0;
}
// O pedido é a mesma entidade independente do status ou itens
public function adicionarItem(ItemPedido $item): void
{
$this->itens[] = $item;
$this->recalcularTotal();
}
public function equals(Pedido $other): bool
{
return $this->numeroPedido === $other->numeroPedido;
}
private function recalcularTotal(): void
{
$this->valorTotal = array_sum(
array_map(fn($item) => $item->getSubtotal(), $this->itens)
);
}
}
Implementando Identidade
A identidade de uma Entity pode vir de diferentes formas:
Identificadores Naturais: Quando o domínio já possui um identificador único
- CPF para Pessoa
- CNPJ para Empresa
- Código de barras para Produto
Identificadores Gerados: Quando não existe um identificador natural
- UUIDs
- IDs auto-incrementais
- Códigos gerados com prefixos (ex: "USR-123456")
Value Objects: Descrição sem Identidade
Value Objects representam aspectos descritivos do domínio que não possuem identidade conceitual. Como Evans explica:
"Um objeto que representa um aspecto descritivo do domínio sem identidade conceitual é chamado de Value Object."
Características dos Value Objects
- Imutabilidade: Uma vez criados, não podem ser modificados
- Igualdade estrutural: Dois Value Objects com os mesmos atributos são idênticos
- Sem identidade: Não há necessidade de rastreá-los ao longo do tempo
- Funções side-effect free: Operações que não causam efeitos colaterais
Exemplos Práticos de Value Objects
Dinheiro:
final readonly class Dinheiro
{
public function __construct(
public float $valor,
public Moeda $moeda
) {
if ($valor < 0) {
throw new InvalidArgumentException("Valor não pode ser negativo");
}
}
public function somar(Dinheiro $outro): Dinheiro
{
if ($this->moeda !== $outro->moeda) {
throw new InvalidArgumentException("Moedas diferentes");
}
return new Dinheiro($this->valor + $outro->valor, $this->moeda);
}
public function multiplicar(int $quantidade): Dinheiro
{
return new Dinheiro($this->valor * $quantidade, $this->moeda);
}
public function isMaiorQue(Dinheiro $outro): bool
{
if ($this->moeda !== $outro->moeda) {
throw new InvalidArgumentException("Moedas diferentes");
}
return $this->valor > $outro->valor;
}
// Igualdade baseada nos atributos
public function equals(Dinheiro $outro): bool
{
return $this->valor === $outro->valor && $this->moeda === $outro->moeda;
}
public static function reais(float $valor): self
{
return new self($valor, Moeda::BRL);
}
}
enum Moeda: string
{
case BRL = 'BRL';
case USD = 'USD';
case EUR = 'EUR';
}
Endereço:
final readonly class Endereco
{
public function __construct(
public string $logradouro,
public string $numero,
public string $cep,
public string $cidade,
public string $estado
) {
$this->validarCep($cep);
$this->validarCamposObrigatorios($logradouro, $cidade, $estado);
}
public function isEnderecoValido(): bool
{
return !empty($this->cep) && !empty($this->cidade) && !empty($this->estado);
}
public function equals(Endereco $outro): bool
{
return $this->logradouro === $outro->logradouro
&& $this->numero === $outro->numero
&& $this->cep === $outro->cep
&& $this->cidade === $outro->cidade
&& $this->estado === $outro->estado;
}
public function comNovoNumero(string $novoNumero): self
{
return new self(
$this->logradouro,
$novoNumero,
$this->cep,
$this->cidade,
$this->estado
);
}
private function validarCep(string $cep): void
{
if (!preg_match('/^\d{5}-?\d{3}$/', $cep)) {
throw new InvalidArgumentException("CEP inválido: {$cep}");
}
}
private function validarCamposObrigatorios(string ...$campos): void
{
foreach ($campos as $campo) {
if (empty(trim($campo))) {
throw new InvalidArgumentException("Campos obrigatórios não podem estar vazios");
}
}
}
}
Como Decidir Entre Entity e Value Object
A decisão entre modelar algo como Entity ou Value Object depende do contexto e das necessidades do domínio. Aqui estão algumas perguntas que podem ajudar:
Perguntas para Entity
-
"Preciso rastrear este objeto ao longo do tempo?"
- Se sim, provavelmente é uma Entity
-
"Duas instâncias com os mesmos atributos representam a mesma coisa?"
- Se não, provavelmente é uma Entity
-
"Este objeto tem um ciclo de vida importante para o negócio?"
- Se sim, provavelmente é uma Entity
Perguntas para Value Object
-
"Posso substituir este objeto por outro com os mesmos atributos?"
- Se sim, provavelmente é um Value Object
-
"Este objeto descreve uma característica de algo?"
- Se sim, provavelmente é um Value Object
-
"A igualdade é baseada nos valores dos atributos?"
- Se sim, provavelmente é um Value Object
Exemplo: O Contexto Importa
Um Endereço pode ser tanto Entity quanto Value Object dependendo do contexto:
Como Value Object (na maioria dos casos):
- Descreve onde uma pessoa mora
- Dois endereços iguais são o mesmo endereço
- Não há necessidade de rastrear o endereço individualmente
Como Entity (em um sistema de entregas):
- Cada endereço tem um histórico de entregas
- Precisa rastrear tentativas de entrega
- Dois endereços iguais podem ter históricos diferentes
Combatendo a Obsessão por Primitivos
Value Objects são uma ferramenta poderosa para combater o que conhecemos como "Primitive Obsession" - o uso excessivo de tipos primitivos para representar conceitos de domínio.
Problema: Método com muitos primitivos
// Problemático: muitos string e int
function criarUsuario(
string $nome,
string $email,
string $cep,
string $cidade,
string $estado,
int $idade
): void {
// Validações espalhadas
if (empty($nome)) {
throw new InvalidArgumentException("Nome inválido");
}
if (!str_contains($email, '@')) {
throw new InvalidArgumentException("Email inválido");
}
// ... mais validações
}
Solução: Value Objects expressivos
// Melhor: Value Objects expressivos
function criarUsuario(Nome $nome, Email $email, Endereco $endereco, Idade $idade): void
{
// Validações encapsuladas nos Value Objects
// Código mais limpo e expressivo
}
final readonly class Email
{
public function __construct(public string $valor)
{
if (!$this->isEmailValido($valor)) {
throw new InvalidArgumentException("Email inválido: {$valor}");
}
}
public function equals(Email $outro): bool
{
return $this->valor === $outro->valor;
}
public function getDominio(): string
{
return substr($this->valor, strpos($this->valor, '@') + 1);
}
private function isEmailValido(string $email): bool
{
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
}
final readonly class Nome
{
public function __construct(public string $valor)
{
if (empty(trim($valor))) {
throw new InvalidArgumentException("Nome não pode estar vazio");
}
if (strlen($valor) < 2 || strlen($valor) > 100) {
throw new InvalidArgumentException("Nome deve ter entre 2 e 100 caracteres");
}
}
public function getPrimeiroNome(): string
{
return explode(' ', $this->valor)[0];
}
public function getIniciais(): string
{
$nomes = explode(' ', $this->valor);
return implode('', array_map(fn($nome) => strtoupper($nome[0]), $nomes));
}
}
Implementação de Value Objects: Boas Práticas
1. Sempre Imutáveis
final readonly class Coordenada
{
public function __construct(
public float $latitude,
public float $longitude
) {
if ($latitude < -90 || $latitude > 90) {
throw new InvalidArgumentException("Latitude inválida: deve estar entre -90 e 90");
}
if ($longitude < -180 || $longitude > 180) {
throw new InvalidArgumentException("Longitude inválida: deve estar entre -180 e 180");
}
}
// Operações retornam novas instâncias
public function mover(float $deltaLat, float $deltaLon): self
{
return new self($this->latitude + $deltaLat, $this->longitude + $deltaLon);
}
public function distanciaAte(Coordenada $outra): float
{
$deltaLat = deg2rad($outra->latitude - $this->latitude);
$deltaLon = deg2rad($outra->longitude - $this->longitude);
$a = sin($deltaLat / 2) * sin($deltaLat / 2) +
cos(deg2rad($this->latitude)) * cos(deg2rad($outra->latitude)) *
sin($deltaLon / 2) * sin($deltaLon / 2);
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
return 6371 * $c; // Raio da Terra em km
}
public function equals(Coordenada $outra): bool
{
return abs($this->latitude - $outra->latitude) < 0.0001
&& abs($this->longitude - $outra->longitude) < 0.0001;
}
}
2. Validação no Construtor
final readonly class Cpf
{
public function __construct(public string $numero)
{
$cpfLimpo = $this->limparCpf($numero);
if (!$this->isCpfValido($cpfLimpo)) {
throw new InvalidArgumentException("CPF inválido: {$numero}");
}
$this->numero = $cpfLimpo;
}
public function formatado(): string
{
return substr($this->numero, 0, 3) . '.' .
substr($this->numero, 3, 3) . '.' .
substr($this->numero, 6, 3) . '-' .
substr($this->numero, 9, 2);
}
public function equals(Cpf $outro): bool
{
return $this->numero === $outro->numero;
}
private function limparCpf(string $cpf): string
{
return preg_replace('/[^0-9]/', '', $cpf);
}
private function isCpfValido(string $cpf): bool
{
if (strlen($cpf) !== 11) {
return false;
}
// Verifica se todos os dígitos são iguais
if (preg_match('/^(\d)\1+$/', $cpf)) {
return false;
}
return $this->calcularDigitoVerificador($cpf);
}
private function calcularDigitoVerificador(string $cpf): bool
{
// Primeiro dígito verificador
$soma = 0;
for ($i = 0; $i < 9; $i++) {
$soma += intval($cpf[$i]) * (10 - $i);
}
$digito1 = $soma % 11 < 2 ? 0 : 11 - ($soma % 11);
// Segundo dígito verificador
$soma = 0;
for ($i = 0; $i < 10; $i++) {
$soma += intval($cpf[$i]) * (11 - $i);
}
$digito2 = $soma % 11 < 2 ? 0 : 11 - ($soma % 11);
return intval($cpf[9]) === $digito1 && intval($cpf[10]) === $digito2;
}
}
Entities: Gerenciando Identidade e Estado
Implementação de Identidade
class Produto
{
private int $estoque;
public function __construct(
private readonly ProdutoId $id,
private string $nome,
private Dinheiro $preco,
private CategoriaProduto $categoria
) {
if (empty(trim($nome))) {
throw new InvalidArgumentException("Nome é obrigatório");
}
$this->estoque = 0;
}
// Igualdade baseada apenas na identidade
public function equals(Produto $outro): bool
{
return $this->id->equals($outro->id);
}
// Métodos de negócio
public function ajustarPreco(Dinheiro $novoPreco): void
{
if ($novoPreco->valor < 0) {
throw new InvalidArgumentException("Preço não pode ser negativo");
}
$this->preco = $novoPreco;
}
public function reporEstoque(int $quantidade): void
{
if ($quantidade <= 0) {
throw new InvalidArgumentException("Quantidade deve ser positiva");
}
$this->estoque += $quantidade;
}
public function reduzirEstoque(int $quantidade): void
{
if (!$this->podeVender($quantidade)) {
throw new InvalidArgumentException("Estoque insuficiente");
}
$this->estoque -= $quantidade;
}
public function podeVender(int $quantidade): bool
{
return $this->estoque >= $quantidade;
}
public function isElegivelParaPromocao(): bool
{
return $this->preco->isMaiorQue(Dinheiro::reais(100));
}
public function aplicarDesconto(float $percentual): void
{
if ($percentual < 0 || $percentual > 1) {
throw new InvalidArgumentException("Percentual deve estar entre 0 e 1");
}
$novoValor = $this->preco->valor * (1 - $percentual);
$this->preco = new Dinheiro($novoValor, $this->preco->moeda);
}
public function getId(): ProdutoId
{
return $this->id;
}
public function getNome(): string
{
return $this->nome;
}
public function getPreco(): Dinheiro
{
return $this->preco;
}
public function getEstoque(): int
{
return $this->estoque;
}
}
Value Objects como Identificadores
final readonly class ProdutoId
{
public function __construct(public string $valor)
{
if (empty(trim($valor))) {
throw new InvalidArgumentException("ID do produto não pode ser vazio");
}
}
public static function gerar(): self
{
return new self("PROD-" . strtoupper(substr(uniqid(), -8)));
}
public static function de(string $valor): self
{
return new self($valor);
}
public function equals(ProdutoId $outro): bool
{
return $this->valor === $outro->valor;
}
public function __toString(): string
{
return $this->valor;
}
}
enum CategoriaProduto: string
{
case ELETRONICOS = 'eletronicos';
case ROUPAS = 'roupas';
case LIVROS = 'livros';
case CASA = 'casa';
case ESPORTES = 'esportes';
}
Erros Comuns e Como Evitá-los
1. Entities Anêmicas
Problema:
class Produto
{
private string $id;
private string $nome;
private float $preco;
// Apenas getters e setters
public function getId(): string { return $this->id; }
public function setId(string $id): void { $this->id = $id; }
public function getNome(): string { return $this->nome; }
public function setNome(string $nome): void { $this->nome = $nome; }
public function getPreco(): float { return $this->preco; }
public function setPreco(float $preco): void { $this->preco = $preco; }
}
Solução:
class Produto
{
public function __construct(
private readonly ProdutoId $id,
private string $nome,
private Dinheiro $preco
) {}
// Comportamentos do domínio
public function aplicarDesconto(float $percentual): void
{
if ($percentual < 0 || $percentual > 1) {
throw new InvalidArgumentException("Percentual inválido");
}
$novoValor = $this->preco->valor * (1 - $percentual);
$this->preco = new Dinheiro($novoValor, $this->preco->moeda);
}
public function isElegivelParaPromocao(): bool
{
return $this->preco->isMaiorQue(Dinheiro::reais(100));
}
public function podeSerVendido(): bool
{
return $this->preco->valor > 0;
}
}
2. Value Objects Mutáveis
Problema:
class Endereco
{
private string $rua;
private string $cep;
// Permite modificação - PROBLEMA!
public function setRua(string $rua): void {
$this->rua = $rua;
}
public function setCep(string $cep): void {
$this->cep = $cep;
}
}
Solução:
final readonly class Endereco
{
public function __construct(
public string $rua,
public string $cep,
public string $cidade,
public string $estado
) {
$this->validarCep($cep);
}
// Imutável - para mudar, crie uma nova instância
public function comNovaRua(string $novaRua): self
{
return new self($novaRua, $this->cep, $this->cidade, $this->estado);
}
public function comNovoCep(string $novoCep): self
{
return new self($this->rua, $novoCep, $this->cidade, $this->estado);
}
private function validarCep(string $cep): void
{
if (!preg_match('/^\d{5}-?\d{3}$/', $cep)) {
throw new InvalidArgumentException("CEP inválido: {$cep}");
}
}
}
3. Identificadores Primitivos
Problema:
function transferirProduto(string $produtoId, string $categoriaId): void
{
// Fácil trocar os parâmetros por engano
// Compilador não detecta o erro
}
// Chamada incorreta - erro silencioso
transferirProduto($categoriaId, $produtoId);
Solução:
function transferirProduto(ProdutoId $produtoId, CategoriaId $categoriaId): void
{
// Tipos específicos impedem erros
}
// Erro de compilação se trocar os parâmetros
transferirProduto($categoriaId, $produtoId); // TypeError!
Exemplo de Uso Completo
Para demonstrar como Entities e Value Objects trabalham juntos na prática, vamos criar um exemplo completo de um sistema de e-commerce:
// Value Objects já implementados anteriormente...
// Entity
class Cliente
{
public function __construct(
private readonly ClienteId $id,
private Nome $nome,
private Email $email,
private Endereco $endereco,
private readonly DateTimeImmutable $dataCadastro = new DateTimeImmutable()
) {}
public function atualizarEmail(Email $novoEmail): void
{
if ($novoEmail->equals($this->email)) {
return; // Não há mudança
}
$this->email = $novoEmail;
// Poderia disparar um evento de domínio aqui
}
public function mudarEndereco(Endereco $novoEndereco): void
{
$this->endereco = $novoEndereco;
}
public function atualizarNome(Nome $novoNome): void
{
$this->nome = $novoNome;
}
public function getIdadeEmDias(): int
{
return $this->dataCadastro->diff(new DateTimeImmutable())->days;
}
public function isClienteAntigo(): bool
{
return $this->getIdadeEmDias() > 365;
}
// Identidade baseada no ID
public function equals(Cliente $outro): bool
{
return $this->id->equals($outro->id);
}
// Getters
public function getId(): ClienteId { return $this->id; }
public function getNome(): Nome { return $this->nome; }
public function getEmail(): Email { return $this->email; }
public function getEndereco(): Endereco { return $this->endereco; }
public function getDataCadastro(): DateTimeImmutable { return $this->dataCadastro; }
}
final readonly class ClienteId
{
public function __construct(public string $valor)
{
if (empty(trim($valor))) {
throw new InvalidArgumentException("ID do cliente não pode ser vazio");
}
}
public static function gerar(): self
{
return new self("CLI-" . strtoupper(substr(uniqid(), -8)));
}
public function equals(ClienteId $outro): bool
{
return $this->valor === $outro->valor;
}
}
// Uso
class ClienteService
{
public function exemploUso(): void
{
// Criando Value Objects
$id = ClienteId::gerar();
$nome = new Nome("João Silva");
$email = new Email("joao@email.com");
$endereco = new Endereco("Rua A", "123", "12345-678", "São Paulo", "SP");
// Criando Entity
$cliente = new Cliente($id, $nome, $email, $endereco);
// Operações mantêm identidade
$cliente->atualizarEmail(new Email("joao.silva@email.com"));
$cliente->mudarEndereco($endereco->comNovaRua("Rua B"));
// Cliente continua sendo o mesmo (mesma identidade)
assert($cliente->getId()->equals($id));
// Demonstrando comportamentos
echo "Cliente antigo: " . ($cliente->isClienteAntigo() ? "Sim" : "Não") . "\n";
echo "Iniciais: " . $cliente->getNome()->getIniciais() . "\n";
echo "Domínio do email: " . $cliente->getEmail()->getDominio() . "\n";
}
}
Este exemplo demonstra como:
- Value Objects (
Nome
,Email
,Endereco
) encapsulam validações e comportamentos específicos - Entity (
Cliente
) mantém identidade através do tempo e coordena os Value Objects - Identidade única permite rastrear o cliente mesmo quando seus dados mudam
- Comportamentos de domínio são implementados onde fazem mais sentido
Conclusão
Entities e Value Objects são os blocos fundamentais de qualquer modelo de domínio rico em DDD. A distinção entre eles - baseada na questão da identidade - é crucial para criar um modelo que reflita verdadeiramente as regras e conceitos do negócio.
Entities representam conceitos que têm identidade e ciclo de vida, devendo ser rastreados ao longo do tempo. Elas encapsulam comportamentos do domínio e mantêm sua identidade mesmo quando seus atributos mudam.
Value Objects representam conceitos descritivos sem identidade, sendo definidos pelos seus atributos. São imutáveis, combatém a obsessão por primitivos e tornam o código mais expressivo e seguro.
A combinação inteligente de Entities e Value Objects resulta em um modelo de domínio mais expressivo, com regras de negócio bem encapsuladas e código mais limpo e testável. Lembre-se: o contexto sempre importa na decisão entre Entity e Value Object.
No próximo artigo da série, exploraremos Agregados e Aggregate Roots, aprendendo como agrupar Entities e Value Objects relacionados para manter a consistência e integridade do modelo de domínio.
Referências
- Evans, Eric. "Domain-Driven Design: Tackling Complexity in the Heart of Software". Addison-Wesley, 2003.
- Fowler, Martin. "Value Objects vs Entities". martinfowler.com
- Jovanovic, Milan. "Value Objects in .NET (DDD Fundamentals)". milanjovanovic.tech, 2023.
- Wempe, Jannik. "Domain-Driven Design: Distinguish Entities, Value Objects". blog.jannikwempe.com, 2021.
- Pfeffer, Zohar. "Why you desperately need Value Objects in your life". dev.to, 2022.
- Stemmler, Khalil. "Value Objects - DDD w/ TypeScript". khalilstemmler.com, 2019.
- Vernon, Vaughn. "Implementing Domain-Driven Design". Addison-Wesley, 2013.
- Khoriakov, Vladimir. "Value Object: a better implementation". enterprisecraftsmanship.com, 2017.
- Young, Greg. "CQRS Documents". goodlydocs.io
- Nilsson, Jimmy. "Applying Domain-Driven Design and Patterns". Addison-Wesley, 2006.