CRUD
Tradicional vs Abordagem DDD - IV
![]() |
Neste artigo vou continuar uma comparação com a implementação de um CRUD tradicional e a abordagem do Domain Driven Design. |
Continuando o artigo anterior , agora vou mostrar como persistir um modelo de domínio rico usando EF Core sem poluir o domínio e sem voltar para o modelo anêmico do CRUD.
Princípios que vamos seguir (antes do código)
✔ O Domínio não referencia EF
Core
✔ Nenhuma anotação [ForeignKey], [Key], [Column]
✔
O Mapeamento é feito 100%
via Fluent API
✔ A Infraestrutura se adapta ao domínio
✔ O Código de
persistência fica fora do domínio
“Se eu consigo remover o EF Core e
o domínio continua compilando, eu fiz certo.”
Como sugestão podemos organizar o projeto usando a seguinte estrutura:
/Domain ├── Pedido.cs ├── ItemPedido.cs ├── Pagamento.cs ├── Produto.cs ├── Categoria.cs ├── Cliente.cs ├── Endereco.cs ├── Money.cs /Infrastructure ├── ContextoApp.cs ├── Mappings ├── PedidoMapping.cs ├── ProdutoMapping.cs ├── ClienteMapping.cs |
“O domínio não sabe que existe o banco.”
DbContext (simples e limpo)
public class VendasContext : DbContext { public ContextoApp(DbContextOptions<ContextoApp> options) : base(options) { } public DbSet<Pedido> Pedidos => Set<Pedido>(); public DbSet<Produto> Produtos => Set<Produto>(); public DbSet<Cliente> Clientes => Set<Cliente>(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfigurationsFromAssembly( typeof(ContextoApp).Assembly); } } |
Cada Aggregate tem seu mapeamento isolado e isso evita OnModelCreating gigante e ajuda a manter limites claros.
Mapeamento do Aggregate Pedido (o mais importante) : PedidoMapping
public sealed class PedidoMapping : IEntityTypeConfiguration<Pedido> { public void Configure(EntityTypeBuilder<Pedido> builder) { builder.ToTable("Pedidos"); builder.HasKey(p => p.Id); builder.Property(p => p.Status) .HasConversion<string>(); builder.OwnsOne(p => p.EnderecoEntrega, endereco => { endereco.Property(e => e.Rua).HasColumnName("Rua"); endereco.Property(e => e.Numero).HasColumnName("Numero"); endereco.Property(e => e.Cidade).HasColumnName("Cidade"); endereco.Property(e => e.Estado).HasColumnName("Estado"); endereco.Property(e => e.CEP).HasColumnName("CEP"); }); // ------------------------- // Value Object: Money (Total) // ------------------------- builder.Property(p => p.Total) .HasConversion( v => v.Valor, v => new Money(v)); builder.OwnsMany(p => p.Itens, itens => { itens.ToTable("ItensPedido"); itens.WithOwner() .HasForeignKey("PedidoId"); itens.Property<Guid>("Id"); itens.HasKey("Id"); itens.Property(i => i.Quantidade); itens.Property(i => i.Preco) .HasConversion( v => v.Valor, v => new Money(v)); itens.Property(i => i.ProdutoId); }); builder.OwnsOne(p => p.Pagamento, pagamento => { pagamento.Property(p => p.Valor) .HasConversion( v => v.Valor, v => new Money(v)); pagamento.Property(p => p.Data); }); } } |
Vamos entender os recursos usados neste mapeamento:
1. OwnsOne e
OwnsMany
EnderecoEntrega não é entidade. Ele não vive sozinho. Ele
pertence ao Pedido.
| Cbuilder.OwnsOne(p => p.EnderecoEntrega) |
OwnsOne, estamos
dizendo ao EF Core: 'Não crie uma vida própria para esse endereço. Ele é parte
integrante do Pedido'.
No banco de dados, o EF Core geralmente coloca as colunas do endereço (Rua, Cidade, CEP) dentro da mesma tabela do Pedido. Se o Pedido for excluído, o endereço desaparece com ele automaticamente."
Lembrando que ItemPedido não tem repositório. Ele só existe dentro do Pedido.
OwnsMany é usado para mapear coleções de objetos que são dependentes do Aggregate Root. Exemplo no código usado para ItemPedido:
| builder.OwnsMany(p => p.Itens) |
Aqui o
ItemPedido não tem vida social. Você
nunca verá um
ItemPedido navegando sozinho pelo
sistema ou tendo um
IRepositorioItemPedido. Ele só existe
enquanto o Pedido existir. Ao usar
OwnsMany, o EF Core
entende que essa lista de itens é uma
dependência total da raiz.
No banco, eles estarão em uma tabela separada (por ser uma lista), mas para o
seu código C#, eles são invisíveis para qualquer um que não seja o próprio
Pedido. O Aggregate Root é o único portão de entrada.
2- Shadow Key em ItemPedido
O domínio não precisa de Id em ItemPedido, mas o banco precisa. Assim usamos este código:
|
itens.Property<Guid>("Id"); itens.HasKey("Id"); |
Reparem que, na nossa classe ItemPedido, nós não criamos uma propriedade
public
Guid Id { get; set; }. Para o nosso Domínio, o item é identificado apenas pela
sua posição dentro do Pedido ou pelo seu conteúdo. Adicionar um ID lá seria
'sujar' nossa classe com uma necessidade que não é do negócio.
No
entanto, o Banco de Dados Relacional (como SQL Server ou SQLite) é implacável:
ele precisa de uma Chave Primária para gerenciar as linhas da tabela de itens de
forma única.
É aqui que entra a Shadow Key (Chave Sombra):
Ela é "Sombra": Porque ela existe apenas no
'mundo' do EF Core e do Banco de Dados. Ela não existe na sua classe C#.
O Domínio fica puro: Seu objeto ItemPedido continua focado apenas no que importa
para o negócio (produto, quantidade, preço).
O Banco fica feliz: O EF
Core cria e gerencia esse ID nos bastidores. Quando ele salva o item, ele gera o
valor; quando ele lê, ele mantém esse valor em uma estrutura interna, sem nunca
precisar 'tocar' na sua entidade de domínio.”
O DDD preza pelo Domínio isolado da Infraestrutura. Se começarmos a colocar IDs em tudo só porque o banco pede, logo teremos classes cheias de atributos e propriedades que não servem para o negócio, mas apenas para "agradar" o ORM. A Shadow Key é o recurso que nos permite usar o EF Core sem sacrificar a elegância do nosso modelo.
3. Conversão de Value Object (Money)
O banco de dados salva o dado (decimal). O domínio trabalha com o conceito (Money).
| builder.Property(p => p.Total) .HasConversion( v => v.Valor, // Como o Objeto vira um Decimal para o Banco v => new Money(v)); // Como o Decimal do Banco vira um Objeto Money |
Muitas vezes, o desenvolvedor desiste de usar Value Objects porque acha que vai
ter que criar uma tabela inteira só para guardar um único valor de dinheiro. O
HasConversion resolve isso de forma elegante.
O Banco de Dados é 'burro':
Ele só entende tipos primitivos. Para ele, dinheiro é apenas um decimal(18,2).
Ele não sabe o que é inflação, arredondamento ou moeda.
O Domínio é
'inteligente': Para nós, Money não é apenas um número. É um conceito que protege
o sistema de valores negativos e centraliza a lógica financeira.
Quando
usamos o HasConversion, estamos criando uma ponte de tradução:
Na hora de Salvar: O EF Core 'desmonta' o seu objeto Money,
pega apenas o número decimal e joga na coluna do banco.
Na hora
de Ler: O EF Core pega aquele número seco do banco e o 'reidrata',
transformando-o novamente em um objeto Money completo, com todos os seus
comportamentos e proteções.”
Mapeamento do Aggregate Produto
public sealed class ProdutoMapping : IEntityTypeConfiguration<Produto> { public void Configure(EntityTypeBuilder<Produto> builder) { builder.ToTable("Produtos"); builder.HasKey(p => p.Id); builder.Property(p => p.Nome) .IsRequired(); builder.Property(p => p.Preco) .HasConversion( v => v.Valor, v => new Money(v)); builder.OwnsOne(p => p.Categoria, categoria => { categoria.Property(c => c.Nome) .HasColumnName("Categoria"); }); } } |
A Categoria não merece uma tabela não tem repositório. Ela é parte do Produto.
Mapeamento do Aggregate Cliente
public sealed class ClienteMapping : IEntityTypeConfiguration<Cliente> { public void Configure(EntityTypeBuilder<Cliente> builder) { builder.ToTable("Clientes"); builder.HasKey(c => c.Id); builder.Property(c => c.Nome) .IsRequired(); builder.OwnsOne(c => c.Endereco, endereco => { endereco.Property(e => e.Rua).HasColumnName("Rua"); endereco.Property(e => e.Numero).HasColumnName("Numero"); endereco.Property(e => e.Cidade).HasColumnName("Cidade"); endereco.Property(e => e.Estado).HasColumnName("Estado"); endereco.Property(e => e.CEP).HasColumnName("CEP"); }); } } |
O Endereco do Cliente e Endereco do Pedido têm o mesmo tipo, mas significados diferentes no domínio.
O DDD não elimina o EF Core. Ele impede que o EF Core destrua seu domínio.
A seguir veremos a criação dos reposit[órios para cada Aggregate Root.
E estamos
conversados...
"Porque vós, irmãos, fostes chamados à liberdade. Não useis então da liberdade
para dar ocasião à carne, mas servi-vos uns aos outros pelo amor."
Gálatas 5:13
Referências:
NET - Unit of Work - Padrão Unidade de ...