Value Objects no .NET - Modelagem Rica e Persistência no EF Core
   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.

Em muitas aplicações corporativas, temos o hábito de representar conceitos de  domínio utilizando apenas tipos primitivos como decimal, string
ou Guid.

Para operações básicas de CRUD, isso até funciona. Porém, à medida que a  complexidade de negócio aumenta, o modelo torna-se progressivamente difícil de  compreender e manter: um preço não é apenas um número solto e  um endereço não é apenas um punhado de quatro strings espalhadas pelas  propriedades de uma entidade.

Sem limites arquiteturais bem definidos, as regras de negócio acabam dispersas  em múltiplas camadas de serviços, e bugs sutis surgem quando o mesmo conceito é  tratado de formas divergentes pelo sistema.

Uma das formas mais diretas e eficazes de tornar o domínio expressivo e seguro é  através da introdução de Value Objects (Objetos de Valor).

Neste artigo, vamos cobrir:

• O que são Value Objects no DDD
• A abordagem clássica baseada em classes
• A abordagem moderna com records em C#
• A abordagem tradicional de persistência com Owned Types (OwnsOne)
• A solução recomendada: Complex Types no EF Core

Os exemplos usam C# 14, .NET 10 e EF Core 10, com tipos de referência anuláveis (nullable) habilitados.

Vamos lá.

 

O que são Value Objects?

Os Value Objects são um dos blocos de construção fundamentais do  Domain-Driven Design (DDD). Eles representam uma ideia conceitual única do seu  domínio — como Money (Dinheiro) ou Address (Endereço).

Diferentemente das entidades, os objetos de valor não possuem identidade.  Duas instâncias que contêm exatamente os mesmos valores são consideradas  iguais.

Na prática, um Value Object obedece às seguintes regras:

• Sem identidade: a igualdade é  baseada exclusivamente nos atributos, nunca em um Id.
• Imutabilidade: você nunca altera o  estado interno do objeto; substitui a instância inteira.
• Autocontido: valida suas próprias regras e invariantes de negócio, de modo que uma instância inválida nunca chega a existir.
• Livre de efeitos colaterais:  operações não modificam o objeto atual; retornam sempre uma nova instância.

Compare isso a uma entidade como Pedido: dois pedidos com o  mesmo valor total e o mesmo endereço continuam sendo pedidos distintos, pois  cada um possui seu próprio identificador único (Id).

Enums como 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.

Vamos começar pela abordagem clássica baseada em uma classe abstrata, usando o exemplo clássico de Money, que aqui vou traduzir para Dinheiro.

Abordagem clássica: Value Objects baseados em classes

No C#, tipos por referência (classes) são comparados por referência de memória  por padrão. Se você instanciar dois objetos 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.

Por esse motivo, bases de código orientadas a DDD costumam adotar uma classe  base reutilizável para centralizar a comparação:

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());
}

Observe que a verificação de tipo (
GetType()) 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.

Cada classe concreta informa à classe base quais propriedades participam do  cálculo de igualdade implementando o método GetAtomicValues.

Veja a implementação de Dinheiro herdando  dessa abstração. A moeda é definida pela seguinte enumeração:

public enum Moeda
{
    BRL,
    USD,
    EUR
}

A seguir temos a implementação de Dinheiro:

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;
    }
}

O construtor garante a invariante (valor não negativo), de modo que nenhuma instância inválida de Dinheiro chega a existir. O método 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.

Essa estratégia é explícita e funciona universalmente, inclusive em versões  legadas do .NET. O preço a pagar é a quantidade de código repetitivo (boilerplate)  para sustentar a igualdade estrutural.

Abordagem moderna: records como Value Objects

O C# oferece um mecanismo muito mais enxuto: os 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.

Para a grande maioria dos casos, a classe base torna-se desnecessária. Veja a  mesma regra modelada com um
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);
    }
}

Declarar as propriedades explicitamente, inicializadas a partir dos parâmetros do construtor primário, permite validar a invariante no momento da criação. Como elas têm apenas 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.

A comparação  funciona exatamente como o esperado:

var primeiro = new DinheiroRecord(100, Moeda.EUR);
var segundo = new DinheiroRecord(100, Moeda.EUR);

bool saoIguais = primeiro == segundo; // true

Dois cuidados ao usar records como Value Objects:

• Expressões with: em um record posicional sem propriedades explícitas, as propriedades geradas são 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 em structs: 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.


Em novos projetos, 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.

Mapeamento e persistência: o dilema do EF  Core

Modelar os Value Objects é apenas uma etapa do processo; é indispensável saber como  persistir essas estruturas no banco relacional.

Além de Dinheiro, vamos usar um segundo Value Object composto, o endereço de entrega:

public record Endereco(
    string Rua,
    string Cidade,
    string Pais,
    string Cep);

Em vez de desmembrar propriedades como Valor e Moeda  diretamente na entidade Pedido, mantemos os tipos encapsulados. O estado da entidade só muda por meio dos seus próprios métodos:

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);
}

A abordagem tradicional: Owned Types

Durante muito tempo, o mecanismo padrão no Entity Framework Core para viabilizar  esse mapeamento foi o uso de Owned Types via 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();

Embora funcional — os dados ficam em colunas da mesma tabela —, o recurso  de tipos pertencentes (owned types) nunca foi o ideal para representar  Value Objects.

Internamente, o EF Core trata um owned type como uma entidade, com uma chave oculta. Isso  frequentemente gerava conflitos na nomenclatura de colunas, exigia configurações  artificiais de navegação e causava comportamentos em consultas LINQ  que destoavam da modelagem do domínio.

Em resumo: Owned Types resolveram o mapeamento relacional, mas não a  modelagem conceitual. Eles continuam suportados pelo EF Core, mas deixaram de ser a melhor escolha para Value Objects.

A solução recomendada: Complex Types no EF  Core

O EF Core 8 introduziu os Complex Types  (tipos complexos), projetados especificamente para representar Value Objects. Eles não são entidades, não possuem  chaves e não requerem tabelas próprias. A própria documentação do EF Core recomenda que quem usa owned types para Value Objects considere migrar para complex types.

O mapeamento é feito pelo método fluente 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();
});

O EF Core materializa 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.).

O que o EF Core 10 acrescentou

No EF Core 8, uma propriedade complexa era sempre obrigatória e precisava ser um tipo por referência. O EF Core 10 ampliou bastante o recurso:

• Tipos complexos opcionais: uma propriedade complexa pode ser declarada como anulável (por exemplo,
Endereco? EnderecoCobranca).

• Structs: tipos de valor (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.


• Coleções: uma propriedade pode conter uma coleção de complex types.


• Mapeamento para JSON: complex types tornaram-se o mecanismo principal para mapear Value Objects em colunas JSON, o que antes exigia owned types com ToJson(). No SQL Server 2025 e no Azure SQL, o EF Core 10 usa o tipo de dados json nativo.


• ExecuteUpdate em JSON: atualizações em massa dentro de documentos JSON exigem que os tipos estejam mapeados como complex types; não funcionam com owned types.


Para armazenar o endereço em uma única coluna JSON em vez de colunas separadas, basta:
builder.ComplexProperty(pedido => pedido.EnderecoEntrega, endereco => endereco.ToJson());

Vale registrar uma limitação: o EF Core ainda não suporta herança de complex types. Se o seu modelo depende de hierarquias de Value Objects, avalie esse ponto antes de migrar.

Se você busca construir um modelo de domínio rico, os Value Objects  fornecem a expressividade na modelagem de negócios, enquanto os Complex  Types entregam o suporte adequado à persistência.

Resumo

• 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.

Conclusão

O princípio essencial do DDD é direto: tipos primitivos apenas transportam  dados; Value Objects transportam significado de negócio.

Comece modelando pequenos conceitos que hoje estão soltos nas suas entidades —  como CPF, e-mails, endereços e valores monetários — e experimente a redução  imediata de bugs de validação no seu ecossistema .NET.

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:


José  Carlos Macoratti