Por que você deveria estar usando Value Objects no seu código — agora mesmo

Você já se pegou duplicando validações em vários lugares da aplicação? Ou lidando com valores como CNPJ, CPF, Email ou CEP como simples string
?
Essa abordagem funciona… até o domínio mudar.
E quando muda — como pode acontecer com o CNPJ, por exemplo — sistemas que foram modelados com tipos primitivos começam a quebrar.
Neste artigo, quero mostrar por que Value Objects importam, como implementá-los em C#, e como aplicá-los mesmo em sistemas legados que não seguem DDD.
Tipos primitivos não carregam regras de negócio
Um dos erros mais comuns é esse:
public class Empresa
{
public string Cnpj { get; set; }
}
Aqui, Cnpj
é apenas uma string:
- Pode estar em branco;
- Pode conter caracteres inválidos;
- Pode ser trocado com qualquer outro campo (
Email
,Telefone
) e o compilador não vai reclamar.
Esse é o famoso Primitive Obsession — quando usamos tipos primitivos para representar conceitos ricos do domínio.
O conceito de Value Object
Na orientação a objetos, um Value Object (VO) representa um valor do domínio que tem significado por si só, mas não possui identidade própria.
Ele é definido por suas propriedades e regras de validação, e dois objetos com os mesmos valores são considerados iguais, mesmo que estejam em instâncias diferentes.
Lembre-se, Orientação a Objetos é sobre dividir responsabilidades.
Em outras palavras, não importa “quem ele é”, mas sim “o que ele representa”.
Um Value Object é:
- Imutável — não muda depois de ser criado;
- Sem identidade — diferente de uma entidade que precisa de um ID;
- Autocontido — ele carrega consigo suas regras de validação e consistência;
- Comparado por valor — dois VOs com os mesmos dados são iguais.
Implementando um Value Object
Apesar do Value Object estar fortemente ligado ao DDD, ele também pode ser implementado em aplicações que não utilizam esse design.
Vamos pegar como caso de uso a validação do CNPJ que passar a ter letras a partir do próximo ano.
Para construir um Value Object em C# utilizamos os records
, pois são estruturas imutáveis, não possuem identidade e ao compara-los olhamos para os dados e não para o endereço na memória como as classes.
A estrutura base de um Value Object consiste em uma variável Value
, seu construtor deve ser privado e temos um Builder que faz a criação do objeto.
public sealed record Cnpj
{
public string Value { get; init; }
private Cnpj() { }
public static Cnpj Create(string value)
{
// Todo logic...
}
}
Implementando a nova regra do CNPJ devemos ter a implementação abaixo. Note que toda regra de negócio está contida no VO, não sendo necessária a criação de uma classe “CnpjUtil.cs”.
public sealed record Cnpj
{
public string Value { get; init; }
private static readonly Regex CnpjRegex =
new(@"^[A-Z0-9]{12}\d{2}$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly int[] WeightsForFirstDigit = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
private static readonly int[] WeightsForSecondDigit = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
public static Cnpj Create(string value)
{
if (!TryParse(value, out var parsed))
throw new ArgumentException("CNPJ inválido.", nameof(value));
return parsed;
}
private Cnpj(string sanitizedValue)
{
Value = sanitizedValue;
}
public static bool TryParse(string input, out Cnpj cnpj)
{
cnpj = null;
if (string.IsNullOrWhiteSpace(input))
return false;
string sanitized = Sanitize(input);
if (!IsValidFormat(sanitized))
return false;
string baseNumber = sanitized[..12];
string dvInput = sanitized[12..14];
int calculatedDv1 = CalculateCheckDigit(baseNumber, WeightsForFirstDigit);
int calculatedDv2 = CalculateCheckDigit(baseNumber + calculatedDv1, WeightsForSecondDigit);
string dvCalculated = $"{calculatedDv1}{calculatedDv2}";
if (dvInput != dvCalculated)
return false;
cnpj = new Cnpj(sanitized);
return true;
}
private static string Sanitize(string input) =>
Regex.Replace(input, @"[^A-Za-z0-9]", "").ToUpper();
private static bool IsValidFormat(string input) =>
input.Length == 14 && CnpjRegex.IsMatch(input);
private static int CalculateCheckDigit(string sequence, int[] weights)
{
if (sequence.Length != weights.Length)
throw new ArgumentException("Comprimento da sequência não corresponde aos pesos.");
int sum = 0;
for (int i = 0; i < sequence.Length; i++)
{
int value = CharToDvValue(sequence[i]);
sum += value * weights[i];
}
int remainder = sum % 11;
return (remainder == 0 || remainder == 1) ? 0 : 11 - remainder;
}
private static int CharToDvValue(char c)
{
int ascii = (int)char.ToUpper(c);
return ascii - 48;
}
public string GetFormattedValue()
{
if (Value.Length != 14)
return Value;
return $"{Value.Substring(0, 2)}.{Value.Substring(2, 3)}.{Value.Substring(5, 3)}/" +
$"{Value.Substring(8, 4)}-{Value.Substring(12, 2)}";
}
public override string ToString() => Value;
}
E como aplicar isso no seu sistema?
Vimos que a implementação é bem simples e não é necessária nenhuma grande refatoração no código, é uma prática que você pode iniciar hoje mesmo na aplicação em que trabalha.
Vamos ver agora como seria a utilização desse VO:
public class Empresa
{
public Cnpj Cnpj { get; private set; }
public Empresa(Cnpj cnpj)
{
Cnpj = cnpj;
}
}
1. Criação de entidade com validação automática
var cnpj = new Cnpj("12345678000195");
var empresa = new Empresa(cnpj);
Aqui, se o CNPJ for inválido, a exceção será lançada imediatamente — antes mesmo de criar a Empresa
. A validação está centralizada no Value Object
, evitando duplicação de lógica e regras espalhadas.
2. Comparação segura
if (empresaA.Cnpj == empresaB.Cnpj)
{
Console.WriteLine("Empresas com o mesmo CNPJ");
}
Sem Value Object, estaríamos comparando strings, correndo risco de bugs sutis. Com VO, a comparação é semântica — baseada no valor.
Se você já se sentiu desconfortável em trabalhar com Exceptions
para tratar validações de domínio como o Cnpj
, você não está sozinho.
Uma alternativa elegante é o uso do Result Pattern para encapsular o resultado de uma operação — com sucesso ou erro — sem depender de exceções.
👉 Já escrevi um artigo completo sobre o Result Pattern e como aplicá-lo no domínio — recomendo a leitura se você quer aprofundar esse padrão e integrá-lo com seus Value Objects.
Conclusão
Você não precisa reescrever seu sistema em DDD para usar Value Objects.
Pode começar agora, com algo simples como Cnpj
, Cpf
ou Email
.
Seu código vai ficar mais limpo, mais seguro e mais preparado para mudanças inevitáveis no domínio.