|
Aprenda como aplicar o conceito de Value Objects (Objetos de Valor) do DDD em C#, comparando a abordagem clássica com classes, o uso moderno de records e o mapeamento eficiente no Entity Framework Core com Complex Types. |
decimal,
string ou
Guid.

Money (Dinheiro) ou
Address (Endereço).StatusPedido são úteis, mas não são Value Objects. Um
Value Object é um tipo dotado de estrutura, propriedades e regras de negócio, e
não uma simples constante nomeada.Dinheiro com o mesmo valor
e a mesma moeda, a operação == retornará
false,
a menos que a lógica de igualdade seja explicitamente sobrescrita.
public abstract class ValueObject : IEquatable<ValueObject>
{
public static bool operator ==(ValueObject? first, ValueObject? second)
{
if (first is null && second is null)
{
return true;
}
if (first is null || second is null)
{
return false;
}
return first.Equals(second);
}
public static bool operator !=(ValueObject? first, ValueObject? second)
=> !(first == second);
public bool Equals(ValueObject? other) =>
other is not null &&
GetType() == other.GetType() &&
ValuesEqual(other);
public override bool Equals(object? obj) => obj is ValueObject other && Equals(other);
public override int GetHashCode() =>
GetAtomicValues()
.Aggregate(default(int), (hashcode, value) =>
HashCode.Combine(hashcode, value?.GetHashCode() ?? 0));
protected abstract IEnumerable<object?> GetAtomicValues();
private bool ValuesEqual(ValueObject other) =>
GetAtomicValues().SequenceEqual(other.GetAtomicValues());
}
|
()) fica em
Equals(ValueObject?), que é o método chamado pelo operador ==. Assim, dois Value Objects de tipos diferentes nunca são considerados iguais, mesmo que seus valores coincidam — um
Cep e um
Telefone com os mesmos dígitos, por exemplo. O
GetHashCode também trata valores nulos sem lançar exceção.GetAtomicValues.
public enum Moeda
{
BRL,
USD,
EUR
}
|
public sealed class Dinheiro : ValueObject
{
public decimal Valor { get; }
public Moeda Moeda { get; }
public Dinheiro(decimal valor, Moeda moeda)
{
if (valor < 0)
{
throw new ArgumentOutOfRangeException(nameof(valor),
"O valor não pode ser negativo.");
}
Valor = valor;
Moeda = moeda;
}
public Dinheiro Somar(Dinheiro outro)
{
if (Moeda != outro.Moeda)
{
throw new InvalidOperationException("Não é possível somar valores
com moedas diferentes.");
}
return new Dinheiro(Valor + outro.Valor, Moeda);
}
protected override IEnumerable<object?> GetAtomicValues()
{
yield return Valor;
yield return Moeda;
}
}
|
Somar não altera a instância atual: ele valida a
regra (moedas idênticas) e devolve uma nova instância de Dinheiro.
Isso preserva a imutabilidade e simplifica o raciocínio sobre o fluxo de dados.
records,
introduzidos no C# 9 (record class) e ampliados no C# 10 (record struct). Eles fornecem igualdade baseada em valores, apoio à imutabilidade e sintaxe concisa
de forma nativa.readonly record struct:
public readonly record struct DinheiroRecord(decimal Valor, Moeda Moeda)
{
public decimal Valor { get; } = Valor >= 0
? Valor
: throw new ArgumentOutOfRangeException(nameof(Valor), "O valor não pode ser negativo.");
public Moeda Moeda { get; } = Moeda;
public DinheiroRecord Somar(DinheiroRecord outro)
{
if (Moeda != outro.Moeda)
{
throw new InvalidOperationException("Não é possível somar valores com moedas diferentes.");
}
return new DinheiroRecord(Valor + outro.Valor, Moeda);
}
}
|
get, também ficam bloqueadas para expressões
with: um
dinheiro with { Valor = -10 } não compila e, portanto, não consegue contornar a validação.
var primeiro = new DinheiroRecord(100, Moeda.EUR);
var segundo = new DinheiroRecord(100, Moeda.EUR);
bool saoIguais = primeiro == segundo; // true
|
init, e
with cria uma cópia alterada sem executar nenhuma validação do construtor. Para Value Objects com invariantes, declare as propriedades explicitamente, como no exemplo acima.
default(DinheiroRecord) produz uma instância que nunca passou pelo construtor (valor 0 e a primeira moeda da enumeração). Se um estado "zerado" for inválido para o seu domínio, prefira um record class.records costumam ser a primeira escolha. Observe que
record class também suporta herança entre records; apenas
record struct não suporta. A abordagem clássica com a classe base
ValueObject continua fazendo sentido quando você precisa de controle fino sobre a igualdade ou está
atuando em soluções legadas que já padronizaram essa classe.Dinheiro, vamos usar um segundo Value Object composto, o endereço de entrega:
public record Endereco(
string Rua,
string Cidade,
string Pais,
string Cep);
|
public class Pedido
{
public Guid Id { get; private set; }
public Dinheiro ValorTotal { get; private set; } = null!;
public Endereco EnderecoEntrega { get; private set; } = null!;
// Construtor sem parâmetros usado pelo EF Core na materialização
private Pedido() { }
private Pedido(Guid id, Endereco enderecoEntrega, Dinheiro valorTotal)
{
Id = id;
EnderecoEntrega = enderecoEntrega;
ValorTotal = valorTotal;
}
public static Pedido Create(Endereco enderecoEntrega, Dinheiro valorTotal) =>
new(Guid.NewGuid(), enderecoEntrega, valorTotal);
public void AdicionaTotal(Dinheiro valor) =>
ValorTotal = ValorTotal.Somar(valor);
}
|
OwnsOne:
builder.OwnsOne(pedido => pedido.ValorTotal, dinheiroBuilder =>
{
dinheiroBuilder.Property(dinheiro => dinheiro.Valor)
.HasPrecision(18, 2)
.HasColumnName("ValorTotal_Valor")
.IsRequired();
dinheiroBuilder.Property(dinheiro => dinheiro.Moeda)
.HasConversion<string>()
.HasMaxLength(3)
.HasColumnName("ValorTotal_Moeda")
.IsRequired();
});
builder.Navigation(pedido => pedido.ValorTotal).IsRequired();
|
ComplexProperty. Com ele, mapeamos os dois Value Objects de
Pedido — o
Dinheiro e o
Endereco — sem configurações de navegação nem semântica artificial de entidade:
builder.ComplexProperty(pedido => pedido.ValorTotal, dinheiro =>
{
dinheiro.Property(d => d.Valor)
.HasPrecision(18, 2)
.HasColumnName("ValorTotal_Valor");
dinheiro.Property(d => d.Moeda)
.HasConversion<string>()
.HasMaxLength(3)
.HasColumnName("ValorTotal_Moeda");
});
builder.ComplexProperty(pedido => pedido.EnderecoEntrega, endereco =>
{
endereco.Property(e => e.Rua).HasMaxLength(200).IsRequired();
endereco.Property(e => e.Cidade).HasMaxLength(100).IsRequired();
endereco.Property(e => e.Pais).HasMaxLength(100).IsRequired();
endereco.Property(e => e.Cep).HasMaxLength(20).IsRequired();
});
|
Dinheiro pelo seu construtor, associando os parâmetros
valor e
moeda às propriedades de mesmo nome. Por padrão, as colunas recebem o nome da propriedade complexa como prefixo
(EnderecoEntrega_Rua,
EnderecoEntrega_Cidade etc.).Endereco? EnderecoCobranca).struct e record struct) passaram a ser aceitos como complex types. Ou seja, o
DinheiroRecord mostrado anteriormente só pode ser mapeado com ComplexProperty a partir do EF Core 10.ToJson(). No SQL Server 2025 e no Azure SQL, o EF Core 10 usa o tipo de dados json nativo.
builder.ComplexProperty(pedido => pedido.EnderecoEntrega, endereco => endereco.ToJson());
|
|
• Expressividade de domínio:
Value Objects agrupam dados relacionados, garantem as invariantes de
negócio junto ao conceito e realizam comparação estritamente estrutural. • Records em C#: Utilize
record ou
readonly record struct para reduzir
boilerplate mantendo imutabilidade e igualdade por valor. Declare as propriedades explicitamente quando houver invariantes a validar.• Classe base ValueObject: recorra a ela quando precisar de controle fino sobre a igualdade ou de compatibilidade com bases legadas. • Persistência moderna: adote ComplexProperty no EF Core para novos mapeamentos e
migre mapeamentos com OwnsOne sempre que possível, observando a falta de suporte a herança em complex types.
|
E estamos conversados..![]()
"Não estejais inquietos por coisa alguma; antes as
vossas petições sejam em tudo conhecidas diante de Deus pela oração e súplica,
com ação de graças."
Filipenses 4:6
Referências: