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)

Quando usamos 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:


José Carlos Macoratti