Entity Framework e Value Objects
Mapear value objects no EF Core é a ponte entre um domínio rico, cheio de regras de negócio, e uma tabela simples no banco de dados. Neste artigo, você vai entender o que são objetos de valor, por que eles combatem a obsessão por tipos primitivos e como persistir tudo com o método OwnsOne.
O que são value objects no EF Core
Objetos de valor, ou value objects, são estruturas imutáveis que compõem as entidades de negócio. Além disso, eles concentram regras em um único lugar e eliminam a reescrita de código.
Antes de falar de value objects no EF Core, vale separar o conceito da ferramenta. O value object nasce na modelagem de domínio, enquanto o EF Core apenas cuida da persistência dele.
Segundo o guia de arquitetura da Microsoft, um value object tem duas características principais. Primeiro, ele não tem identidade; em seguida, ele é imutável depois de criado.
Primitive obsession na prática
Uma das premissas para usar value objects é evitar a obsessão primitiva, ou primitive obsession. O termo descreve entidades construídas apenas com tipos primitivos, como int, string e bool.
Para ilustrar, vamos usar a classe User, que praticamente todo sistema possui.
public class User
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public string Password { get; set; }
}
Este é um caso clássico de entidade anêmica. Ela apenas reflete os dados da tabela, e não o negócio em si.
Para piorar, a classe está totalmente presa a tipos primitivos. Agora imagine que você precisa validar o e-mail no usuário, depois no aluno, depois no fornecedor e assim por diante.
O e-mail tem sua própria regra de negócio, e essa regra vale para qualquer e-mail do sistema.
Sem um tipo dedicado, essa validação se espalha por várias classes e serviços. Mais tarde, quando chegarmos aos value objects no EF Core, você verá que centralizar a regra também simplifica o mapeamento.
Em resumo, faz sentido criar um tipo chamado Email que já carrega as validações.
Criando o primeiro value object
O código abaixo cria o tipo Email. Na prática, ele recebe o endereço no construtor e só deixa o objeto existir se o valor for válido.
public class Email
{
public Email(string address)
{
if (string.IsNullOrEmpty(address) || address.Length < 5)
throw new InvalidEmailException();
Address = address.ToLower().Trim();
const string pattern = @"^\w+([-+.']\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$";
if (!Regex.IsMatch(Address, pattern))
throw new InvalidEmailException();
}
public string Address { get; }
}
Com o tipo criado, a entidade passa a usar mais tipos complexos e menos tipos primitivos. Como o e-mail compõe uma entidade, mas não tem razão para existir sozinho, ele se classifica como value object.
public class User
{
public string FirstName { get; set; }
public string LastName { get; set; }
public Email Email { get; set; }
public string Password { get; set; }
}
Esse tipo será a base de todos os exemplos de value objects no EF Core deste artigo. Se quiser se aprofundar na criação desses tipos, confira o artigo sobre como criar bons value objects em C#.
Imutabilidade e igualdade por valor
A imutabilidade significa que, uma vez construído, o objeto não muda mais, pelo menos não externamente. Isso nos leva a remover ou privar o set das propriedades, como fizemos no Email.
Esse cuidado também deixa a classe aberta para extensão, mas fechada para modificação. Assim, ninguém altera o endereço de e-mail sem passar pelas regras do construtor.
Outro ponto importante é a igualdade. Dois value objects com os mesmos valores são considerados iguais, porque não existe um identificador para diferenciá-los.
Por esse motivo, muitos projetos usam uma classe base ValueObject que compara todos os atributos. Essa classe não interfere no mapeamento dos value objects no EF Core, porque a comparação acontece apenas em memória.
A Microsoft mostra essa abordagem no domínio de pedidos do eShopOnContainers.
Confira mais sobre DDD, modelagem de domínios, entidades e objetos de valor no nosso curso Modelando Domínios Ricos.
Da entidade anêmica para o modelo rico
Para comparar, vamos acompanhar a mudança de uma entidade anêmica para uma entidade segmentada em objetos de valor. O primeiro passo é sair da classe obcecada por tipos primitivos.
Essa mudança acontece só no domínio, mas ela prepara o terreno para os value objects no EF Core. Afinal, o mapeamento depende de como a entidade expõe seus objetos.
// RUIM: entidade anêmica e presa a tipos primitivos
public class User
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public string Password { get; set; }
}
Em seguida, a entidade passa a receber value objects no construtor. Dessa forma, ela só nasce em um estado válido.
// BOM: entidade composta por value objects
public class User
{
public User(Name name, Email email, Password password)
{
Name = name;
Email = email;
Password = password;
}
public Name Name { get; }
public Email Email { get; }
public Password Password { get; }
}
IMPORTANTE: o código acima não está completo. Ele é apenas um exemplo, então não o tome como base para seus projetos.
Regras de dentro para fora
À medida que adotamos objetos de valor, as regras migram para dentro deles. Com isso, as entidades ficam com menos regras e mais clareza.
Esse movimento se repete em todo o sistema, e o uso de value objects no EF Core não muda essa lógica. No fim, os controllers da API terão uma ou duas linhas de código.
O movimento de dentro para fora é importante e necessário. Afinal, quanto mais próximo do núcleo, mais livre de infraestrutura e mais fácil de testar fica o código.
Se quiser conferir mais sobre testes de unidade, confira nosso curso Refatorando para testes de unidade.
O value object de e-mail pronto para o EF Core
Para se ter uma noção desse movimento, este é um value object de e-mail semelhante ao que usamos aqui no balta. Ele cuida da validação, da verificação e da expiração do código.
public class Email : ValueObject
{
// Construtor obrigatório para o EF Core materializar o objeto
protected Email()
{
}
public Email(string address)
{
if (string.IsNullOrEmpty(address) || address.Length < 5)
throw new InvalidEmailException();
Address = address.ToLower().Trim();
const string pattern = @"^\w+([-+.']\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$";
if (!Regex.IsMatch(Address, pattern))
throw new InvalidEmailException();
Verified = false;
VerificationCode = GenerateVerificationCode();
VerificationCodeExpireDate = DateTime.UtcNow.AddHours(2);
}
public string Address { get; private set; } = string.Empty;
public bool Verified { get; private set; }
public string VerificationCode { get; private set; } = string.Empty;
public DateTime VerificationCodeExpireDate { get; private set; } = DateTime.UtcNow.AddHours(2);
public void Verify(string verificationCode)
{
if (verificationCode != VerificationCode)
throw new ArgumentException("Invalid verification code");
// O código expira quando a data limite já ficou no passado
if (VerificationCodeExpireDate < DateTime.UtcNow)
throw new ArgumentException("Verification code expired");
Verified = true;
}
public void GenerateNewVerificationCode()
{
Verified = false;
VerificationCode = GenerateVerificationCode();
VerificationCodeExpireDate = DateTime.UtcNow.AddHours(8);
}
public void Expire() => Verified = false;
private static string GenerateVerificationCode()
=> Guid.NewGuid().ToString().ToUpper()[..8];
public static implicit operator string(Email email) => email.Address;
public static implicit operator Email(string address) => new(address);
public override string ToString() => Address;
}
Repare que o construtor protegido existe só para o EF Core. Ele é um detalhe comum a quase todos os value objects no EF Core.
Na prática, ele precisa criar a instância antes de preencher as propriedades com os dados do banco.
Note também os operadores implícitos no fim da classe. Eles permitem converter Email em string e vice-versa; o artigo sobre implicit operators explica esse recurso em detalhes.
Como mapear value objects no EF Core
Depois dessa introdução sobre o que são e para que servem os objetos de valor, surge a pergunta: como colocar essa informação no banco de dados?
Uma das premissas da modelagem de domínio é não se prender ao banco. Lembra que as entidades anêmicas eram apenas uma visualização de uma tabela?
Porém, em algum momento precisamos persistir esses valores. Normalmente, fazemos isso com um ORM, e é aqui que entram os value objects no EF Core.
Value objects no EF Core não têm Id
Diferente das entidades, objetos de valor não possuem identificador. Isso acontece porque eles fazem parte da composição das entidades e não têm motivo para existir sozinhos.
Na documentação, o EF Core chama esse recurso de owned entity types, ou tipos possuídos. Nesse caso, a entidade que contém o objeto é a dona, e o objeto só aparece por meio dela.
Internamente, o EF Core cria uma chave de sombra para o tipo possuído. Contudo, o valor dessa chave é o mesmo da chave da entidade dona, então o seu domínio continua sem Id no value object.
Mapeando a entidade User
Vamos usar o usuário como base para o mapeamento inicial. Ele herda de uma classe base chamada Entity, que define o Id, e tem apenas um campo que não é value object, o Active.
public class User : Entity
{
// Construtor obrigatório para o EF Core funcionar
protected User() { }
public User(Name name, Email email, Password password)
{
Name = name;
Email = email;
Password = password;
Active = false;
}
public Name Name { get; private set; }
public Email Email { get; private set; }
public Password Password { get; private set; }
public bool Active { get; private set; }
}
public class UserMap : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.ToTable("User");
builder.HasKey(x => x.Id);
builder.Property(x => x.Active)
.HasColumnName("Active")
.IsRequired(true);
}
}
Neste ponto, o mapeamento cuida apenas da tabela, da chave e do campo Active, sem nenhum dos value objects no EF Core. Por enquanto, as propriedades que são objetos de valor ficam de fora.
Usamos private set porque o EF Core precisa atribuir valores ao materializar o objeto. Mesmo assim, nenhum código externo consegue alterar essas propriedades.
Caso queira aprender mais sobre Entity Framework e mapeamento, confira nosso curso Fundamentos do Entity Framework.
Usando OwnsOne para mapear value objects no EF Core
O EF Core não tem um método chamado "MapValueObject", e nem precisa. Como os objetos de valor não têm Id, eles funcionam apenas como composição das entidades.
Assim, usamos o OwnsOne, que significa "possui um". Com ele, dizemos que o usuário possui um objeto aninhado que vai para a mesma tabela.
builder.OwnsOne(x => x.Email)
.Property(x => x.Address)
.HasColumnName("Email")
.IsRequired(true);
builder.OwnsOne(x => x.Email)
.Property(x => x.Verified)
.HasColumnName("EmailVerified")
.IsRequired(true);
builder.OwnsOne(x => x.Email)
.Property(x => x.VerificationCode)
.HasColumnName("EmailVerificationCode")
.IsRequired(true);
builder.OwnsOne(x => x.Email)
.Property(x => x.VerificationCodeExpireDate)
.HasColumnName("EmailVerificationCodeExpireDate")
.IsRequired(true);
Também usamos o HasColumnName para dar nomes legíveis às colunas da tabela. Sem ele, o EF Core gera nomes no padrão Email_Address, Email_Verified e assim por diante.
Isso é tudo o que precisamos para mapear value objects no EF Core. A partir daqui, você já consegue ler, salvar, editar e excluir informações usando seus objetos de valor.
Configuração mais limpa com lambda aninhada
Chamar OwnsOne várias vezes para o mesmo objeto funciona, mas repete código. Por outro lado, o método aceita uma segunda lambda que configura todas as propriedades de uma vez.
builder.OwnsOne(x => x.Email, email =>
{
email.Property(x => x.Address)
.HasColumnName("Email")
.HasMaxLength(160)
.IsRequired(true);
email.Property(x => x.Verified)
.HasColumnName("EmailVerified")
.IsRequired(true);
email.Property(x => x.VerificationCode)
.HasColumnName("EmailVerificationCode")
.IsRequired(true);
email.Property(x => x.VerificationCodeExpireDate)
.HasColumnName("EmailVerificationCodeExpireDate")
.IsRequired(true);
});
Esse formato aparece na documentação oficial de owned entity types. Na prática, ele deixa claro que todas as linhas pertencem ao mesmo value object.
O mesmo padrão vale para os outros objetos da entidade. Por exemplo, o Name pode ser mapeado para as colunas FirstName e LastName.
builder.OwnsOne(x => x.Name, name =>
{
name.Property(x => x.FirstName)
.HasColumnName("FirstName")
.HasMaxLength(80)
.IsRequired(true);
name.Property(x => x.LastName)
.HasColumnName("LastName")
.HasMaxLength(80)
.IsRequired(true);
});
Mapeamento completo do usuário
Juntando tudo, o mapeamento final do usuário com o e-mail fica assim. Repare que tudo vai para a tabela User.
public class UserMap : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.ToTable("User");
builder.HasKey(x => x.Id);
builder.Property(x => x.Active)
.HasColumnName("Active")
.IsRequired(true);
builder.OwnsOne(x => x.Email)
.Property(x => x.Address)
.HasColumnName("Email")
.IsRequired(true);
builder.OwnsOne(x => x.Email)
.Property(x => x.Verified)
.HasColumnName("EmailVerified")
.IsRequired(true);
builder.OwnsOne(x => x.Email)
.Property(x => x.VerificationCode)
.HasColumnName("EmailVerificationCode")
.IsRequired(true);
builder.OwnsOne(x => x.Email)
.Property(x => x.VerificationCodeExpireDate)
.HasColumnName("EmailVerificationCodeExpireDate")
.IsRequired(true);
}
}
Por fim, registre a configuração no DbContext. O método ApplyConfiguration mantém o OnModelCreating enxuto.
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options) { }
public DbSet<User> Users { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new UserMap());
}
}
Note que não existe DbSet<Email>. Isso é intencional, porque um tipo possuído nunca ganha um DbSet próprio.
O que acontece no banco de dados
Por padrão, o EF Core grava o tipo possuído na mesma tabela da entidade dona. A documentação chama essa técnica de table splitting, ou divisão de tabela.
Em outras palavras, algumas colunas guardam os dados do usuário e outras guardam os value objects no EF Core, como o e-mail. Para o banco, existe apenas uma tabela User com todas as colunas lado a lado.
Se preferir, você pode mover o tipo possuído para outra tabela. Para isso, basta chamar ToTable dentro da configuração do OwnsOne.
Ao gerar uma migração, as colunas Email, EmailVerified, EmailVerificationCode e EmailVerificationCodeExpireDate aparecem na tabela. O artigo sobre migrations com EF Core e PostgreSQL mostra esse fluxo passo a passo.
Consultas com value objects no EF Core
Ao consultar a entidade dona, o EF Core já carrega os tipos possuídos. Portanto, não é necessário chamar Include para trazer o e-mail do usuário.
Também é possível filtrar pelas propriedades do value object. Nesse caso, o EF Core traduz o acesso para a coluna correspondente.
// Busca o usuário pelo endereço de e-mail
var user = await context.Users
.FirstOrDefaultAsync(x => x.Email.Address == "john@balta.io");
Com isso, a consulta gera um WHERE direto na coluna Email. Ou seja, o domínio fica rico sem prejudicar a forma de consultar o banco.
Limitações dos value objects no EF Core
O próprio guia da Microsoft reconhece que existem lacunas entre o value object canônico do DDD e o owned entity type. Ainda assim, por muito tempo ele foi a melhor forma de persistir value objects no EF Core.
As principais limitações, segundo a documentação, são estas:
- Você não pode criar um
DbSet<T>para um tipo possuído. - Você não pode chamar
Entity<T>()com um tipo possuído noModelBuilder. - A mesma instância de um tipo possuído não pode ser compartilhada por várias entidades donas.
- Tipos possuídos não suportam hierarquias de herança.
Além disso, versões antigas tinham comportamentos próprios. No EF Core 3.x, por exemplo, as colunas de tipos possuídos na mesma tabela eram sempre marcadas como anuláveis.
Por isso, confira o comportamento da versão que você usa antes de colocar o mapeamento em produção. Afinal, um detalhe de nulidade muda o esquema gerado pela migração.
Complex types como alternativa
A partir do EF Core 8, a documentação passou a recomendar os complex types para modelar value objects. Diferente dos tipos possuídos, eles têm semântica de valor e não usam chave oculta.
Nesse modelo, a configuração usa ComplexProperty em vez de OwnsOne. As colunas também ficam na tabela da entidade, mas nunca em uma tabela separada.
Se o seu projeto já usa o EF Core 8 ou superior, vale avaliar essa opção. Se estiver em versões anteriores, o OwnsOne continua sendo o caminho deste artigo.
Para entender onde esse mapeamento se encaixa na arquitetura, leia também sobre EF Core em Clean Architecture.
Conclusão
Trabalhar com objetos de valor deixa o código um pouco mais burocrático e complexo. No entanto, ele traz vantagens no longo prazo, como reuso de código e regras concentradas em um só lugar.
Além disso, persistir value objects no EF Core é uma tarefa simples. Basta usar o OwnsOne, nomear as colunas com HasColumnName e manter o construtor protegido para o EF Core.
Dessa forma, o domínio continua expressivo e o banco continua simples. Esse equilíbrio é exatamente o que buscamos ao modelar domínios ricos.
FAQ
O que é um value object?
É um objeto sem identidade própria, definido apenas pelos seus valores e normalmente imutável. Exemplos comuns são e-mail, nome, endereço e dinheiro.
Preciso criar uma tabela para cada value object?
Não. Por padrão, o OwnsOne grava as propriedades do value object como colunas na mesma tabela da entidade dona.
Por que o value object precisa de um construtor protegido?
O EF Core precisa criar a instância antes de preencher as propriedades com os dados do banco. O construtor protegido permite isso sem expor um construtor vazio ao restante do código.
Preciso usar Include para carregar um tipo possuído?
Não. O EF Core carrega os tipos possuídos automaticamente sempre que você consulta a entidade dona.
Qual a diferença entre OwnsOne e ComplexProperty?
O OwnsOne cria um tipo possuído com chave de sombra, enquanto o ComplexProperty, disponível a partir do EF Core 8, cria um complex type com semântica de valor e sem chave.