Dapper Unit of Work e Repository Pattern

Implementar Unit of Work com Dapper é uma das primeiras tarefas de quem decide abrir mão de um ORM completo. O Dapper é um Micro ORM muito poderoso e que nos dá muita liberdade, porém com grandes poderes vêm grandes responsabilidades.

Neste artigo, vamos conferir como gerenciar a conexão, controlar transações e organizar o acesso a dados com os padrões Unit of Work e Repository em uma API ASP.NET Core.

O que é o Dapper

O Dapper é um Micro ORM criado pelo time do Stack Overflow com foco em performance. Hoje, ele segue como projeto open source no repositório oficial do Dapper no GitHub.

Ele se difere de outros ORMs por ser mais simples no seu escopo. Esse, aliás, é o motivo pelo qual ele consegue performar melhor.

Justamente por essa simplicidade, o Dapper não traz um Unit of Work pronto, e é isso que vamos construir aqui.

Na verdade, o Dapper apenas estende o IDbConnection com métodos que simplificam a leitura e a escrita no banco. Por baixo dos panos, ele roda sobre o ADO.NET, a camada de acesso a dados do próprio .NET.

A principal diferença entre ele e o Entity Framework é que o EF é mais completo e traz bem mais facilidades. No entanto, essas facilidades obviamente cobram um custo maior de performance.

Embora seja possível otimizar bastante as consultas com o EF, em alguns pontos eu prefiro usar o Dapper. Também gosto de mesclar os dois, usando o Dapper para leitura e o EF para escrita.

Quando o Dapper cuida também da escrita, o Unit of Work passa a ser essencial para manter a consistência.

Se quiser entender melhor essa comparação, leia o artigo Dapper ou Entity Framework Core.

Para usar o Dapper no seu projeto, basta instalar os pacotes abaixo. O segundo traz o SqlConnection, que usaremos para falar com o SQL Server.

dotnet add package Dapper
dotnet add package Microsoft.Data.SqlClient

Por que usar Unit of Work com Dapper

Com o Entity Framework, o DbContext já implementa os padrões Repository e Unit of Work. Segundo a documentação da Microsoft, ele é executado como uma unidade de trabalho quando chamamos o SaveChanges.

Com o Dapper, por outro lado, não existe nada parecido. Ou seja, abrir a conexão, iniciar a transação e decidir quando confirmar tudo fica sob a nossa responsabilidade.

Martin Fowler define a unidade de trabalho como um objeto que acompanha tudo o que você faz em uma transação de negócio e que pode afetar o banco. No fim, ele resolve o que precisa ser gravado.

Portanto, usar Unit of Work com Dapper significa recriar, de forma artesanal, esse controle que o EF nos entrega pronto.

Conexão e transação

A primeira grande responsabilidade ao montar um Unit of Work com Dapper é gerenciar a conexão com o banco. Enquanto no EF temos um DbContext, aqui o trabalho é mais artesanal.

Para este exemplo, vamos usar a classe abaixo. Se você ficar curioso sobre o motivo de ela ser selada, confira este artigo sobre classes seladas.

public sealed class DbSession : IDisposable
{
    private Guid _id;
    public IDbConnection Connection { get; }
    public IDbTransaction Transaction { get; set; }

    public DbSession()
    {
        _id = Guid.NewGuid();
        Connection = new SqlConnection(Settings.ConnectionString);
        Connection.Open();
    }

    public void Dispose() => Connection?.Dispose();
}

Nesta classe, temos uma conexão e uma transação, definidas pelo IDbConnection e pelo IDbTransaction, respectivamente.

Escolhi essas interfaces do System.Data em vez das classes concretas SqlConnection e SqlTransaction. Assim, os repositórios não dependem diretamente dos tipos do pacote do SQL Server, e apenas a DbSession conhece o provider.

Também adicionei um _id na sessão, apenas para acompanhar os testes. Você pode removê-lo, pois ele não tem efeito algum sobre o resto do código.

O IDbConnection gerencia a conexão com o banco, enquanto o IDbTransaction cuida das transações. Por enquanto, vamos deixar as transações para o Unit of Work com Dapper que criaremos adiante.

No construtor, sempre abrimos uma nova conexão com o banco por meio do método Connection.Open().

Para finalizar, temos o método Dispose, vindo da interface IDisposable, que roda no término da instância desta classe.

Implementar o IDisposable nos permite usar esta classe com o using do C#. Dessa forma, o término do objeto fica garantido ao fim do contexto.

Por fim, no método Dispose, fechamos a conexão chamando o Connection?.Dispose(). Note o ?, que verifica se o objeto Connection está null antes da chamada.

Close ou Dispose?

O objeto de conexão tem dois métodos similares, o Close e o Dispose. Ambos fecham a conexão com o banco, porém o Close mantém o objeto de conexão utilizável.

Em suma, se você pretende reabrir essa mesma conexão depois, a melhor escolha é o Close. Caso contrário, com o Dispose você precisa criar um novo objeto de conexão.

Vale lembrar que, segundo a documentação do SqlConnection na Microsoft, os dois são funcionalmente equivalentes para o pool. Com o pooling ativo, ambos devolvem a conexão física ao pool em vez de encerrá-la de fato.

Além disso, uma conexão que apenas sai de escopo não é fechada sozinha. Por isso, nunca esqueça de chamar um dos dois, ou de usar o using.

Implementando o Unit of Work com Dapper

O padrão unidade de trabalho prevê a centralização das transações e, em alguns casos, até dos repositórios em um único ponto.

No nosso Unit of Work com Dapper, o objetivo principal é garantir a transação entre todos os repositórios de forma uniforme. Em suma, ele é um objeto bem simples, que apenas confirma ou desfaz tudo o que aconteceu na transação.

Novamente, como trabalhamos com objetos de conexão e transação, é importante implementar o IDisposable. Assim, garantimos que nenhuma transação fique aberta.

public interface IUnitOfWork : IDisposable
{
    void BeginTransaction();
    void Commit();
    void Rollback();
}

Na interface, declaramos apenas três métodos. O BeginTransaction inicia a transação, o Commit confirma as mudanças e o Rollback desfaz tudo caso algo dê errado.

A implementação dessa interface só precisa de uma instância da DbSession. Com ela, criamos uma nova transação e fazemos o commit ou o rollback.

public sealed class UnitOfWork : IUnitOfWork
{
    private readonly DbSession _session;

    public UnitOfWork(DbSession session)
    {
        _session = session;
    }

    public void BeginTransaction()
    {
        _session.Transaction = _session.Connection.BeginTransaction();
    }

    public void Commit()
    {
        _session.Transaction.Commit();
        Dispose();
    }

    public void Rollback()
    {
        _session.Transaction.Rollback();
        Dispose();
    }

    public void Dispose()
    {
        _session.Transaction?.Dispose();
        // limpa a referência para que as próximas chamadas não usem uma transação descartada
        _session.Transaction = null;
    }
}

Repare que este Unit of Work com Dapper não conhece nenhum repositório, apenas a sessão compartilhada.

Em relação ao código original, acrescentei apenas a linha que limpa a referência da transação no Dispose. Sem ela, um repositório chamado depois do commit receberia uma transação já descartada.

Repository Pattern

O padrão repositório prevê um ponto único de acesso a dados para uma entidade. Frameworks como o EF Core, aliás, já trazem uma implementação dele.

Em suma, os repositórios em si não são o maior desafio de um Unit of Work com Dapper. O verdadeiro desafio está nas transações, principalmente entre repositórios diferentes.

A Microsoft, no guia de arquitetura de microsserviços .NET, lembra que repositórios não são obrigatórios. Ainda assim, eles ajudam a isolar a persistência e facilitam testes com dublês.

Para este exemplo, separei o repositório de notificações com dois métodos simples de leitura e escrita.

public class NotificationRepository
{
    private readonly DbSession _session;

    public NotificationRepository(DbSession session)
    {
        _session = session;
    }

    public IEnumerable<NotificationModel> Get()
    {
        return _session.Connection.Query<NotificationModel>(
            "SELECT * FROM [Notifications]",
            null,
            _session.Transaction);
    }

    public void Save(NotificationModel model)
    {
        _session.Connection.Execute(
            "INSERT INTO [Notifications] ([Id], [Title], [Url], [Date]) VALUES (@Id, @Title, @Url, @Date)",
            model,
            _session.Transaction);
    }
}

No original, o INSERT usava valores fixos. Aqui, passei o próprio model como parâmetro, e o Dapper associa cada propriedade ao @ de mesmo nome.

Note que, no final dos métodos, informamos o _session.Transaction. Isso faz o Dapper considerar a transação atual, caso ela exista.

Sendo este um contexto transacionado, o INSERT do método Save só vale de fato quando o Unit of Work do Dapper executa o Commit.

Para ver outras formas de organizar esse padrão, confira o artigo sobre Repository Pattern em aplicações .NET.

Por que o repositório precisa da transação no Unit of Work com Dapper

No ADO.NET, uma transação local fica presa à conexão que a criou. Então, todo comando executado nessa conexão precisa informar a mesma transação.

Na prática, se a conexão tiver uma transação pendente e o comando não a receber, o SqlClient lança uma InvalidOperationException. Por isso, o parâmetro _session.Transaction aparece em todas as chamadas.

Quando o Unit of Work não abriu transação, o valor fica null, e o Dapper executa o comando normalmente. Dessa forma, o mesmo repositório atende chamadas transacionadas e não transacionadas.

Injeção de dependência

Para prosseguir, precisamos primeiro resolver as dependências no ASP.NET Core que criamos. O ASP.NET Core faz isso muito bem com a sua injeção de dependência nativa.

services.AddScoped<DbSession>();
services.AddTransient<IUnitOfWork, UnitOfWork>();
services.AddTransient<NotificationRepository>();

Note que usamos AddScoped para a DbSession e AddTransient para o UnitOfWork e para o repositório.

Esse é o ponto que faz o Unit of Work com Dapper funcionar.

O ciclo de vida scoped cria uma única DbSession por requisição HTTP. Assim, o UnitOfWork e todos os repositórios daquela requisição compartilham a mesma conexão e a mesma transação.

Além disso, o próprio container chama o Dispose da DbSession no fim da requisição. Com isso, a conexão volta ao pool sem nenhum código extra.

Caso essa parte ainda pareça confusa, recomendo também a leitura do artigo sobre injeção de dependência.

Usando Unit of Work com Dapper nos controllers

Agora que temos tudo pronto, podemos finalizar a nossa API fazendo as devidas chamadas no controlador.

Chamadas não transacionadas

A primeira chamada, apenas um GET, não precisa de transação. Então, não precisamos do Unit of Work aqui, apenas do repositório com Dapper.

[HttpGet("")]
public IEnumerable<NotificationModel> Get(
    [FromServices] NotificationRepository repository)
{
    return repository.Get();
}

Chamadas transacionadas com Unit of Work com Dapper

A segunda chamada é uma inserção, e queremos poder revertê-la caso algo dê errado. Indo ainda mais a fundo, queremos revertê-la inclusive se algo falhar em outro repositório.

Desta forma, precisamos de uma instância do UnitOfWork e chamamos o BeginTransaction antes das chamadas ao repositório.

Isso cria uma transação no nível do controlador. Então, todas as chamadas ao banco que informam _session.Transaction ficam nesse escopo.

Como resultado, o Unit of Work com Dapper permite persistir com Commit ou reverter com Rollback tudo o que aconteceu nessa transação.

[HttpPost("")]
public IActionResult Post(
    [FromServices] IUnitOfWork unitOfWork,
    [FromServices] NotificationRepository repository)
{
    unitOfWork.BeginTransaction();
    repository.Save(new NotificationModel
    {
        Date = DateTime.Now,
        Id = Guid.NewGuid(),
        Title = "Test",
        Url = "Test"
    });
    unitOfWork.Commit();
    return Ok();
}

Tratando falhas com rollback

O exemplo acima mostra o caminho feliz. No entanto, o valor real do Unit of Work com Dapper aparece quando algo dá errado no meio do processo.

Imagine que, além da notificação, você grave um registro de auditoria em outro repositório. Se a segunda gravação falhar, a primeira também precisa ser desfeita.

[HttpPost("with-audit")]
public IActionResult PostWithAudit(
    [FromServices] IUnitOfWork unitOfWork,
    [FromServices] NotificationRepository notifications,
    [FromServices] AuditRepository audits)
{
    unitOfWork.BeginTransaction();
    try
    {
        var notification = new NotificationModel
        {
            Id = Guid.NewGuid(),
            Date = DateTime.Now,
            Title = "Test",
            Url = "Test"
        };

        notifications.Save(notification);
        audits.Save($"Notification {notification.Id} created");

        unitOfWork.Commit();
        return Ok();
    }
    catch
    {
        // desfaz tudo o que os dois repositórios fizeram
        unitOfWork.Rollback();
        throw;
    }
}

Como os dois repositórios recebem a mesma DbSession, eles compartilham a mesma transação. Portanto, o Rollback desfaz as duas gravações de uma vez.

O AuditRepository segue exatamente o mesmo formato do NotificationRepository. Ou seja, ele recebe a DbSession no construtor e informa _session.Transaction em cada comando.

Boas práticas ao usar Unit of Work com Dapper

Algumas práticas deixam o Unit of Work com Dapper mais seguro em produção.

Primeiro, abra a transação o mais tarde possível e feche o mais cedo possível. Transações longas seguram bloqueios no banco e prejudicam outras requisições.

Em seguida, mantenha as leituras simples fora da transação, como fizemos no GET. Dessa forma, você evita custo desnecessário em consultas que não alteram dados.

Também evite misturar o Unit of Work do Dapper e o do EF Core na mesma transação sem um plano claro. Cada um gerencia a sua conexão, então compartilhar uma transação entre eles exige configuração extra.

Por fim, pense na connection string. Neste exemplo, ela vem de uma classe Settings, mas em projetos reais o ideal é lê-la da configuração do ASP.NET Core.

Se quiser um passo a passo de operações básicas antes de aplicar os padrões, veja o artigo sobre como criar um CRUD com Dapper.

Código-fonte

O projeto completo deste artigo está disponível no GitHub.

Conclusão

Embora frameworks ORM como o EF Core tragam vários benefícios, em diversos momentos precisamos de um trabalho mais artesanal no acesso a dados. Isso traz dificuldades extras, como o controle de transações e de conexões com o banco.

Com o Unit of Work com Dapper e o Repository Pattern, conseguimos entregar algo consistente e performático nesses cenários.

Além disso, o mesmo modelo serve para qualquer banco suportado pelo ADO.NET, já que o Unit of Work e o Dapper dependem apenas das interfaces do System.Data.

A DbSession centraliza a conexão, o UnitOfWork controla a transação e os repositórios apenas executam os comandos. Assim, cada peça tem uma responsabilidade clara e o código continua simples de manter.

FAQ

O Dapper tem Unit of Work nativo?

Não. O Dapper apenas estende o IDbConnection, então controlar conexão e transação fica por conta da aplicação.

Por que a DbSession é registrada como scoped?

Porque assim cada requisição HTTP recebe uma única conexão. Dessa forma, todos os repositórios daquela requisição compartilham a mesma transação.

Preciso passar a transação em todos os comandos do Dapper?

Sim, quando existe uma transação aberta na conexão. Sem ela, o SqlClient recusa o comando com uma exceção.

Close e Dispose fazem a mesma coisa na conexão?

Ambos devolvem a conexão ao pool. A diferença é que, após o Close, você pode reabrir o mesmo objeto.

Posso usar Unit of Work com Dapper e EF Core juntos?

É possível, mas exige compartilhar a mesma conexão e a mesma transação entre os dois. Na maioria dos casos, é mais simples usar cada um em operações separadas.