ASP.NET Core -
Minimal APIs com CQRS
![]() |
Hoje voltamos a abordar a implementação do padrão CQRS agora em uma minimal API. |
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)] [StringLength(80,
MinimumLength = 4)]
public
bool
Ativo { get;
set;
} = true; } |
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 // 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> |
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; 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>(); |
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); } }); |
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.
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: