ASP.NET - Usando o padrão Specification - I


  Hoje vou apresentar o padrão Specification e mostrar uma implementação básica em uma aplicação ASP .NET  Core usando a Clean Architecture e o EF Core.

O padrão Specificaton é um padrão de design de software, onde as regras de negócio podem ser recombinadas através de lógica booleana.

Este padrão nos permite encapsular algum conhecimento de domínio em uma única unidade - especificação - e reutilizá-lo em diferentes partes da base de código.

Este padrão foi descrito por Eric Evans e mesmo não sendo uma novidade  ele é mais utilizado na abordagem do Domain Driven Design.

Segundo Martin Fowler e Eric Evans, os principais problemas que este padrão resolve estão relacionados com os seguintes recursos :

  1. Seleção: Você precisa selecionar um subconjunto de objetos com base em alguns critérios e atualizar a seleção várias vezes;
     
  2. Validação: Você precisa verificar se apenas objetos adequados são usados para uma determinada finalidade e se encaixam na especificação;
     
  3. Construção sob encomenda: Você precisa descrever o que um objeto pode fazer, sem explicar os detalhes de como o objeto o faz, mas de forma que um candidato possa ser construído para atender ao requisito;

Se você quer mais detalhes veja o paper escrito neste link : https://martinfowler.com/apsupp/spec.pdf

A seguir vou apresentar um exemplo de atuação do padrão mostrando o problema que ele resolve que foi retirado, traduzido e adaptado deste artigo : https://aspnetboilerplate.com/Pages/Documents/Specifications

Suponha que você tenha um método de serviço que calcula a contagem total de seus clientes conforme mostrado abaixo:

public class CustomerManager
{

    public int GetCustomerCount()
   
{
      
 //TODO...
        return 0;
    }
}

Você provavelmente desejará obter uma contagem de clientes com um filtro. Por exemplo, você pode ter clientes premium (que têm um saldo de mais de R$ 100.000) ou pode querer filtrar os clientes apenas por ano de registro.

Em seguida, você pode criar outros métodos como GetPremiumCustomerCount(), GetCustomerCountRegisteredInYear(int year), GetPremiumCustomerCountRegisteredInYear(int year) e muito mais. Como você tem mais critérios, não é possível criar uma combinação para todas as possibilidades.

Quando temos um cenário como este onde precisamos geralmente definir um repositório de consultas que retornam dados onde temos que definir regras com muita variação ou  que podem sofrer constantes alterações em suas regras.  Geralmente tendemos a poluir repositório com dezenas de consultas.

Como consequência acabamos duplicando código no controlador e no repositório que acabam realizando a mesma tarefa, e assim estamos ferindo os principios SOLID OCP e SRP e esta violação deixa o código difíciil de manter e testar.

Na programação orientada a objeto, o princípio do aberto/fechado estabelece que “entidades de software (classes, módulos, funções, etc.) devem ser abertas para extensão, mas fechadas para modificação“; isto é, a entidade pode permitir que o seu comportamento seja estendido sem modificar seu código-fonte. (Wikipedia)

Aqui é que entra o padrão Specification que pode ser usado para recombinar regras de negócio usando logica booleana. Usando este padrão podemos definir regras de negócios em um cenário onde elas podem ser alteradas com frequencia  como aplicações tratam com leis e regulações.

Uma solução para o problema apresentando no exemplo seria usar o padrão de especificação onde poderíamos criar um único método que recebe um parâmetro como filtro:

public class CustomerManager
{
    private readonly IRepository<Customer> _customerRepository;

    public CustomerManager(IRepository<Customer> customerRepository)
   
{
        _customerRepository = customerRepository;
    }

    public int GetCustomerCount(ISpecification<Customer> spec)
   
{
        var customers = _customerRepository.GetAllList();

        var customerCount = 0;
       
        foreach (var customer in customers)
        {
            if (spec.IsSatisfiedBy(customer))
            {
                customerCount++;
            }
        }

        return customerCount;
    }
}

Desta forma, podemos obter qualquer objeto como parâmetro que implemente a interface ISpecification<Customer> que é definida conforme mostrado abaixo:

public interface ISpecification<T>
{
    bool IsSatisfiedBy(T obj);
}

Podemos chamar IsSatisfiedBy com um cliente para testar se esse cliente é pretendido. Dessa forma, podemos usar o mesmo GetCustomerCount com diferentes filtros sem alterar o método em si.

Desta forma Sem usar o padrão Specification acabamos tendo um repositorio generico com diversos métodos para retornar informações usando regras de negócio nas mais diferentes condições.

Seguindo esta abordagem acabamos temos os seguintes problemas :

1- Código duplicado ferindo o princípio DRY
2- Lógica duplicada no Repositório e no Controller
3- Uso de blocos if/else ferindo o princípio OCP e SRP

Assim fica a pergunta :

Como implementar regras de negócio que podem mudar com o passar do tempo sem violar o princípio SOLID Aberto/Fechado ou “Open/Closed Principle” ?


Aqui entra o padrão Specification.
 Neste padrão destacamos os seguines prós e contras:

1- Vantagens

- Código mais facil de manter
- Reaproveitamento do codigo
- Permite definir regras mais claras e expressivas
- Mais facil de testar


2- Desvantagens

- Muito codigo - uma classe para cada tipo de regra
- Especificações compostas são dificeis de explicitar as mensagens


Agora cabe destacar que devemos usar o padrão de forma correta.

Assim, o padrão Specification é indicado quando a entidade não tem a responsabilidade de validar
uma regra especifica. 

Não é objetivo da especificação absorver regras que pertencem à entidade, e sim absorver regras que NÃO pertencem à entidade.

Desta forma as regras da entidade ficam na entidade.

A seguir vou mostrar uma implementação básica do padrão em uma aplicação ASP .NET Core com EFCore usando a Clean Architecture no ambiente do .NET 7.0.

recursos usados:

Criando a solução e os projetos

Vamos criar uma solução chamada SpecificationPattern contendo 3 projetos :

  1. ApiDev - projeto ASP.NET Core Web API onde teremos o Controller;
     
  2. Core - projeto Class Library representando o nosso Domain e contém as entidades e interfaces e onde vamos implementar o padrão Specification;
     
  3. Data - projeto Class Library representando a Infraestrutura onde temos as referências do EF Core e a implementação do Repositorio Genérico.
    Neste projeto serão incluidas as referências aos seguintes pacotes Nuget do EF Core 7.0.1 :
    1- Microsoft.EntityFramework.SqlServer
    2- Microsoft.EntityFramework.Tools
    3- Microsoft.EntityFramework.Design

Vamos usar a abordagem da Clean Architecture simplificada onde o projeto Core representa o Domain e não possui nenhuma dependência.

O projeto ApiDev tem uma dependência e uma referência com o projeto Data e o projeto Data tem uma referência ao projeto Core.

Criando as entidades e interfaces do Domain

No projeto Core crie a pasta Entities e nesta pasta crie as classes Desenvolvedor e Endereco:

1- Desenvolvedor

public class Desenvolvedor
{
    public int Id { get; set; }
    public string Nome { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public int AnosExperiencia { get; set; }
    public decimal RendaEstimada { get; set; }
    public Endereco? Endereco { get; set; }
}

2- Endereco

public class Endereco
{
    public int Id { get; set; }
    public string Cidade { get; set; } = string.Empty;
    public string Localidade { get; set; }= string.Empty;
}

Implementando o padrão Speficication

A minha implementação do padrão Specification vai focar no básico e vai seguir o seguinte roteiro:

Assim vamos iniciar criando neste projeto a pasta Specifications onde vamos implementar o padrão Specification usando uma abordagem simplificada. Nesta pasta crie os seguintes artefatos :

1- Interface ISpecification<T>

public interface ISpecification<T>
{
    Expression<Func<T, bool>> Criteria { get; }
    List<Expression<Func<T, object>>> Includes { get; }
    Expression<Func<T, object>> OrderBy { get; }
    Expression<Func<T, object>> OrderByDescending { get; }
}

Nesta interface vamos definir propriedades para tratar com as seguintes especificações :

a- Especificações do filtro (predicate)
b- Especificações de classificação (OrderBy ou OrderByDescending)
c- Cláusula Agrupar por (Group by)
d- Especificações de dados relacionados (Include)


Temos aqui um definição de um contrato com algumas especificações que pode ser estendido e onde
eu estou focando apenas no principal.

2- Classe BaseSpecification

public class BaseSpecification<T> : ISpecification<T>
{
    public BaseSpecification(){}
    public BaseSpecification(Expression<Func<T, bool>> criteria)
    {
        Criteria = criteria;
    }
    public Expression<Func<T, bool>> Criteria { get; }
    public List<Expression<Func<T, object>>> Includes { get; } = new List<Expression<Func<T, object>>>();
    public Expression<Func<T, object>> OrderBy { get; private set; }
    public Expression<Func<T, object>> OrderByDescending { get; private set; }
    protected void AddInclude(Expression<Func<T, object>> includeExpression)
    {
        Includes.Add(includeExpression);
    }
    protected void AddOrderBy(Expression<Func<T, object>> orderByExpression)
    {
        OrderBy = orderByExpression;
    }
    protected void AddOrderByDescending(Expression<Func<T, object>> orderByDescExpression)
    {
        OrderByDescending = orderByDescExpression;
    }
}

Na classe concreta BaseSpecification<T> implementamos interface ISpecification<T>. Nesta classe destacamos que estamos definindo métodos onde estamos:

- Adicionando as expressões à propriedade Include
- Adicionando as expressões à propriedade OrderBy
- Adicionando as expressões à propriedade OrderByDescending


Note que também temos um construtor que aceita critérios que estamos usando Expressions
que são uma combinação de operandos (variáveis, literais, chamadas de método) e operadores que podem ser avaliados em um único valor.  As Expressions representam uma expressão lambda fortemente tipada como uma estrutura de dados na forma de uma árvore de expressão.

A seguir vamos definir duas especificações usando o padrão Specification criando as classes

1- DesenvolvedorExperienciaEnderecoSpec

using Core.Entities;
namespace Core.Specifications;
public class DesenvolvedorExperienciaEnderecoSpec : BaseSpecification<Desenvolvedor>
{
    public DesenvolvedorExperienciaEnderecoSpec(int anos) : base(x => x.AnosExperiencia > anos)
    {
        AddInclude(x => x.Endereco);
    }
}

Esta especificação retorna uma lista de desenvolvedores com experiência de N anos de experiencia ou superior junto com seus endereços.

Então, aqui estamos passando a query expression para Specification que é o construtor de BaseSpecification, que por sua vez irá adicioná-la à propriedade Criteria que havíamos criado anteriormente.

2- DesenvolvedorRendaEstimadaSpec

public class DesenvolvedorRendaEstimadaSpec : BaseSpecification<Desenvolvedor>
{
    public DesenvolvedorRendaEstimadaSpec(decimal renda) : base(x => x.RendaEstimada > renda)
    {
        AddOrderByDescending(x => x.RendaEstimada);
    }
}

Esta especificação retorna a lista de desenvolvedores na ordem decrescente de salário.

Percebeu que podemos criar quantas especificações precisarmos conforme os critérios definidos nas regras de negócio.

Agora, com nossas classes de especificação prontas, vamos continuar.

A seguir no projeto Core cria a pasta Interfaces e defina a interface IGenericRepository :

public interface IGenericRepository<T> where T : class
{
    Task<T> GetByIdAsync(int id);
    Task<List<T>> GetAllAsync();

   IEnumerable<T> FindWithSpecificationPattern(ISpecification<T>
        specification = null);
}

Estamos definindo um contrato para obter um desenvolvedor pelo seu id e uma lista de todos os desenvolvedores; e também estamos definindo o método FindWithSpecificationPattern() onde vamos aplicar o padrão specification definindo consultas passando como parâmetro os critérios das consultas.

Criando o mapeamento ORM e implementando o repositório

Vamos agora definir o mapeamento ORM e implementar o repositório genérico no projeto Data.

Crie a pasta Context e a seguir nesta pasta crie a classe AppDbContext:

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions options) : base(options)
    { }
    public DbSet<Desenvolvedor> Desenvolvedores { get; set; }
    public DbSet<Endereco> Enderecos { get; set; }
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Desenvolvedor>()
              .Property(x => x.Nome).HasMaxLength(80);
        modelBuilder.Entity<Desenvolvedor>()
             .Property(x => x.Email).HasMaxLength(150);
        modelBuilder.Entity<Desenvolvedor>()
             .Property(x => x.RendaEstimada).HasPrecision(10, 2);
        modelBuilder.Entity<Endereco>()
             .Property(x => x.Cidade).HasMaxLength(50);
        modelBuilder.Entity<Endereco>()
               .Property(x => x.Localidade).HasMaxLength(200);
    }
}

A seguir crie a pasta Repositories e nesta pasta cria a classe GenericRepository que implementa a interface IGenericRepository:

public class GenericRepository<T> : IGenericRepository<T> where T : class
{
    protected readonly AppDbContext _context;
    public GenericRepository(AppDbContext context)
    {
        _context = context;
    }
    public async Task<List<T>> GetAllAsync()
    {
        return await _context.Set<T>().ToListAsync();
    }
    public async Task<T> GetByIdAsync(int id)
    {
        return await _context.Set<T>().FindAsync(id);
    }
    public IEnumerable<T> FindWithSpecificationPattern(ISpecification<T> specification = null)
    {
        return SpecificationEvaluator<T>.GetQuery(_context.Set<T>().AsQueryable(), specification);
    }
}

A ideia por trás da implementação é criar classes de especificação separadas que possam retornar conjuntos de resultados específicos.  Cada uma dessas novas classes de especificação herdará da classe BaseSpecification.

Para concluir esta etapa vamos criar a classe SpecificationEvaluator<T>:

public class SpecificationEvaluator<TEntity> 
    where TEntity : class
{
    public static IQueryable<TEntity> GetQuery(
        IQueryable<TEntity> inputQuery,
        ISpecification<TEntity> spec)
    {
        var query = inputQuery;
        if (spec.Criteria != null)
        {
            query = query.Where(spec.Criteria);
        }
        if (spec.OrderBy != null)
        {
            query = query.OrderBy(spec.OrderBy);
        }
        if (spec.OrderByDescending != null)
        {
            query = query.OrderByDescending(spec.OrderByDescending);
        }
        query = spec.Includes.Aggregate(query, (current, include) => current.Include(include));
        return query;
    }
} 

Nesta classe podemos receber os parâmetros definidos como critério e montar a consulta desejada compondo com IQueryable.

Na próxima parte do artigo vamos definir o código do projeto ApiDev.

"Também não oprimirás o estrangeiro; pois vós conheceis o coração do estrangeiro, pois fostes estrangeiros na terra do Egito."
Êxodo 23:9

Referências:


José Carlos Macoratti