ASP.NET Core - Clean Architecture com CQRS e Dapper - I
    Neste tutorial veremos como criar uma aplicação ASP.NET Core usando a abordagem da Clean Architecture e realizar um CRUD usando o Dapper.

O Dapper é um micro ORM (Object-Relational Mapper) para o .NET. Ele simplifica o acesso a bancos de dados relacionais, como o SQL Server, o MySQL e o PostgreSQL, permitindo que os desenvolvedores escrevam consultas SQL de forma mais limpa e eficiente.



O conceito de Arquitetura Limpa é baseado na Regra de Dependência, que afirma que a dependência do código-fonte só pode apontar para o interior do aplicativo.

Vamos criar uma aplicação ASP.NET Core e mostrar como usar o Dapper para realizar o CRUD em uma arquitectura limpa.

Criando o projeto

Vamos iniciar criando uma Blank Solution no VS 2022 chamada ApiDapperClean.

A seguir vamos incluir os seguintes projetos individuais na solução.

No menu File selecione Add -> New Project, selecione o template Class Library(.NET Core) e informe o nome ApiDapperClean.Application;

A seguir vamos repetir o procedimento acima e criar os seguintes projetos todos do tipo Class Library(.NET Core):

Por último vamos incluir um projeto Web API usando o template ASP .NET Core Web Api informando o nome ApiDapperClean.Products

Ao final teremos a solução com 5 projetos conforme abaixo:

  • ApiDapperClean.Application -  Interfaces, serviços e regras da aplicação;
  • ApiDapperClean.Domain - modelo de domínio com regras de negócio;
  • ApiDapperClean.Infrastructure - lógica de acesso aos dados, repositórios, Migrations;
  • ApiDapperClean.CrossCutting - Injeção de dependência;
  • ApiDapperClean.Products Aplicação Web API;

 

Definindo o relacionamento entre os projetos

Vamos definir o seguinte relacionamento entre os projetos respeitando a diretriz da Clean Architecture:

A estrutura do projeto e os relacionamentos estão em conformidade com os princípios da Clean Architecture, onde as dependências do código-fonte devem sempre apontar para dentro, em direção a políticas e abstrações de nível superior, e nunca devem apontar para fora, em direção a detalhes de implementação de nível inferior, mantendo uma clara separação de preocupações e facilitando a manutenção e testabilidade do sistema.

Definindo os recursos na camada Domain

Vamos iniciar definindo as entidades no projeto ApiDapperClean.Domain que representam o nosso modelo de domínio e onde residem as regras de negócio.

Vamos criar uma pasta chamada Entities e nesta pasta criar a classe Product que representa o nosso modelo de domínio:

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public string Description { get; set; } = string.Empty;
    public double Stock { get; set; }
}

Aqui estamos usando um modelo totalmente anêmico com get/set públicos para simplificar o exemplo mas em um projeto de produção do mundo real essa abordagem não é aconselhada.

Vamos criar neste projeto a pasta Abstractions e nesta pasta criar a interface IProductRepository.

A criação da interface IProductRepository ajuda a abstrair o conceito de um repositório de produtos. Isso significa que você pode definir um contrato comum para todas as implementações de repositório de produtos em seu sistema, independentemente de qual tecnologia de acesso a dados você esteja usando (como Entity Framework, Dapper, NHibernate, etc.).

Esta interface vai definir as operações que desejamos realizar com o domínio:

public interface IProductRepository
{
    Task<Product> GetProductById(int id);
    Task<IEnumerable<Product>> GetProducts();
    Task<Product> Add(Product product);
    Task<Product> Update(Product product);
    Task<Product> Delete(int id);
}

Devido a simplicidade do nosso projeto é isso que iremos definir na camada Domain.

Implementando a camada Infrastructure

A camada Infrastructure é responsável por abrigar todos os detalhes técnicos relacionados à infraestrutura externa ao sistema, como acesso a banco de dados, comunicação de rede, frameworks e bibliotecas externas, entre outros. Sua função principal é prover implementações concretas para as interfaces definidas nas camadas internas, como a camada de Domínio ou de Aplicação.

Neste projeto vamos configurar a entidade Product usando a Fluent API e implementar a interface IProductRepository.

Vamos usar o SQLite como banco de dados para armazenar as informações e aqui eu poderia criar o banco e as tabelas usando o DBBrowser para o SQLite  e assim não precisaria usar o EF Core nem aplicar o Migrations.

Para facilitar a criação do banco e tabelas vou usar o EF Core e criar uma classe de contexto definindo o mapeamento ORM e usar os recursos do EF Core para criar o banco e a tabela com dados iniciais.

Assim vamos incluir neste projeto os seguintes pacotes Nugets:

Crie o no projeto a pasta Context e nesta pasta crie a classe AppDbContext :

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
    {
    }
    public DbSet<Product>? Products { get; set; }
    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);
        builder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
    }
}

O método OnModelCreating() é chamado durante a inicialização do contexto do banco de dados e é usado para configurar o modelo de dados. No caso, ele chama o método ApplyConfigurationsFromAssembly() do ModelBuilder para aplicar as configurações de mapeamento das entidades definidas no assembly que contém a classe AppDbContext.

Isso geralmente é usado para aplicar configurações de mapeamento de entidade definidas em classes separadas, como classes de configuração de entidade.

Crie a pasta EntitiesConfiguration e nela crie a classe ProductConfiguration :

public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
    public void Configure(EntityTypeBuilder<Product> builder)
    {
        builder.HasKey(t => t.Id);
        builder.Property(p => p.Name).HasMaxLength(100).IsRequired();
        builder.Property(p => p.Description).HasMaxLength(200).IsRequired();
        builder.Property(p => p.Stock).IsRequired();
        builder.Property(p => p.Price).HasPrecision(10, 2);
        builder.HasData(
             new Product
             {
                 Id = 1,
                 Name = "Caderno",
                 Description = "Caderno Espiral",
                 Price = 12.90m,
                 Stock = 30.0d
             },
             new Product
             {
                 Id = 2,
                 Name = "Caneta",
                 Description = "Caneta Esferográfica",
                 Price = 5.45m,
                 Stock = 50.0d
             },
             new Product
             {
                 Id = 3,
                 Name = "Lápis",
                 Description = "Lápis preto no2",
                 Price = 3.85m,
                 Stock = 45.0d
             }
         );
    }
}

Esse código representa uma classe chamada ProductConfiguration, que implementa a interface IEntityTypeConfiguration<Product>. Essa classe é usada para configurar o mapeamento da entidade Product para o banco de dados usando o Entity Framework Core. Ela também usa o método HasData para incluir dados iniciais na tabela mapeada.

Agora crie a pasta Repositories e nesta pasta crie a classe ProductRepository:

public class ProductRepository : IProductRepository
{
    private readonly IDbConnection _dbConnection;
    public ProductRepository(IDbConnection dbConnection)
    {
        _dbConnection = dbConnection;
    }
    public async Task<IEnumerable<Product>> GetProducts()
    {
        string query = "SELECT * FROM Products";
        var products = await _dbConnection.QueryAsync<Product>(query);
        return products;
    }
    public async Task<Product> GetProductById(int id)
    {
        string query = "SELECT * FROM Products WHERE Id = @Id";
        
        var product = await 
            _dbConnection.QueryFirstOrDefaultAsync<Product>(query, new { Id = id });
        return product;
    }
    // Outras consultas Dapper aqui...
    public async Task<Product> Add(Product product)
    {
        var query = @"
            INSERT INTO Products (Name, Price, Description, Stock) 
            VALUES (@Name, @Price, @Description, @Stock);";
        var id = await _dbConnection.ExecuteScalarAsync<int>(query, product);
        product.Id = id;
        return product;
    }
    public async Task<Product> Update(Product product)
    {
        var query = @"
            UPDATE Products 
            SET Name = @Name, Price = @Price, Description = @Description, Stock = @Stock 
            WHERE Id = @Id";
        await _dbConnection.ExecuteAsync(query, product);
        return product;
    }
    public async Task<Product> Delete(int id)
    {
        var product = await GetProductById(id);
        if (product is null)
            throw new InvalidOperationException("Product Not found");
        var query = "DELETE FROM Products WHERE Id = @Id";
        await _dbConnection.ExecuteAsync(query, new { Id = id });
        return product;
    }
}

A classe ProductRepository  implementa a interface IProductRepository e é responsável por fornecer métodos para acessar e manipular os dados da entidade Product no banco de dados.

Essa classe utiliza o Dapper, uma biblioteca de mapeamento objeto-relacional (ORM), para simplificar o acesso aos dados do banco de dados usando consultas SQL parametrizadas.

Implementando os recursos do projeto CrossCutting

A camada Crosscutting é responsável por elementos que atravessam várias camadas da arquitetura, fornecendo funcionalidades que não se encaixam diretamente em nenhuma camada específica. Ela trata de preocupações que cortam transversalmente todas as camadas do sistema, fornecendo serviços genéricos que podem ser utilizados em diferentes partes da aplicação.

Vamos criar a pasta ApplicationDependencies e nesta pasta criar a classe DependencyInjection:

public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(
                  this IServiceCollection services,
                  IConfiguration configuration)
    {
        var connectionString = configuration
                             .GetConnectionString("Sqlite");
        services.AddDbContext<AppDbContext>(opt =>
                opt.UseSqlite(connectionString));
        services.AddScoped<IProductRepository, ProductRepository>();
        var myhandlers = AppDomain.CurrentDomain.Load("ApiDapperClean.Application");
        services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(myhandlers));
        services.AddSingleton<IDbConnection>(provider =>
        {
            var connection = new SqliteConnection(connectionString);
            connection.Open();
            return connection;
        });
        return services;
    }
}

Este código define um método de extensão chamado AddInfrastructure para a interface IServiceCollection, que é usada no ASP.NET Core para registrar serviços na coleção de serviços.

Neste código estamos carregando os handlers (manipuladores de comandos e consultas) da aplicação usando o MediatR, um padrão de mediador para o ASP.NET Core. Ele permite que os comandos e consultas sejam tratados por classes específicas de manipuladores em vez de controladores ou serviços diretamente.

Aqui também a conexão do banco de dados - IDbConnection - é registrada como um serviço singleton. Isso significa que apenas uma instância dessa conexão será criada durante o ciclo de vida da aplicação e será compartilhada por todos os componentes que a injetam.

Na próxima parte do artigo vamos concluir a implementação dos demais projetos.

"Olhai para as aves do céu, que nem semeiam, nem segam, nem ajuntam em celeiros; e vosso Pai celestial as alimenta. Não tendes vós muito mais valor do que elas?"
Mateus 6:26

Referências:


José Carlos Macoratti