Unit of Work

Coordenando Transações e Persistências

Contexto e Problema Identificado

Analisando a API atual, observa-se a existência da camada de infraestrutura e dos repositórios, como o ProductRepository, que atualmente possui apenas operações de leitura por ID. Para complementar o CRUD, será implementado um método Create para criar novos produtos.

Implementação Inicial do Método Create

No ProductRepository, adiciona-se o seguinte método:

public async Task CreateAsync(Product product, CancellationToken cancellationToken = default)
{
    await context.Products.AddAsync(product, cancellationToken);    
    await context.SaveChangesAsync(cancellationToken);
}

Sua assinatura correspondente na interface IProductRepository:

Task CreateAsync(Product product, CancellationToken cancellationToken = default);

Esta implementação segue o padrão estabelecido anteriormente, mas apresenta um ponto crítico de atenção.

O Problema do SaveChangesAsync em Métodos Isolados

A chamada explícita de SaveChangesAsync() dentro do método CreateAsync representa um problema arquitetural significativo.

Compreendendo o Ciclo de Vida do DbContext

Para entender completamente o problema, é fundamental compreender como o DbContext funciona no contexto de uma requisição HTTP:

  • Instância por requisição: Para cada requisição aberta, é criada uma instância única do DbContext

  • Escopo de vida: Esta instância persiste durante toda a duração da requisição

  • Compartilhamento: O mesmo DbContext é compartilhado entre todos os repositórios utilizados durante a requisição

Consequências da Persistência Precoce

Quando SaveChangesAsync() é executado em um repositório específico (por exemplo, no repositório de produtos), todas as entidades rastreadas por qualquer repositório que utilize o mesmo DbContext são afetadas.

Exemplo Prático:

  1. Um repositório de produto adiciona um novo produto

  2. Um repositório de categoria adiciona uma nova categoria

  3. Um repositório de usuário atualiza informações do usuário

  4. Se o SaveChangesAsync() for chamado no repositório de produto, todas as três operações serão persistidas no banco de dados simultaneamente

Analogia para melhor compreensão

Imagine que o DbContext é como uma "lista de compras" compartilhada entre vários departamentos de uma loja:

  • Departamento de Eletrônicos (ProductRepository) adiciona um smartphone

  • Departamento de Móveis (CategoryRepository) adiciona uma cadeira

  • Departamento de Roupas (UserRepository) adiciona uma camiseta

Se o funcionário do departamento de Eletrônicos decidir "finalizar a compra" (SaveChangesAsync) imediatamente após adicionar o smartphone, todos os itens dos outros departamentos também serão comprados, mesmo que os outros departamentos ainda estejam escolhendo produtos.

O ideal é que todos os departamentos coloquem seus itens na lista primeiro, e somente no final alguém finalize a compra de todos os itens de uma vez.

Impacto no Desempenho

Executar SaveChangesAsync múltiplas vezes durante uma única requisição resulta em múltiplas viagens ao banco de dados ("descendo ao banco" múltiplas vezes), o que impacta significativamente o desempenho devido à latência de rede e sobrecarga de transações.

Solução: Padrão Unit of Work

Reestruturando o Repositório

A solução envolve remover a chamada explícita de SaveChangesAsync dos métodos do repositório, transferindo a responsabilidade de persistência para um componente coordenador.

Repositório modificado:

Agora o método apenas adiciona a entidade ao contexto em memória, sem persistir no banco de dados. Esta abordagem é aplicada a todas as operações do CRUD (Create, Read, Update, Delete).

Implementando o Caso de Uso Create

Cria-se um novo diretório Create no mesmo nível de GetById dentro de UseCases/Products, contendo os arquivos Command, Handler e Response.

Response:

Command:

Handler:

A injeção de IUnitOfWork permite que o handler controle quando as mudanças devem ser persistidas, separando a lógica de negócio da mecânica de persistência.

Definindo a Abstração Unit of Work

Interface IUnitOfWork

Cria-se a interface IUnitOfWork no projeto Domain, dentro de Abstractions:

Observação sobre Rollback: Em cenários típicos de aplicações web, não é necessário implementar explicitamente métodos de rollback. Quando o DbContext sai do escopo da requisição (seja por conclusão bem-sucedida ou por exceção), todas as mudanças não confirmadas são automaticamente descartadas, não persistindo nenhuma informação indesejada no banco de dados.

Escopos de Vida de Serviço no .NET

Tabela comparativa de escopos de vida
Escopo
Descrição
Tempo de Vida
Casos de Uso Típicos

Transient (AddTransient)

Nova instância criada a cada solicitação

Muito curto (por solicitação)

Serviços stateless, serviços leves, factories

Scoped (AddScoped)

Mesma instância por requisição

Duração da requisição HTTP

DbContext, UnitOfWork, repositórios

Singleton (AddSingleton)

Única instância por aplicação

Vida da aplicação

Caches, configurações, serviços de log

Explicação detalhada:

  • Transient: Ideal para serviços que não mantêm estado entre chamadas ou têm custo de criação baixo

  • Scoped: Padrão para serviços que precisam compartilhar estado durante uma requisição, como transações de banco de dados

  • Singleton: Usado para serviços que são caros para criar ou precisam compartilhar estado global

No contexto do Unit of Work, o escopo Scoped é o mais apropriado, garantindo que todas as operações durante uma requisição compartilhem a mesma transação.

Implementação Concreta do UnitOfWork

A implementação concreta reside na camada de infraestrutura, no diretório de dados ao lado de AppDbContext:

Configuração de Dependências

Registro no Container de DI

A configuração de dependências é realizada na classe DependencyInjection da infraestrutura:

Justificativa dos escopos: Ambos IUnitOfWork e IProductRepository são registrados como Scoped para garantir que compartilhem a mesma instância de DbContext durante uma requisição, permitindo o funcionamento correto do padrão Unit of Work.

Expondo o Endpoint na API

Mapeamento da Rota POST

Adiciona-se o mapeamento para criação de produtos na API:

Testando a Implementação

Execução da Aplicação

Inicia-se a aplicação e o banco de dados:

Teste no Postman

Requisição POST:

Resposta (200 OK):

Verificação com GET:

Resposta (200 OK):

Boas Práticas e Considerações Adicionais

Padrões Recomendados

  1. Injeção via Construtor: Sempre preferir injeção via construtor sobre propriedades para melhor testabilidade

  2. Tratamento de Exceções: Implementar tratamento adequado para exceções de persistência

  3. Transações Explícitas: Para operações complexas, considerar o uso de transações explícitas

  4. Validações de Domínio: Executar validações antes do commit para evitar exceções do banco de dados

Exemplo Aprimorado do Handler

Conclusão

A implementação do padrão Unit of Work proporciona significativas melhorias arquiteturais:

  1. Coordenação Centralizada: Todas as operações de persistência são coordenadas por um único ponto

  2. Consistência Transacional: Garante que múltiplas operações sejam tratadas atomicamente

  3. Otimização de Performance: Reduz o número de viagens ao banco de dados

  4. Separação de Responsabilidades: Isola a lógica de persistência da lógica de negócio

  5. Testabilidade Aprimorada: Facilita a simulação de operações de persistência em testes

A abordagem apresentada segue os princípios da Arquitetura Limpa, mantendo as camadas de aplicação e domínio independentes dos detalhes de implementação de infraestrutura, enquanto fornece um mecanismo robusto para gerenciamento transacional em aplicações empresariais.

Atualizado