Como criar bons Value Objects em C#
Value objects são um dos conceitos mais fundamentais do Domain-Driven Design (DDD) e, quando bem implementados, elevam a qualidade, a segurança e a expressividade de um sistema. No C#, os records tornaram essa implementação bem mais simples do que era há alguns anos.
Este artigo apresenta os princípios que definem um bom value object, as decisões de design que o tornam robusto e exemplos práticos em C#. Além disso, ele mostra como persistir esses tipos com o Entity Framework Core e como testá-los.
O que são value objects
No contexto do DDD, value objects são objetos que representam um conceito do domínio pelo seu valor, e não por uma identidade. Como lembra Eric Evans, muitos objetos não têm identidade conceitual e apenas descrevem características de algo.
Portanto, dois value objects são iguais quando todos os seus atributos são iguais, mesmo que sejam instâncias distintas em memória. Em outras palavras, importa o que eles são, e não quem eles são.
Um endereço de e-mail, um título de conteúdo, um valor monetário e um código de convite são exemplos típicos. Nenhum deles tem identidade própria, porque o que importa é o valor que carregam.
A principal diferença em relação às entidades está justamente aqui. Entidades possuem identidade, geralmente um Id, enquanto value objects possuem apenas valor.
Por que usar value objects em vez de primitivos
A alternativa mais comum aos value objects é representar esses conceitos com tipos primitivos, como string, decimal ou int. Essa abordagem, conhecida como Primitive Obsession, traz problemas sérios:
- Não há garantia de que o valor é válido no momento em que é utilizado.
- A validação fica espalhada pela aplicação e, muitas vezes, duplicada.
- O compilador não distingue uma
stringque representa um título de outra que representa um slug. - Os comportamentos ligados ao conceito ficam sem um lugar natural para morar.
Os value objects resolvem todos esses problemas de uma vez. Afinal, eles encapsulam o valor, a validação e os comportamentos relacionados em um único tipo coeso.
Na prática, isso transforma regras de negócio em garantias do sistema de tipos. Se um método recebe um Email, ele não precisa mais conferir se aquele texto é um e-mail válido.
Princípios de bons value objects
Criar bons value objects vai além de embrulhar um primitivo em uma classe. A seguir, você vê os princípios que, aplicados juntos, garantem tipos seguros e expressivos.
Imutabilidade
Um value object deve ser imutável depois da criação, ou seja, ninguém pode alterar o seu estado externamente. Em C#, um record com propriedades somente leitura é a forma mais idiomática de garantir isso.
public sealed record Title
{
public string Value { get; }
private Title(string value) => Value = value;
}
Aqui, apenas o construtor privado define a propriedade Value. Com isso, a instância fica imutável por design.
Um detalhe importante é que a propriedade não tem init. Assim, nem mesmo uma expressão with consegue criar uma cópia com outro valor sem passar pela validação.
Construtor privado e fábrica estática
O construtor deve ser privado para que ninguém crie uma instância inválida diretamente. Por isso, a criação passa obrigatoriamente por um método de fábrica estático, que aplica todas as regras antes de instanciar o objeto.
public static Title Create(string? value)
{
if (string.IsNullOrWhiteSpace(value))
throw new NullOrEmptyException(nameof(value));
var normalized = value.NormalizeTitle();
return normalized.Length switch
{
< MinLength or > MaxLength => throw new InvalidLengthException(MinLength, MaxLength),
_ => new Title(normalized)
};
}
Esse padrão garante que, se uma instância de Title existe, ela é válida. Dessa forma, nenhum outro ponto do sistema precisa validar o valor de novo.
Validação completa na criação
Toda a validação deve acontecer no momento da criação. Isso inclui nulidade, comprimento, formato e qualquer outra regra de negócio aplicável ao conceito.
public static Name Create(string? value)
{
if (string.IsNullOrWhiteSpace(value))
throw new NullOrEmptyException(nameof(value));
var normalized = value.NormalizeTitle();
if (normalized.Length is < MinLength or > MaxLength)
throw new InvalidLengthException(MinLength, MaxLength);
if (!RegularExpression.NameRegex().IsMatch(normalized))
throw new InvalidNameException(normalized);
var parts = normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return new Name(parts[0], parts[^1], parts.Length > 2
? string.Join(' ', parts[1..^1])
: null);
}
Repare que o tipo Name também decompõe o valor em primeiro nome, sobrenome e nomes do meio. Assim, o value object não só valida, mas também estrutura a informação para o restante do domínio.
Exceções específicas
Exceções genéricas como ArgumentException ou Exception não comunicam a intenção do domínio. Por isso, cada violação de regra deve ter a sua própria exceção, com uma mensagem clara e localizada.
public class NullOrEmptyException()
: Exception(ExceptionMessages.NullOrEmpty);
public class InvalidLengthException(int min, int max)
: Exception(string.Format(ExceptionMessages.InvalidLength, min, max));
public class InvalidNameException(string value)
: Exception(string.Format(ExceptionMessages.InvalidName, value));
Além de melhorar a legibilidade, exceções específicas permitem que o código consumidor trate cada caso com precisão. Se você prefere evitar exceções no fluxo de negócio, o artigo sobre exceptions e domain notifications discute as alternativas.
Mensagens de erro localizadas
Em sistemas com vários idiomas, as mensagens de erro devem vir de arquivos de recurso .resx. Nesse caso, o .NET escolhe o texto conforme a cultura definida em Thread.CurrentThread.CurrentUICulture.
<!-- ExceptionMessages.resx (en) -->
<data name="NullOrEmpty">
<value>This field should not be null or empty.</value>
</data>
<!-- ExceptionMessages.pt-BR.resx -->
<data name="NullOrEmpty">
<value>Este campo não pode ser nulo ou vazio.</value>
</data>
Dessa forma, a mesma exceção exibe a mensagem correta para cada usuário, sem nenhuma alteração no código. O artigo sobre globalization e localization no .NET aprofunda esse assunto.
Normalização antes da validação
Dados digitados por usuários costumam trazer espaços extras, quebras de linha ou tabulações. Portanto, a normalização deve acontecer antes da validação de comprimento e formato, para evitar falsos negativos.
public static class StringExtension
{
private static readonly Regex TabsAndLineBreaks = RegularExpression.TabsAndLineBreaksRegex();
private static readonly Regex MultipleSpaces = RegularExpression.MultipleSpacesRegex();
public static string NormalizeTitle(this string value)
{
var result = value.Trim();
result = TabsAndLineBreaks.Replace(result, string.Empty);
result = MultipleSpaces.Replace(result, " ");
return result;
}
}
Centralizar essa lógica em um método de extensão garante consistência entre todos os tipos que precisam do mesmo comportamento.
Uso de records
Em C#, o record é a escolha natural para modelar esses tipos. Além da sintaxe concisa, o compilador gera automaticamente Equals, GetHashCode, os operadores == e != e um ToString legível.
// Com record, a igualdade estrutural é automática
var a = Title.Create("Clean Architecture");
var b = Title.Create("Clean Architecture");
Console.WriteLine(a == b); // true
Com isso, você não precisa implementar IEquatable<T> manualmente, como aconteceria com uma class comum. A documentação da Microsoft, aliás, recomenda records sempre que dois objetos com os mesmos dados devem ser considerados iguais.
Por outro lado, a mesma documentação desaconselha records para entidades do Entity Framework Core, porque o rastreamento delas depende de igualdade por referência. Ou seja, record combina com value object, e não com entidade.
Igualdade por valor entre value objects
A igualdade entre value objects deve se basear nos valores, e não nas referências de memória. Com record, o compilador garante isso automaticamente.
Ainda assim, a definição de igualdade pode variar conforme o domínio. Em Title, por exemplo, a comparação pode diferenciar maiúsculas de minúsculas, enquanto em InvitationCode isso não faz sentido, pois o código sempre vira maiúsculo na criação.
Há também uma armadilha conhecida. Se o record tiver uma propriedade do tipo List<T> ou array, a igualdade gerada compara a coleção por referência, e não pelo conteúdo.
Conversões implícitas em value objects
Para facilitar a integração com mapeamentos e interfaces, vale oferecer em value objects uma conversão implícita para string e uma implementação de ToString.
public static implicit operator string(Title title) => title.Value;
public override string ToString() => Value;
Por outro lado, a conversão implícita de string para o value object deve ser evitada ou declarada como explícita. Afinal, ela pode lançar exceções em pontos onde quem chama não espera.
// Seguro: sempre funciona
string value = title;
// Perigoso: pode lançar exceção de forma inesperada
Title title = someString; // evite implicit nesta direção
Se você quer revisar como esses operadores funcionam, o artigo sobre implicit operators explica o recurso em detalhes.
Exemplos avançados de value objects
Os princípios anteriores cobrem os casos mais simples. Agora, veja exemplos de value objects com várias propriedades, derivação de valor, geração automática e rastreamento de tempo.
Money com múltiplas propriedades
Nem todo value object possui uma única propriedade. O Money, por exemplo, encapsula Amount e Currency e inclui operações aritméticas que nunca misturam moedas diferentes.
public sealed record Money
{
public decimal Amount { get; }
public CurrencyCode Currency { get; }
private Money(decimal amount, CurrencyCode currency)
{
Amount = amount;
Currency = currency;
}
public static Money Create(decimal amount, CurrencyCode currency)
=> new(amount, currency);
public static Money Zero(CurrencyCode currency)
=> new(0, currency);
public Money Add(Money other)
{
EnsureSameCurrency(other);
return new Money(Amount + other.Amount, Currency);
}
public Money Subtract(Money other)
{
EnsureSameCurrency(other);
return new Money(Amount - other.Amount, Currency);
}
public Money Multiply(decimal factor)
=> new(Amount * factor, Currency);
public static Money operator +(Money left, Money right) => left.Add(right);
public static Money operator -(Money left, Money right) => left.Subtract(right);
public static Money operator *(Money money, decimal factor) => money.Multiply(factor);
public static Money operator *(decimal factor, Money money) => money.Multiply(factor);
private void EnsureSameCurrency(Money other)
{
if (Currency != other.Currency)
throw new CurrencyMismatchException(Currency, other.Currency);
}
public override string ToString()
=> $"{Amount:F2} {Currency.ToString().ToUpperInvariant()}";
}
Repare que toda operação devolve uma nova instância, o que preserva a imutabilidade. Além disso, o método Zero ajuda a inicializar agregados sem valores mágicos:
order.Total = Money.Zero(CurrencyCode.Brl);
Slug com derivação de valor
Alguns tipos derivam o valor de uma transformação sobre a entrada, em vez de apenas validá-la. O Slug é um bom exemplo, porque o valor armazenado é sempre normalizado, independentemente do que o usuário enviar.
public static Slug Create(string? value)
{
if (string.IsNullOrWhiteSpace(value))
throw new NullOrEmptyException(nameof(value));
var slug = value.Slugify();
return slug.Length switch
{
< MinLength or > MaxLength => throw new InvalidLengthException(MinLength, MaxLength),
_ => new Slug(slug)
};
}
O método Slugify aplica a transformação completa:
public static string Slugify(this string value)
{
var lower = value.NormalizeTitle().ToLowerInvariant();
var noSpecial = RegularExpression.NonSlugCharactersRegex().Replace(lower, string.Empty);
return RegularExpression.WhiteSpaceRegex().Replace(noSpecial, "-").Trim('-');
}
Assim, Slug.Create("Clean Architecture in .NET") sempre produz "clean-architecture-in-net", não importa como a entrada chegou.
InvitationCode com geração automática
Quando o sistema gera o valor, em vez de recebê-lo do usuário, o tipo pode expor um método Generate como fábrica alternativa.
public static InvitationCode Generate()
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
var code = new string(Enumerable
.Range(0, Length)
.Select(_ => chars[Random.Shared.Next(chars.Length)])
.ToArray());
return new InvitationCode(code);
}
O Random.Shared é thread-safe desde o .NET 6, então dispensa instâncias estáticas ou sincronização manual. No entanto, ele não é criptograficamente seguro, e códigos que funcionam como segredo pedem RandomNumberGenerator.
Tracker com rastreamento de tempo
Esses tipos também podem encapsular comportamentos de infraestrutura, como as datas de criação e de atualização de uma entidade.
public sealed record Tracker
{
public DateTime CreatedAtUtc { get; }
public DateTime UpdatedAtUtc { get; }
private Tracker(DateTime createdAtUtc, DateTime updatedAtUtc)
{
CreatedAtUtc = createdAtUtc;
UpdatedAtUtc = updatedAtUtc;
}
public static Tracker Create(IDateTimeProvider? provider = null)
{
var now = (provider ?? new DateTimeProvider()).UtcNow;
return new(now, now);
}
public static Tracker Updated(Tracker tracker, IDateTimeProvider? provider = null)
{
var now = (provider ?? new DateTimeProvider()).UtcNow;
return new(tracker.CreatedAtUtc, now);
}
}
A injeção de IDateTimeProvider permite que os testes controlem o relógio de forma determinística:
public class FakeDateTimeProvider(DateTime utcNow) : IDateTimeProvider
{
public DateTime UtcNow => utcNow;
}
var provider = new FakeDateTimeProvider(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
var tracker = Tracker.Create(provider);
Assert.That(tracker.CreatedAtUtc, Is.EqualTo(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)));
O artigo sobre como testar data e hora no .NET explora essa técnica com mais exemplos.
Expressões regulares centralizadas
Esses tipos dependem com frequência de expressões regulares para validar e transformar dados. Em vez de instanciar Regex dentro de cada classe, centralize todas as expressões em uma classe estática parcial com o source generator [GeneratedRegex], disponível desde o .NET 7.
public static partial class RegularExpression
{
[GeneratedRegex(@"[\t\r\n]")]
public static partial Regex TabsAndLineBreaksRegex();
[GeneratedRegex(@" {2,}")]
public static partial Regex MultipleSpacesRegex();
[GeneratedRegex(@"[^a-z0-9\s]")]
public static partial Regex NonSlugCharactersRegex();
[GeneratedRegex(@"\s+")]
public static partial Regex WhiteSpaceRegex();
[GeneratedRegex(@"^(?:\p{L}[\p{L}-]+)(?:\s+(?:\p{L}[\p{L}-]+))+$")]
public static partial Regex NameRegex();
[GeneratedRegex(@"^[A-Z0-9]{8}$")]
public static partial Regex InvitationCodeRegex();
}
O atributo [GeneratedRegex] faz o compilador gerar o código da expressão em tempo de compilação. Dessa forma, você elimina o custo de inicialização em runtime e mantém o código compatível com Native AOT.
Esse padrão aparece em detalhes no artigo sobre como organizar e reutilizar expressões regulares em C#.
Persistindo value objects com Entity Framework Core
Em algum momento, value objects precisam ir para o banco de dados. O Entity Framework Core oferece três caminhos, e a escolha depende de quantas propriedades o tipo possui.
Value converter para um único valor
Para tipos com uma só propriedade, como Title, o caminho mais simples é um value converter. Ele converte o objeto para o primitivo ao gravar e reconstrói a instância ao ler.
builder.Property(x => x.Title)
.HasConversion(
t => t.Value,
v => Title.Create(v))
.HasMaxLength(Title.MaxLength);
Repare que a leitura usa a própria fábrica Create. Assim, até os dados que vêm do banco passam pela mesma validação.
Owned entities e complex types para value objects compostos
Para value objects com várias propriedades, como Money, a abordagem clássica é a owned entity. Ela mapeia cada propriedade em uma coluna distinta da tabela do dono:
builder.OwnsOne(x => x.Price, money =>
{
money.Property(m => m.Amount).HasColumnName("price_amount");
money.Property(m => m.Currency).HasColumnName("price_currency");
});
No entanto, owned entities continuam sendo entidades por baixo dos panos, com uma chave oculta e semântica de referência. Por isso, a partir do EF Core 8, a Microsoft recomenda os complex types, que comparam por valor e não têm identidade.
builder.ComplexProperty(x => x.Price, money =>
{
money.Property(m => m.Amount).HasColumnName("price_amount");
money.Property(m => m.Currency).HasColumnName("price_currency");
});
Com complex types, você pode atribuir a mesma instância a duas propriedades sem erro, algo que falha com owned entities. Para se aprofundar nas opções de mapeamento, veja o artigo sobre value objects no EF Core.
Testes de unidade para value objects
Value objects são ideais para testes de unidade, porque não têm dependências externas, são determinísticos e têm comportamento bem definido. A estrutura abaixo, com NUnit, agrupa os testes por responsabilidade.
[TestFixture]
public class TitleTests
{
[Test(Description = "Should create a title when value is valid")]
public void Title_ShouldCreate_WhenValueIsValid()
{
var title = Title.Create("Clean Architecture in .NET");
Assert.That(title.Value, Is.EqualTo("Clean Architecture in .NET"));
}
[Test(Description = "Should throw NullOrEmptyException when value is null")]
public void Title_ShouldThrowNullOrEmptyException_WhenValueIsNull()
=> Assert.Throws<NullOrEmptyException>(() => Title.Create(null));
[Test(Description = "Should throw InvalidLengthException when value is too long")]
public void Title_ShouldThrowInvalidLengthException_WhenValueIsTooLong()
=> Assert.Throws<InvalidLengthException>(() => Title.Create(new string('a', Title.MaxLength + 1)));
[Test(Description = "Should be equal when values are the same")]
public void Title_ShouldBeEqual_WhenValuesAreTheSame()
{
var a = Title.Create("Clean Architecture");
var b = Title.Create("Clean Architecture");
Assert.That(a, Is.EqualTo(b));
}
}
Cada grupo de testes deve cobrir o caminho feliz, as normalizações, as exceções esperadas, a igualdade estrutural e os operadores definidos. Se você está começando nesse tema, o artigo sobre fundamentos dos testes de unidade é um bom ponto de partida.
Erros comuns ao criar value objects
Mesmo conhecendo os princípios, alguns deslizes aparecem com frequência em value objects reais. O primeiro é deixar o construtor público "só para facilitar os testes", o que abre a porta para instâncias inválidas.
O segundo é usar propriedades com init ou set. Nesse caso, uma expressão with ou um serializador consegue alterar o valor sem passar pela fábrica.
Outro erro comum é validar apenas na camada de API, com anotações ou validadores externos, e esquecer o próprio tipo. Assim, qualquer código interno que crie o objeto sem passar pela API escapa da regra.
Também vale tomar cuidado com coleções dentro de records. Como você viu, listas e arrays comparam por referência, então é preciso sobrescrever a igualdade ou usar uma coleção com comparação por conteúdo.
Por fim, não transforme todo primitivo em value object. Um identificador técnico sem regra de negócio, por exemplo, raramente justifica um tipo próprio.
A regra prática é simples: crie value objects quando o conceito tiver regras, comportamento ou risco real de confusão com outro valor do mesmo tipo primitivo. Fora disso, o tipo primitivo continua sendo uma escolha legítima.
Considerações finais
Value objects são uma das ferramentas mais poderosas do DDD para tornar o modelo de domínio expressivo, seguro e testável. Quando você aplica os princípios deste artigo de forma consistente, a validade dos dados passa a ser uma propriedade do tipo.
O record do C# simplificou muito essa implementação, porque eliminou o código repetitivo e tornou a igualdade estrutural o comportamento padrão. Somado a fábricas estáticas, exceções específicas e mensagens localizadas, ele produz tipos que comunicam a intenção do negócio.
Por fim, testes de unidade abrangentes e um bom mapeamento no EF Core fecham o ciclo. Com isso, seus value objects falham de forma previsível e informativa sempre que alguém viola uma regra.
FAQ
Qual a diferença entre value object e entidade?
A entidade tem identidade, geralmente um Id, e continua a mesma mesmo quando seus dados mudam. O value object não tem identidade, e dois deles são iguais quando todos os valores coincidem.
Devo usar record class ou record struct?
Use record class quando precisar de herança ou quando o tipo for grande. Já o record struct faz sentido para valores pequenos, em que copiar os dados é barato.
Posso usar record para entidades do EF Core?
A documentação da Microsoft desaconselha, porque o EF Core depende de igualdade por referência para rastrear entidades. Records combinam melhor com value objects.
Owned entity ou complex type?
A partir do EF Core 8, a Microsoft recomenda complex types para value objects. Eles não têm chave oculta e comparam por valor, ao contrário das owned entities.
Value objects deixam o sistema mais lento?
Em geral, o custo é pequeno perto do ganho em segurança. Além disso, a imutabilidade permite reaproveitar instâncias com segurança.