Agregados Raiz e Especificações

Padrões Estruturais para um Domínio Robusto

Introdução aos Conceitos de Domínio

Ao desenvolver sistemas complexos seguindo os princípios do Domain-Driven Design (DDD), surgem necessidades específicas de organização das entidades do domínio e das regras de consulta. Dois padrões fundamentais que abordaremos são o Aggregate Root (Agregado Raiz) e o Specification Pattern (Padrão de Especificação).

Trabalhando com Diferentes Entidades

Toda vez que lidamos com diferentes entidades em uma aplicação seguindo Domain-Driven Design, precisamos organizar essas entidades de forma estruturada. Vamos criar uma nova entidade chamada Category para exemplificar os conceitos.

No projeto Domain, dentro do diretório Entities, criamos:

using CleanArchitectureStore.Domain.Abstractions;

namespace CleanArchitectureStore.Domain.Entities;

public class Category 
{
    // Inicialmente criamos uma classe vazia para exemplificação
}

Agregados Raiz

Compreendendo Agregados e Agregados Raiz no DDD

Ao olharmos para Domain-Driven Design, encontramos dois conceitos fundamentais: agregado e agregado raiz.

No contexto de uma loja, temos que entender:

  • Produto é o agregado (Aggregate)

  • Categoria seria o agregado raiz (AggregateRoot)

O que são Agregados e Agregados Raiz?

Um agregado é um conjunto de objetos de domínio que são tratados como uma unidade para propósito de mudanças de dados. O agregado raiz é a entidade principal desse agrupamento, atuando como ponto de acesso único.

Por que essa distinção é importante? No contexto de uma loja, faz sentido ter um repositório apenas para Produto e "anexar" a categoria a ele para persistência. Não faz sentido ter um repositório de categoria neste contexto específico, embora possa fazer sentido ter um repositório de categoria em um módulo de BackOffice para manutenção de categorias e outros itens que compõem a lógica.

Exemplo Prático:

  • Quando você cria um produto, você associa uma categoria existente

  • A categoria não é criada ou modificada através do produto

  • A categoria tem seu próprio ciclo de vida e regras

  • Tudo é persistido através do agregado raiz

O Problema da Persistência Indiscriminada

Em sistemas que não seguem padrões de arquitetura claros, é comum observar a criação de repositórios para todas as entidades do domínio. Esta abordagem apresenta vários problemas:

  • Duplicação de lógica de persistência

  • Dificuldade em manter a consistência transacional

  • Violação do encapsulamento do domínio

  • Acoplamento direto entre aplicação e estrutura de dados

Implementando a Interface IAggregateRoot

Para classificar as entidades como agregados e agregados raiz, geralmente cria-se uma interface chamada IAggregateRoot dentro de Abstractions no projeto Domain que não tem nada dentro:

Esta interface não contém membros porque seu único propósito é servir como um marcador para identificar quais entidades são agregados raiz.

Elegendo Product como Agregado Raiz

Pode-se então ir até uma entidade, por exemplo, Product, e elegê-la como agregado raiz implementando IAggregateRoot:

Esta é apenas uma forma de classificar as entidades como agregados e agregados raiz.

Restringindo Repositórios Apenas para Agregados Raiz

Uma das coisas importantes para trabalhar o RepositoryPattern junto do Aggregate e AggregateRoot é restringir na abstração dos repositórios em IRepository existente dentro do diretório Repositories ainda dentro do projeto Domain. Vamos substituir Entity por IAggregateRoot:

Antes:

Depois:

Com isso, permitimos que sejam criados repositórios somente para Agregados Raiz e não permitimos que sejam criados para outros agregados ou mesmo para entidades normais.

Por que essa restrição é importante?

Não queremos ter a possibilidade de ter repositórios para todas as entidades do domínio porque muitas vezes não faz sentido fazer operações CRUD em cima de qualquer entidade do domínio. Então queremos eleger determinadas entidades para serem persistidas e lidas do banco, e assim por diante.

Testando a Restrição

Se no domínio Product for retirado o IAggregateRoot inserido anteriormente, e com a restrição inserida no IRepository para restringir IAggregateRoot ao invés de Entity, a interface de IProductRepository vai falhar, porque o Repositório de Produto não é mais um agregado raiz. Agora só é possível criar repositórios de agregados raiz.

De:

para:

... isso quebra a compilação.

O mesmo vai acontecer se for tentado criar um repositório de uma entidade que não for um agregado raiz, tal como a categoria.

Benefícios Desta Abordagem

  1. Consistência garantida: Todas as mudanças no agregado passam pelo Aggregate Root

  2. Redução de acoplamento: A aplicação interage apenas com agregados raiz

  3. Melhor desempenho transacional: Operações relacionadas são agrupadas naturalmente

  4. Clareza arquitetural: A estrutura do código reflete a estrutura do domínio

Removendo a Classe Category de Exemplo

Vamos remover a classe Category utilizada para fins de exemplificação até o momento (apenas didática), pois não vamos precisar dela:

Esta é uma abordagem muito usada juntamente com Domain Driven Design.


Specification Pattern

Introdução ao Specification Pattern para Consultas

O próximo tópico que vamos passar a explorar agora é o Specification Pattern para criar especificações de leitura.

Distinção entre Regras de Gravação e Leitura

  • Regras de gravação não passarão pelo repositório e provavelmente serão feitas pela camada de aplicação

  • Regras de leitura sim, passarão pelo repositório

Exemplo Prático: Se a entidade produto tivesse uma propriedade booleana IsActive e quisesse fazer uma especificação para garantir que a leitura dos produtos, a consulta no banco de dados, não trará nenhum produto inativo, como fazer isso? Como testar isso?

A resposta é simples: criar uma especificação. Criamos uma especificação, testamos ela, e depois só a reusamos dentro das queries e consultas.

Criando a Interface ISpecification

Para isso, cria-se uma interface genérica em Abstractions do projeto Domain chamada ISpecification com apenas 2 métodos:

Entendendo os Componentes:

ToExpression(): Espera-se uma expressão que representa a consulta que desejamos executar. É uma expressão genérica Func<T, bool> que recebe um objeto do tipo T e retorna um booleano.

IsSatisfiedBy(T entity): Espera-se uma entidade que é satisfeita por um booleano. Este método verifica se uma entidade específica atende aos critérios da especificação.

Criando a Classe Base Specification

Também cria-se uma classe abstrata genérica base em Abstractions do projeto Domain chamada Specification.cs que implementa a interface ISpecification<T> criada acima:

Como funciona IsSatisfiedBy?

  1. Chama ToExpression() para obter a árvore de expressão

  2. Chama .Compile() para converter a expressão em uma função executável

  3. Executa a função passando a entidade como parâmetro

  4. Retorna o resultado booleano

Esta é uma "receita de bolo" para criarmos nossos specifications de forma padronizada.

Entendendo Expression, Func e Delegates

Expandir: Explicação Detalhada sobre Expression e Func

O que é Expression<Func<T, bool>>?

  • Expression<TDelegate>: Representa uma árvore de expressão que descreve código de forma estruturada

  • Func<T, bool>: Um delegado que recebe um parâmetro do tipo T e retorna um booleano

  • Combinação: Expression<Func<T, bool>> é uma representação de código que pode ser analisada, transformada e executada

Diferença entre Expressão e Delegado

Por que usar Expressões?

  • Tradução para SQL: Entity Framework Core converte expressões LINQ para comandos SQL

  • Composição: Expressões podem ser combinadas em tempo de execução

  • Otimização: O provedor de banco pode otimizar a consulta antes da execução

  • Flexibilidade: Podem ser modificadas antes da execução

Como funciona .Compile()?

O método Compile() converte uma Expression<TDelegate> em um TDelegate executável. Este processo:

  1. Analisa a árvore de expressão

  2. Gera código IL (Intermediate Language)

  3. Compila o código IL em código nativo

  4. Retorna um delegado que pode ser executado

Nota: A compilação tem custo de performance, então é melhor fazer isso uma vez e reutilizar o delegado se possível.

Criando uma Especificação Concreta

Precisamos de uma explicação bem ampla num tópico à parte sobre Expression, Func, etc., mas por enquanto, vamos criar o diretório Specifications/Products no projeto Domain e a classe GetProductByIdSpecification, que seria a especificação para obter o produto pelo Id.

Neste caso será recebido um Guid id, a classe será um ISpecification<T> onde T é Product. Vamos sobrescrever o método Expression com filtro para o Id do produto:

O que está acontecendo aqui?

Ao invés de chapar essa consulta no repositório, criamos uma especificação para ela. Consegue-se testar essa especificação e usar ou reusar ela em diferentes repositórios.

Aqui só é definida a expressão de filtro. Note que usamos uma expressão lambda product => product.Id == _id que será convertida pelo Entity Framework para uma cláusula WHERE no SQL.

Modificando a Interface do Repositório

Agora vamos para os repositórios ainda no projeto Domain. Temos o IProductRepository e ao invés de esperar um Guid id será esperado um Specification<Product> chamado specification:

Antes:

Depois:

Consequentemente, isso quebrará o repositório na infraestrutura e o handler na aplicação, então precisamos atualizar ambos.

Atualizando a Implementação do Repositório

Começando pelo ProductRepository na infraestrutura. Novamente, ao invés de esperar um Guid id, esperar agora um Specification<Product> chamado specification, e consequentemente no FirstOrDefaultAsync não é possível mais utilizar x.Id == id sendo necessário trocar por um Where com a expressão da especificação convertendo para uma expressão lambda:

Antes:

Depois:

Essa seria a modificação do ProductRepository com a utilização de specification.ToExpression() ao invés de utilizar o id direto.

Explicação das Mudanças:

  1. AsNoTracking(): Adicionado para melhor performance. O Entity Framework não rastreia as entidades retornadas, o que é ideal para operações apenas de leitura.

  2. Where(specification.ToExpression()): Aplica a expressão da especificação como filtro na consulta.

  3. Remoção do filtro direto: Não usamos mais x => x.Id == id diretamente.

Atualizando o Handler na Camada de Application

Agora nosso Handler no projeto Application precisa alterar request.id para passar a utilizar uma expressão. Vamos criar um var spec que será uma instância de GetProductByIdSpecification passando o request.Id para ele, e alterar no repository.GetByIdAsync(spec) para passar a utilizá-lo:

Antes:

Depois:

Importante: Como estamos usando Command nada quebra na nossa API. A mudança é interna e transparente para os consumidores da API.


Testando a Implementação Completa

Vamos rodar a aplicação novamente, lembrando de executar o banco de dados:

Testando no Postman:


Exemplos Adicionais de Especificações

Para um entendimento mais completo, vejamos mais exemplos de especificações:

O Problema das Regras de Consulta Espalhadas

Quando as regras de consulta estão espalhadas por diferentes camadas (repositórios, serviços, handlers), encontramos:

  • Dificuldade de reutilização de critérios

  • Complexidade nos testes unitários

  • Duplicação de lógica de filtragem

  • Dificuldade em combinar múltiplos critérios

Solução: Especificações como Cidadãs de Primeira Classe

O Specification Pattern encapsula critérios de consulta em objetos que podem ser combinados, reutilizados e testados independentemente.

Benefícios Combinados

Ao utilizar Aggregate Roots e Specifications em conjunto, obtemos:

  1. Domínio rico e expressivo que encapsula comportamento

  2. Separação clara de responsabilidades entre camadas

  3. Testabilidade aprimorada com especificações isoladas

  4. Flexibilidade de consultas sem vazar detalhes de infraestrutura

  5. Mantenabilidade através de código organizado por responsabilidade

Testando as Especificações

Testes Unitários para Especificações

Boas Práticas e Considerações Finais

Quando Usar Aggregates Roots

  1. Entidades com ciclo de vida independente

  2. Agrupamentos que exigem consistência transacional

  3. Raízes de hierarquias de objetos complexas

  4. Pontos de entrada naturais para operações de negócio

Quando Usar Specifications

  1. Critérios de consulta reutilizáveis em múltiplos lugares

  2. Regras complexas que necessitam de teste unitário

  3. Consultas que podem ser combinadas dinamicamente

  4. Separação clara entre regras de negócio e implementação de persistência

Conclusão

A adoção de Aggregate Roots e Specifications representa um salto de maturidade na aplicação dos princípios do Domain-Driven Design. Estes padrões não apenas organizam o código de forma mais eficiente, mas também refletem com maior fidelidade os conceitos e regras do domínio de negócio, criando uma base sólida para a evolução sustentável do sistema.

A combinação de agregados bem definidos com especificações testáveis resulta em uma arquitetura onde as regras de negócio são explícitas, o código é previsível e as mudanças futuras podem ser implementadas com confiança, sabendo que o sistema mantém sua integridade e coerência em todas as camadas.

Seguindo o fluxo das anotações originais, implementamos:

  1. A criação da entidade Category para exemplificação

  2. A compreensão de agregados e agregados raiz no DDD

  3. A implementação da interface IAggregateRoot

  4. A restrição de repositórios apenas para agregados raiz

  5. A introdução ao Specification Pattern

  6. A criação da interface ISpecification e classe base Specification

  7. A implementação de uma especificação concreta GetProductByIdSpecification

  8. A atualização do repositório para usar especificações

  9. A atualização do handler para criar e usar especificações

  10. O teste completo da implementação

Esta abordagem segue o Princípio da Inversão de Dependência (DIP), onde detalhes de implementação (infraestrutura) dependem de abstrações definidas no domínio, resultando em um código mais testável, flexível e alinhado com as melhores práticas de arquitetura de software.

Atualizado