ASP.NET Core -  Minimal APIs com CQRS


  Hoje voltamos a abordar a implementação do padrão CQRS agora em uma minimal API.

Existem situações onde é necessário separar funções de leitura e escrita, principalmente em cenários complexos ou que demandam grande escalabilidade de recursos.

Imagine um site de comércio eletrônico onde os dados são lidos várias vezes por um cliente enquanto ele navega no site – no entanto, a gravação só começa quando o usuário adiciona um item ao carrinho. Nesse cenário, a leitura requer muito mais recursos do que a escrita. Nos modelos tradicionais, é difícil lidar com isso de forma simples, pois as funções de leitura e escrita são processadas da mesma forma.

Felizmente, existem soluções inteligentes que resolvem esses e outros problemas. Uma das mais conhecidas é o padrão de arquitetura CQRS e para ilustrar isso vamos mostrar a seguir como implementar este recurso em uma minimal API ASP.NET Core.

O que é CQRS ?

CQRS significa Comando e Segregação de Responsabilidade de Consulta, um padrão de arquitetura para desenvolvimento de software.

No CQRS, as operações de leitura de dados são separadas das operações de gravação ou atualização de dados. Essa separação ocorre na interface ou classe onde são mantidas as funções de leitura e escrita.

Algumas das vantagens de usar o CQRS são:

- Equipes separadas podem implementar as operações;
- As operações de escrita são muito menos utilizadas do que as operações de leitura  assim é possível dimensionar os recursos de acordo com a necessidade;
- Cada operação pode ter sua própria segurança de acordo com os requisitos;


O termo Command Query Separation (CQS), que deu origem ao CQRS, foi definido por Bertrand Meyer em seu livro Object-Oriented Software Construction. Nele, duas camadas bem definidas são separadas uma da outra:

Queries : As consultas apenas retornam um estado e não o alteram.
Commands :
Os comandos apenas alteram o estado.

Portanto, o CQRS (apresentado por Greg Young) é baseado no CQS, mas é mais detalhado.

Por que usar CQRS?

É comum encontrar em sistemas modernos e antigos padrões arquitetônicos tradicionais que utilizam o mesmo modelo de dados ou DTO para consultar e persistir/atualizar dados. Quando o sistema usa apenas um CRUD simples, isso pode ser uma ótima abordagem, mas conforme o sistema cresce e se torna complexo, pode se tornar um verdadeiro desastre.

Nesses cenários, a leitura e a gravação apresentam incompatibilidades entre si, como propriedades que precisam ser atualizadas, mas não devem ser retornadas nas consultas. Essa diferença pode levar à perda de dados e, na melhor das hipóteses, quebrar o projeto arquitetônico do aplicativo.

Portanto, o principal objetivo do CQRS é permitir que uma aplicação funcione corretamente utilizando diferentes modelos de dados, oferecendo flexibilidade em cenários que requerem um modelo complexo. Você tem a possibilidade de criar vários DTOs sem quebrar nenhum padrão de arquitetura ou perder nenhum dado no processo.

A seguir vamos iniciar a implementação do padrão CQRS na aplicação ASP.NET Core e para isso vamos usar os seguintes recursos:

Criando o projeto ASP.NET Core

Abra o VS 2022 e crie um novo projeto do tipo ASP.NET Core Web API chamado ApiCatalogo usando o .NET 7.0. e marcando a opção para não usar Controllers conforme mostrado na figura abaixo:

Se preferir criar o projeto na linha de comando : dotnet new web -o ApiCatalogo

A seguir vamos incluir no projeto os seguintes pacotes nugets:

 -  Microsoft.EntityFrameworkCore.Sqlite
 -  Microsoft.EntityFrameworkCore.Tools

No projeto vamos criar as pastas Entities e Context  e na pasta Entities vamos criar a classe Produto que representa o nosso modelo de domínio :

public class Produto
{
  
public int Id { get; set; }

   [StringLength(80, MinimumLength = 4)]
  
public string? Nome { get; set; }

   [StringLength(80, MinimumLength = 4)]
  
public string? Descricao { get; set; }

   [StringLength(80, MinimumLength = 4)]
  
public string? Categoria { get; set; }

   public bool Ativo { get; set; } = true;
  
   [Column(TypeName =
"decimal(10,2)")]
  
public decimal Preco { get; set; }

}

Na pasta Context vamos criar a classe AppDbContext que herda de DbContext:

using ApiCatalogo.Entities;
using
Microsoft.EntityFrameworkCore;

namespace ApiCatalogo.Context;

public class AppDbContext : DbContext
{
  
public DbSet<Produto> Produtos { get; set; }

  
protected override void OnConfiguring(DbContextOptionsBuilder options) =>
            options.UseSqlite(
"DataSource=produtosdb.db;Cache=Shared");
}

A seguir vamos registrar este serviço na classe Program :

...
builder.Services.AddDbContext<AppDbContext>();

...

Agora precisamos criar o banco de dados e a tabela e para isso vamos aplicar o Migrations do EF Core.

Aqui temos duas opções:

1- Executar os comandos abaixo em um terminal raiz do projeto usando a ferramenta dotnet ef:

-  dotnet ef migrations add Inicial
-  dotnet ef database update

2- Executar os seguintes comandos no Console do Gerenciador de Pacotes no Visual Studio :

-  Add-Migration Inicial
-  Update-Database

Agora que o aplicativo base e o banco de dados estão prontos, podemos aplicar o padrão CQRS para implementar os métodos CRUD, separando a consulta da persistência.

Mas para ajudar nessa implementação, existe um recurso muito importante chamado mediador. Confira abaixo o que o mediador faz.

O Padrão Mediador

O padrão mediador utiliza um conceito muito simples que cumpre perfeitamente seu papel: Fornecer uma classe mediadora para coordenar as interações entre diferentes objetos e assim reduzir o acoplamento e a dependência entre eles.

Em suma, o mediador faz uma ponte entre diferentes objetos, o que elimina a dependência entre eles, pois eles não se comunicam diretamente.

O diagrama abaixo demonstra como o mediador funciona, ligando indiretamente os objetos A, B e C.

A seguir podemos destacar os prós e contras em usar este padrão:

1- Prós: 

2- Contras :

Implementando o padrão mediador com MediatR

MediatR é uma biblioteca criada por Jimmy Bogard (também criador do AutoMapper) que auxilia na implementação do padrão Mediator.

Essa biblioteca fornece interfaces prontas que servem como uma classe mediadora para a comunicação entre os objetos, portanto, ao usar o MediatR, não precisamos implementar nenhuma dessas classes, basta usar os recursos disponíveis no MediatR.

Para adicionar o MediatR ao projeto, basta instalar os seguintes pacotes nugets no projeto:

Nota: O pacote MediatR.Extensions.Microsoft.DependencyInjection"  foi depreciado a partir da versão 11.1.0 e agora basta usar o pacote acima.

Neste exemplo eu não estou usando uma aplicação em camadas para focar apenas na implementação do padrão assim vamos criar uma pasta Resources no projeto e dentro desta vamos criar as pastas : Commands e Queries

A biblioteca MediatR fornece as classes necessárias para a implementação do padrão Mediator, incluindo as interfaces IRequest, IRequestHandler e IMediator.

a- A interface IRequest é implementada por classes que representam comandos e consultas.

b- A interface IRequestHandler<TRequest, TResponse> é implementada por classes que processam as solicitações de comandos e consultas específicas.

c- A classe Mediator implementa a interface IMediator e é responsável por receber as solicitações de comandos e consultas e encaminhá-las para os respectivos manipuladores (IRequestHandler), bem como gerenciar a resolução de dependências necessárias para a execução dos manipuladores.

A implementação do padrão Mediator usando a biblioteca MediatR geralmente envolve a criação de classes de comandos e consultas, bem como seus respectivos manipuladores, que são injetados no Mediator durante a configuração da aplicação.

Criando as consultas

Em seguida, o CQRS é utilizado através da implementação do padrão de consulta composto por dois objetos:

Query – Define os objetos a serem retornados.
Query Handler – Responsável por retornar objetos definidos pela classe que implementa o padrão de consulta.

Assim dentro da pasta Queries vamos criar a classe GetProdutoPorIdQuery :

using ApiCatalogo.Entities;
using
MediatR;

namespace ApiCatalogo.Resources.Queries;

public class GetProdutoPorIdQuery : IRequest<Produto>
{
  
public int Id { get; set; }
}
 

Aqui definimos uma classe que retorna um objeto Produto e por meio dela enviamos uma solicitação ao mediador que executará a consulta.

A seguir vamos criar a classe chamada GetProdutoPorIdQueryHandler  que implementa a interface IRequestHandler<TRequest, TResponse> para tratar a requisição de busca de um produto pelo ID.

using ApiCatalogo.Context;
using
ApiCatalogo.Entities;
using
MediatR;
using
Microsoft.EntityFrameworkCore;

namespace ApiCatalogo.Resources.Queries;

public class GetProdutoPorIdQueryHandler : IRequestHandler<GetProdutoPorIdQuery, Produto>
{
  
private readonly AppDbContext _context;
  
public GetProdutoPorIdQueryHandler(AppDbContext context)
   {
     _context = context;
   }

   //método Handle usando o formato simplificado
  
//public async Task<Produto> Handle(GetProdutoPorIdQuery request,
   // CancellationToken cancellationToken) =>
   // await _context.Produtos.FirstOrDefaultAsync(x => x.Id == request.Id,
   // cancellationToken);

   public async Task<Produto> Handle(GetProdutoPorIdQuery request,
                                CancellationToken cancellationToken)
   {
       
var produto = await _context.Produtos.FirstOrDefaultAsync(x =>
                                  x.Id == request.Id, cancellationToken);
        
return produto;
   }
}

Na implementação acima, o método Handle recebe a consulta GetProdutoPorIdQuery representada pelo objeto request e um token de cancelamento representado pelo parâmetro cancellationToken. O método utiliza o contexto do banco de dados _context para obter o produto pelo Id, usando o método FirstOrDefaultAsync.

O resultado da consulta é retornado diretamente pelo método Handle. Como o método Handle está sendo executado de forma assíncrona, é utilizado o operador await para aguardar a conclusão da operação de consulta antes de retornar o resultado.

Agora vamos criar a consulta e o handler para retornar todos os produtos :

1- GetProdutosQuery

using ApiCatalogo.Entities;
using
MediatR;

namespace ApiCatalogo.Resources.Queries;

public class GetProdutosQuery : IRequest<IEnumerable<Produto>>
{
}

2- GetProdutosQueryHandler

using MediatR;
using
Microsoft.EntityFrameworkCore;

namespace ApiCatalogo.Resources.Queries;

public class GetProdutosQueryHandler : IRequestHandler<GetProdutosQuery, IEnumerable<Produto>>
{
 
private readonly AppDbContext _context;
 
public GetProdutosQueryHandler(AppDbContext context)
  {
     _context = context;
  }

  public async Task<IEnumerable<Produto>> Handle(GetProdutosQuery request,
                                   CancellationToken cancellationToken) =>
                                       
await _context.Produtos.ToListAsync();
}

Criando os Comandos

Agora vamos implementar os comandos  onde o CQRS é utilizado através da implementação do padrão Command composto por dois objetos:

Command – Define quais métodos devem ser executados.
Command Handler – Responsável por executar os métodos definidos pelas classes Command.

Os comandos executarão os métodos de persistência  Create/Update/Delete para criar , atualizar e deletar um produto.

Todas as classes de comando implementam a interface IRequest<T>, onde o tipo de dado a ser retornado é especificado. Dessa forma, o MediatR sabe qual objeto é invocado durante uma solicitação.

Então, dentro da pasta “Commands” vamos criar as classes de comando e as classes de comando handler.

Para facilitar a implementação e não ficar repetindo código vou criar uma classe abstrata ProdutoCommand que as demais classes de comando irão herdar:

using ApiCatalogo.Entities;
using
MediatR;

namespace ApiCatalogo.Resources.Commands;

public abstract class ProdutoCommand : IRequest<Produto>
{
  
public string? Nome { get; set; }
  
public string? Descricao { get; set; }
  
public string? Categoria { get; set; }
  
public bool Ativo { get; set; } = true;
  
public decimal Preco { get; set; }
}

1- CreateProdutoCommand

using ApiCatalogo.Entities;
using
MediatR;

namespace ApiCatalogo.Resources.Commands;

public class CreateProdutoCommand : ProdutoCommand
{  
}

2- CreateProdutoCommanHandler

using ApiCatalogo.Context;
using
ApiCatalogo.Entities;
using
MediatR;

namespace ApiCatalogo.Resources.Commands;

public class CreateProdutoCommandHandler : IRequestHandler<CreateProdutoCommand, Produto>
{
  
private readonly AppDbContext _dbContext;
  
public CreateProdutoCommandHandler(AppDbContext dbContext)
   {
      _dbContext = dbContext;
   }

   public async Task<Produto> Handle(CreateProdutoCommand request, CancellationToken
                                                                   cancellationToken)
   {
    
 var produto = new Produto
      {
          Nome = request.Nome,
          Descricao = request.Descricao,
          Categoria = request.Categoria,
          Preco = request.Preco,
      };
      _dbContext.Produtos.Add(produto);
     
await _dbContext.SaveChangesAsync();
     
return produto;
    }
}

3- UpdateProdutoCommand

namespace ApiCatalogo.Resources.Commands;

public class UpdateProdutoCommand : ProdutoCommand
{
  
public int Id { get; set; }
}

4- UpdateProdutoCommandHandler

using ApiCatalogo.Context;
using
ApiCatalogo.Entities;
using
MediatR;

namespace ApiCatalogo.Resources.Commands;

public class UpdateProdutoCommandHandler : IRequestHandler<UpdateProdutoCommand, Produto>
{
  
private readonly AppDbContext _dbContext;
  
public UpdateProdutoCommandHandler(AppDbContext dbContext)
   {
      _dbContext = dbContext;
   }

  
public async Task<Produto> Handle(UpdateProdutoCommand request, CancellationToken cancellationToken)
   {
      
var produto = _dbContext.Produtos.FirstOrDefault(p => p.Id == request.Id);

      
if (produto is null)
           
return default;

       produto.Nome = request.Nome;
       produto.Descricao = request.Descricao;
       produto.Categoria = request.Categoria;
       produto.Preco = request.Preco;

      
await _dbContext.SaveChangesAsync();
      
return produto;
    }
}

5- DeleteProdutoCommand

using ApiCatalogo.Entities;
using
MediatR;

namespace ApiCatalogo.Resources.Commands;

public class DeleteProdutoCommand : IRequest<Produto>
{
  
public int Id { get; set; }
}

6- DeleteProdutoCommandHandler

using ApiCatalogo.Context;
using
ApiCatalogo.Entities;
using
MediatR;

namespace ApiCatalogo.Resources.Commands;

public class DeleteProdutoCommandHandler : IRequestHandler<DeleteProdutoCommand, Produto>
{
  
private readonly AppDbContext _dbContext;
  
public DeleteProdutoCommandHandler(AppDbContext dbContext)
   {
     _dbContext = dbContext;
   }

   public async Task<Produto> Handle(DeleteProdutoCommand request, CancellationToken
                                                                  cancellationToken)
   {
     
var produto = _dbContext.Produtos.FirstOrDefault(p => p.Id == request.Id);

     
if (produto is null)
          
return default;

      _dbContext.Remove(produto);
      
await _dbContext.SaveChangesAsync();
      
return produto;
   }
}

Com isso criamos as consultas e os comandos e agora precisamos registrar o serviço do MediatR na classe Program:

...

builder.Services.AddDbContext<AppDbContext>();

builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));

...
 

Criando os endpoints na Minimal API

Vamos criar os endpoints na classe Program para realizar as consultas e o CRUD básico usando os comandos e consultas implementados pelo padrão CQRS:

1- Retorna todos os produtos

app.MapGet("produto/getall", async (IMediator _mediator) =>
{
  
try
   {
    
var command = new GetProdutosQuery();
    
var response = await _mediator.Send(command);
    
return response is not null ? Results.Ok(response) : Results.NotFound();
   }
   
catch (Exception ex)
   {
   
return Results.BadRequest(ex.Message);
   }
});

Este código é usado para criar um endpoint GET para uma API chamada "produto/getall";

O endpoint permite que os usuários possam recuperar todos os produtos cadastrados no sistema. A lógica para isso está contida no bloco de código dentro da função lambda que é passada para o método MapGet. O método MapGet é um método de extensão do objeto IEndpointRouteBuilder e é usado para mapear um endpoint GET para uma determinada rota.

A primeira coisa que o endpoint faz é criar uma instância da classe GetProdutosQuery. Esta classe é uma classe de consulta (query) que é usada para solicitar uma lista de produtos. Em seguida, a instância da classe é passada para o objeto Mediator, que é responsável por gerenciar todas as solicitações de comando e consulta na aplicação. O método Send do objeto Mediator é usado para enviar a solicitação de consulta para o manipulador de consulta correspondente.

Se a solicitação for bem-sucedida, o manipulador de consulta retornará uma lista de produtos, que é então usada para gerar uma resposta HTTP 200 OK usando o método estático Results.Ok. Se a solicitação não retornar nenhum resultado, o endpoint retornará uma resposta HTTP 404 Not Found usando o método Results.NotFound.

Se ocorrer uma exceção durante a execução do endpoint, a mensagem de erro da exceção será capturada e retornada ao cliente como uma resposta HTTP 400 Bad Request usando o método Results.BadRequest.

2- Retorna um produto pelo Id

app.MapGet("produto/getbyid", async (IMediator _mediator, int id) =>
{
  
try
   {
    
var command = new GetProdutoPorIdQuery() { Id = id };
    
var response = await _mediator.Send(command);
    
return response is not null ? Results.Ok(response) : Results.NotFound();
   }
  
catch (Exception ex)
   {
     
return Results.BadRequest(ex.Message);
   }
});

Aqui a rota do endpoint contém um parâmetro {id}, que é usado para passar o valor do identificador na URL.

O primeiro parâmetro da função lambda é um objeto IMediator, que é usado para enviar a solicitação de consulta para o manipulador de consulta correspondente. O segundo parâmetro é o valor do identificador que é passado na URL.

Estou usando o bloco try-catch para tratar os erros nos endpoints mas existe uma alternativa mais elegante e eficaz que seria o uso de middleware de tratamento de erros. O middleware de tratamento de erros é um componente da pipeline de execução da aplicação que é responsável por lidar com exceções que ocorrem durante o processamento de solicitações.

O middleware de tratamento de erros é adicionado à pipeline de execução da aplicação usando o método UseExceptionHandler. Este método permite que a aplicação defina um manipulador de exceções global que pode ser usado para capturar exceções em qualquer lugar da pipeline de execução da aplicação.

Quando ocorre uma exceção, o middleware de tratamento de erros captura a exceção e executa o manipulador de exceções global definido pela aplicação. O manipulador de exceções global pode ser usado para personalizar a resposta HTTP de erro que é enviada ao cliente.

O uso do middleware de tratamento de erros tem várias vantagens em relação ao bloco try-catch em endpoints de API.

  1. Primeiro, ele pode ser usado em toda a pipeline de execução da aplicação, garantindo que todas as exceções sejam tratadas de forma consistente em todos os endpoints.
  2. Em segundo lugar, ele permite que a aplicação defina uma resposta HTTP de erro personalizada para cada tipo de exceção que pode ocorrer, tornando a aplicação mais robusta e fácil de depurar.
  3. E, finalmente, o uso do middleware de tratamento de erros pode ajudar a separar a lógica de tratamento de erros da lógica de negócios do endpoint, tornando o código mais limpo e mais fácil de manter.

Um exemplo básico de implementação seria o seguinte:

app.UseExceptionHandler(options =>
{
    options.Run(
async context =>
    {

      // Obter a exceção que ocorreu
     
var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
     
// Definir a resposta HTTP de erro
      context.Response.StatusCode = 500;
      context.Response.ContentType =
"text/plain";
     
await context.Response.WriteAsync("Ocorreu um erro na aplicação...");
    });
});

3- Criar um novo produto

app.MapPost("produto/create", async (IMediator _mediator, Produto produto) =>
{
  
try
   {
    
var command = new CreateProdutoCommand()
     {
       Nome = produto.Nome,
       Descricao = produto.Descricao,
       Categoria = produto.Categoria,
       Preco = produto.Preco,
       Ativo = produto.Ativo,
      };
     
var response = await _mediator.Send(command);
     
return response is not null ? Results.Ok(response) : Results.NotFound();
    }
   
catch (Exception ex)
    {
      
return Results.BadRequest(ex.Message);
    }
});

4-  Atualizar um produto

app.MapPut("produto/update", async (IMediator _mediator, Produto produto) =>
{
  
try
   {
    
var command = new UpdateProdutoCommand()
     {
        Id = produto.Id,
        Nome = produto.Nome,
        Descricao = produto.Descricao,
        Categoria = produto.Categoria,
        Preco = produto.Preco,
        Ativo = produto.Ativo,
      };
     
var response = await _mediator.Send(command);
     
return response is not null ? Results.Ok(response) : Results.NotFound();
    }
   
catch (Exception ex)
    {
      
return Results.BadRequest(ex.Message);
    }
});

5- Deletar um produto

app.MapDelete("produto/delete", async (IMediator _mediator, int id) =>
{
  
try
   {
     
var command = new DeleteProdutoCommand() { Id = id };
     
var response = await _mediator.Send(command);
     
return response is not null ? Results.Ok(response) : Results.NotFound();
    }
   
catch (Exception ex)
    {
      
return Results.BadRequest(ex.Message);
    }
});

Executando o projeto terermos os endpoints exibidos na interface do Swagger:

Vamos iniciar criando alguns produtos usando o endpoint POST /produto/create:

1- Criar um produto POST /produto/create

Resultado:

Vamos repetir este procedimento incluindo mais dois produtos.

2- Consultando os produtos

Agora vamos obter todos os produtos cadastrados usando a consulta definida no endpoint GET /produto/getall

E assim podemos testar os demais endpoints.

O CQRS é um padrão de desenvolvimento que traz muitas vantagens, como a possibilidade de equipes separadas trabalharem na camada de leitura e persistência e também poder escalar os recursos do banco de dados conforme a necessidade.

Entender como o CQRS funciona e como implementá-lo em uma aplicação permite que você se saia muito bem quando surgir a necessidade de utilizá-lo em algum projeto.


Pegue o projeto aqui : ApiCatalogo.zip ...

"Esta é uma palavra fiel, e digna de toda a aceitação, que Cristo Jesus veio ao mundo, para salvar os pecadores, dos quais eu sou o principal."
1 Timóteo 1:15

Referências:


José Carlos Macoratti