Unit of Work - Implementação usando um middleware


  Hoje voltamos a abordar o padrão Unit Of Work mostrando uma implementação do padrão usando um middleware.

O padrão Unit of Work é útil porque centraliza o gerenciamento de persistência de dados e transações em um aplicativo. Esse padrão é particularmente benéfico ao trabalhar com aplicativos complexos em que várias operações precisam ser executadas em um armazenamento de dados, como um banco de dados.

Ao empregar o padrão Unit of Work, você pode garantir que todas as alterações sejam executadas em uma única transação, o que simplifica o tratamento de erros e ajuda a manter a consistência e a integridade dos dados.

Padrão Unit of Work

Para mostrar a implementação do padrão vamos usar um cenário simplificado onde temos as classes Pedido , Produto e Usuario em uma aplicação ASP.NET Core onde temos os seguintes requisitos para quando o usuário faz um pedido:

1- Cria um novo pedido com produtos selecionados
2- Fazer o pagamento
3- Atualizar o invenraio para diminuir a quantidade
4- Despachar o pedido para serviço que vai enviar o pedido

Para isso definimos os repositórios PedidoRepository, ProdutoRepository e UsuarioRepository e a seguir temos a definição de um serviço que processa o pedido:

public class PedidoService
{
    private readonly PedidoRepository _orderRepository;
    private readonly ProdutoRepository _productRepository;
    private readonly UsuarioRepository _userRepository;
    public PedidoService(PedidoRepository pedidoRepository, ProdutoRepository 
                               produtoRepository, UsuarioRepository usuarioRepository)
    {
        _pedidoRepository = pedidoRepository;
        _produtoRepository = produtoRepository;
        _usuarioRepository = usuarioRepository;
    }
    public async Task ProcessaPedidoAsync(Pedido pedido, List<Produto> 
                                                              produtos, Usuario usuario)
    {
        // Cria um novo pedido
        _pedidoRepository.AddPedido(pedido);
        await _pedidoRepository.SaveChangesAsync();
        // Atualiza o inventario
        foreach (var produto in produtos)
        {
            _produtoRepository.DiminuiQuantidade(produto.Id, produto.Quantidade);
            await _produtoRepository.SaveChangesAsync();
        }
        // Atualiza a conta do usuário
        _usuarioRepository.ProcessaPagamento(usuario.Id, pedido.ValorTotal);
        await _usuarioRepository.SaveChangesAsync();
    }
}

Neste cenário podemos ter a seguinte ocorrência.

Vamos supor que em um pedido temos cinco produtos e que no processamento obtivemos uma exceção no segundo produto ?

Nesta abordagem, de alguma forma salvamos o pedido e diminuímos a quantidade do primeiro produto, mas não atualizamos o saldo da conta do usuário pois ocorreu uma exceção.

Portanto, temos um pedido com um produto que não está em estoque e não existe uma cobrança na conta do usuário.

Em um comércio eletrônico isso pode se tornar um pesadelo e uma fonte de prejuízos.

Para resolver isso vamos usar o padrão Unit of Work que vai centralizar o gerenciamento da persistência dos dados e transações na aplicação.

Para isso fazemos assim:

public class PedidoService
{
    private readonly PedidoRepository _orderRepository;
    private readonly ProdutoRepository _productRepository;
    private readonly UsuarioRepository _userRepository;
    private readonly IUnitOfWork _unitOfWork;
    public PedidoService(PedidoRepository pedidoRepository, ProdutoRepository produtoRepository,
                                              UsuarioRepository usuarioRepository, IUnitOfWork unitOfWork)
    {
        _pedidoRepository = pedidoRepository;
        _produtoRepository = produtoRepository;
        _usuarioRepository = usuarioRepository;
         _unitOfWork = unitOfWork;
    }
    public async Task ProcessaPedidoAsync(Pedido pedido, List<Produto> produtos, Usuario usuario)
    {
        // Cria um novo pedido
        _pedidoRepository.AddPedido(pedido);
        await _pedidoRepository.SaveChangesAsync();
        // Atualiza o inventario
        foreach (var produto in produtos)
        {
            _produtoRepository.DiminuiQuantidade(produto.Id, produto.Quantidade);
            await _produtoRepository.SaveChangesAsync();
        }
        // Atualiza a conta do usuário
        _usuarioRepository.ProcessaPagamento(usuario.Id, pedido.ValorTotal);
        await _usuarioRepository.SaveChangesAsync();
         // Salva todas as alterações
        _unitOfWork.SaveChangesAsync();
    }
}

Assim, mesmo que tenhamos um problema no meio do loop, ainda podemos reverter as alterações e a conta do usuário não será cobrada e o produto ainda estará em estoque.

Este é um exemplo muito simplificado, mas mostra o poder do padrão Unit of Work.

Se formos rigorosos e  dermos uma olhada no código acima, podemos ver que temos uma dependência de IUnitOfWork em nosso PedidoService.

Isso não é bom, porque não há necessidade de o serviço saber tais detalhes e provavelmente, se você estiver em uma aplicação Web API , poderia usar um middleware para lidar com o padrão Unit of Work

Usando Middleware

Então vamos ir um pouco além deste cenário mais tradicional colocando os middlewares na jogada.

Um middleware é um software que fica no pipeline de processamento entre o servidor Web e seu aplicativo. Os componentes de middleware podem lidar com vários aspectos do processamento de requests, como autenticação, cache, roteamento e registro. Eles podem processar um request e passá-lo para o próximo componente de middleware no pipeline ou gerar uma resposta e causar um curto-circuito no pipeline.

Para implementar o padrão Unit Of Work usando um middleware podemos fazer o seguinte:

Primeiro, vamos definir a interface IUnitOfWork que representa a unidade de trabalho:

public interface IUnitOfWork
{
    Task SaveChangesAsync();
    void Rollback();
}

Em seguida, vamos implementar a classe UnitOfWork que encapsula a lógica de transações e persistência:

public class UnitOfWork : IUnitOfWork
{
    private readonly DbContext _dbContext;
    public UnitOfWork(DbContext dbContext)
    {
        _dbContext = dbContext;
    }
    public async Task SaveChangesAsync()
    {
        await _dbContext.SaveChangesAsync();
    }
    public void Rollback()
    {
        // Desfaz todas as alterações no contexto do banco de dados
        foreach (var entry in _dbContext.ChangeTracker.Entries())
        {
            switch (entry.State)
            {
                case EntityState.Added:
                    entry.State = EntityState.Detached;
                    break;
                case EntityState.Modified:
                case EntityState.Deleted:
                    entry.Reload();
                    break;
            }
        }
    }
}

Em seguida, vamos criar um middleware personalizado chamado UnitOfWorkMiddleware que será responsável por iniciar e concluir a unidade de trabalho:

public class UnitOfWorkMiddleware
{
    private readonly RequestDelegate _next;
    public UnitOfWorkMiddleware(RequestDelegate next)
    {
        _next = next;
    }
    public async Task InvokeAsync(HttpContext context, IUnitOfWork unitOfWork)
    {
        try
        {
            await _next(context);
            await unitOfWork.SaveChangesAsync();
        }
        catch (Exception)
        {
            unitOfWork.Rollback();
            throw;
        }
    }
}

A seguir basta registrar o middleware na classe Program:


app.UseMiddleware<UnitOfWorkMiddleware>();

 

Com essa implementação, o UnitOfWorkMiddleware será executado para cada solicitação e garantirá que todas as alterações feitas nos repositórios sejam confirmadas no banco de dados apenas se não ocorrerem exceções. Caso ocorra uma exceção, as alterações serão revertidas.

Lembrando que este é apenas um exemplo simplificado de implementação do padrão Unit of Work usando um middleware em uma aplicação ASP.NET Core. Dependendo do seu cenário específico, pode ser necessário adaptar essa implementação às suas necessidades.

Usando a interface IMiddleware

Outra abordagem para fazer a mesma implementação é usar a interface IMiddleware. Essa interface tem um único método InvokeAsync que será invocado a cada solicitação.

O método usa um RequestDelegate e um HttpContext como parâmetros, onde RequestDelegate é um delegate que será invocado para continuar o pipeline e o HttpContext contém todas as informações sobre a solicitação e a resposta.

Nesta abordagem teremos:

A interface IUnitOfWork que representa a unidade de trabalho:

public interface IUnitOfWork
{
    Task SaveChangesAsync();
    void Rollback();
}

A implementação da classe UnitOfWork que encapsula a lógica de transações e persistência:

public class UnitOfWork : IUnitOfWork
{
    private readonly DbContext _dbContext;
    private DbContextTransaction _transaction;
    public UnitOfWork(DbContext dbContext)
    {
        _dbContext = dbContext;
    }
    public async Task SaveChangesAsync()
    {
        await _dbContext.SaveChangesAsync();
        _transaction?.Commit();
    }
    public void Rollback()
    {
        _transaction?.Rollback();
    }
    public void BeginTransaction()
    {
        _transaction = _dbContext.Database.BeginTransaction();
    }
}

E a implementação da classe UnitOfWorkMiddleware que implementa a interface IMiddleware e será responsável por iniciar e concluir a unidade de trabalho:

public class UnitOfWorkMiddleware : IMiddleware
{
    private readonly IUnitOfWork _unitOfWork;
    public UnitOfWorkMiddleware(IUnitOfWork unitOfWork)
    {
        _unitOfWork = unitOfWork;
    }
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        try
        {
            _unitOfWork.BeginTransaction();
            await next(context);
            await _unitOfWork.SaveChangesAsync();
        }
        catch (Exception)
        {
            _unitOfWork.Rollback();
            throw;
        }
    }
}

Esse método InvokeAsync é invocado sempre que uma solicitação é feita ao nosso aplicativo. Então, depois que tudo estiver pronto (para que nosso código do Controller seja executado), podemos enviar as alterações para o banco de dados.

Se algo deu errado, não confirmamos as alterações, e, com isso, temos uma transação atômica que terá sucesso ou falhará.

Se preferir usar um logger pode registrar o log de auditoria o que é uma boa prática:

public class UnitOfWorkMiddleware : IMiddleware
{
    private readonly IUnitOfWork _unitOfWork;
    private readonly ILogger<UnitOfWorkMiddleware> _logger;
    public UnitOfWorkMiddleware(IUnitOfWork unitOfWork, ILogger<UnitOfWorkMiddleware> logger)
    {
        _unitOfWork = unitOfWork;
        _logger = logger;
    }
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        try
        {
            _unitOfWork.BeginTransaction();
            await next(context);
            await _unitOfWork.SaveChangesAsync();
        }
        catch (Exception)
        {
            _logger.LogError("An error occurred while saving the changes to the database.");
            _unitOfWork.Rollback();
             throw;
        }
    }
}

A seguir vamos registar o middleware na classe Program:

...
  builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
  builder.Services.AddScoped<UnitOfWorkMiddleware>();
...
...
   app.UseMiddleware<UnitOfWorkMiddleware>();
...

Com essa implementação, o UnitOfWorkMiddleware será executado para cada solicitação e garantirá que todas as alterações feitas nos repositórios sejam confirmadas no banco de dados apenas se não ocorrerem exceções. Caso ocorra uma exceção, as alterações serão revertidas.

Desta forma, com o padrão Unit of Work, bem como o uso de middleware, podemos garantir que todas as alterações no banco de dados sejam atômicas. Separamos todas as coisas necessárias onde elas pertenciam. Nossos serviços de domínio não precisam saber nada sobre isso.

Mas ainda existe um problema:

O serviço PedidoService ainda tem muitas responsabilidade e sabe de tudo. É aqui que entram os Eventos de Domínio que é um assunto para outro artigo.

E estamos conversados.

"Porquanto não há diferença entre judeu e grego; porque um mesmo é o Senhor de todos, rico para com todos os que o invocam."
Romanos 10:12

Referências:


José Carlos Macoratti