Arquitetura Limpa com .NET 8: Implementação Prática

Eduardo CintraEduardo Cintra
14 min read

A Arquitetura Limpa (Clean Architecture) é um dos padrões arquiteturais mais respeitados e adotados no desenvolvimento de software moderno. Proposta por Robert C. Martin (Uncle Bob), ela oferece uma abordagem estruturada para criar sistemas que são independentes de frameworks, testáveis, e com baixo acoplamento entre componentes.

Neste artigo, vamos além da teoria e mergulhamos na implementação prática da Arquitetura Limpa usando .NET 8. Você aprenderá como estruturar um projeto seguindo esses princípios e verá exemplos concretos de código que podem ser aplicados em seus próprios projetos.

O que é Arquitetura Limpa?

Antes de começarmos a implementação, vamos revisar brevemente os conceitos fundamentais da Arquitetura Limpa.

Princípios Fundamentais

A Arquitetura Limpa é baseada em alguns princípios essenciais:

  1. Independência de frameworks: A arquitetura não depende da existência de alguma biblioteca ou framework. Isso permite usar frameworks como ferramentas, em vez de forçar o sistema a se adaptar às suas limitações.

  2. Testabilidade: As regras de negócio podem ser testadas sem a UI, banco de dados, servidor web ou qualquer outro elemento externo.

  3. Independência da UI: A UI pode mudar facilmente, sem alterar o resto do sistema. Uma UI web pode ser substituída por uma UI de console, por exemplo, sem alterar as regras de negócio.

  4. Independência de banco de dados: Você pode trocar SQL Server por MongoDB, ou até mesmo por um simples arquivo JSON, sem afetar as regras de negócio.

  5. Independência de qualquer agente externo: As regras de negócio simplesmente não sabem nada sobre o mundo exterior.

As Camadas da Arquitetura Limpa

A Arquitetura Limpa é organizada em camadas concêntricas, onde cada camada representa um nível diferente de abstração:

  1. Entidades (Entities): Encapsulam as regras de negócio mais gerais e de alto nível. São as classes menos propensas a mudanças quando algo externo muda.

  2. Casos de Uso (Use Cases): Contêm regras de negócio específicas da aplicação. Orquestram o fluxo de dados para e das entidades, e direcionam essas entidades a usar suas regras de negócio para atingir os objetivos do caso de uso.

  3. Adaptadores de Interface (Interface Adapters): Convertem dados do formato mais conveniente para os casos de uso e entidades, para o formato mais conveniente para algum agente externo como o banco de dados ou a web.

  4. Frameworks e Drivers: É a camada mais externa, composta por frameworks e ferramentas como o banco de dados, o framework web, etc.

A Regra de Dependência

O princípio mais importante da Arquitetura Limpa é a Regra de Dependência: as dependências de código-fonte só podem apontar para dentro, em direção às políticas de alto nível (regras de negócio).

Nada em um círculo interno pode saber qualquer coisa sobre algo em um círculo externo. Em particular, algo declarado em um círculo externo não deve ser mencionado por código em um círculo interno.

Implementando Arquitetura Limpa em .NET 8

Agora que revisamos os conceitos, vamos implementar uma aplicação seguindo os princípios da Arquitetura Limpa usando .NET 8. Criaremos um sistema simples de gerenciamento de tarefas (Todo List) para demonstrar os conceitos.

Estrutura do Projeto

Primeiro, vamos definir a estrutura de pastas e projetos que usaremos:

CleanArchitecture.TodoApp/
├── src/
│   ├── CleanArchitecture.TodoApp.Domain/           # Camada de Entidades
│   ├── CleanArchitecture.TodoApp.Application/      # Camada de Casos de Uso
│   ├── CleanArchitecture.TodoApp.Infrastructure/   # Camada de Adaptadores e Frameworks
│   └── CleanArchitecture.TodoApp.WebApi/           # Interface de Usuário (API)
└── tests/
    ├── CleanArchitecture.TodoApp.UnitTests/

Vamos criar esta estrutura usando o .NET CLI:

# Criar a solução
dotnet new sln -n CleanArchitecture.TodoApp

# Criar os projetos
dotnet new classlib -n CleanArchitecture.TodoApp.Domain -o src/CleanArchitecture.TodoApp.Domain
dotnet new classlib -n CleanArchitecture.TodoApp.Application -o src/CleanArchitecture.TodoApp.Application
dotnet new classlib -n CleanArchitecture.TodoApp.Infrastructure -o src/CleanArchitecture.TodoApp.Infrastructure
dotnet new webapi -n CleanArchitecture.TodoApp.WebApi -o src/CleanArchitecture.TodoApp.WebApi

# Criar os projetos de teste
dotnet new xunit -n CleanArchitecture.TodoApp.UnitTests -o tests/CleanArchitecture.TodoApp.UnitTests
dotnet new xunit -n CleanArchitecture.TodoApp.IntegrationTests -o tests/CleanArchitecture.TodoApp.IntegrationTests

# Adicionar projetos à solução
dotnet sln add src/CleanArchitecture.TodoApp.Domain/CleanArchitecture.TodoApp.Domain.csproj
dotnet sln add src/CleanArchitecture.TodoApp.Application/CleanArchitecture.TodoApp.Application.csproj
dotnet sln add src/CleanArchitecture.TodoApp.Infrastructure/CleanArchitecture.TodoApp.Infrastructure.csproj
dotnet sln add src/CleanArchitecture.TodoApp.WebApi/CleanArchitecture.TodoApp.WebApi.csproj
dotnet sln add tests/CleanArchitecture.TodoApp.UnitTests/CleanArchitecture.TodoApp.UnitTests.csproj

Configurando as Dependências entre Projetos

Agora, vamos configurar as dependências entre os projetos, seguindo a Regra de Dependência:

# Application depende de Domain
dotnet add src/CleanArchitecture.TodoApp.Application/CleanArchitecture.TodoApp.Application.csproj reference src/CleanArchitecture.TodoApp.Domain/CleanArchitecture.TodoApp.Domain.csproj

# Infrastructure depende de Application e Domain
dotnet add src/CleanArchitecture.TodoApp.Infrastructure/CleanArchitecture.TodoApp.Infrastructure.csproj reference src/CleanArchitecture.TodoApp.Application/CleanArchitecture.TodoApp.Application.csproj
dotnet add src/CleanArchitecture.TodoApp.Infrastructure/CleanArchitecture.TodoApp.Infrastructure.csproj reference src/CleanArchitecture.TodoApp.Domain/CleanArchitecture.TodoApp.Domain.csproj

# WebApi depende de Application, Domain e Infrastructure
dotnet add src/CleanArchitecture.TodoApp.WebApi/CleanArchitecture.TodoApp.WebApi.csproj reference src/CleanArchitecture.TodoApp.Application/CleanArchitecture.TodoApp.Application.csproj
dotnet add src/CleanArchitecture.TodoApp.WebApi/CleanArchitecture.TodoApp.WebApi.csproj reference src/CleanArchitecture.TodoApp.Domain/CleanArchitecture.TodoApp.Domain.csproj
dotnet add src/CleanArchitecture.TodoApp.WebApi/CleanArchitecture.TodoApp.WebApi.csproj reference src/CleanArchitecture.TodoApp.Infrastructure/CleanArchitecture.TodoApp.Infrastructure.csproj

Instalando Pacotes Necessários

Vamos instalar os pacotes que precisaremos para nossa implementação:

# Para o projeto Application
dotnet add src/CleanArchitecture.TodoApp.Application/CleanArchitecture.TodoApp.Application.csproj package MediatR
dotnet add src/CleanArchitecture.TodoApp.Application/CleanArchitecture.TodoApp.Application.csproj package FluentValidation
dotnet add src/CleanArchitecture.TodoApp.Application/CleanArchitecture.TodoApp.Application.csproj package Microsoft.Extensions.DependencyInjection.Abstractions

# Para o projeto Infrastructure
dotnet add src/CleanArchitecture.TodoApp.Infrastructure/CleanArchitecture.TodoApp.Infrastructure.csproj package Microsoft.EntityFrameworkCore
dotnet add src/CleanArchitecture.TodoApp.Infrastructure/CleanArchitecture.TodoApp.Infrastructure.csproj package Microsoft.EntityFrameworkCore.SqlServer
dotnet add src/CleanArchitecture.TodoApp.Infrastructure/CleanArchitecture.TodoApp.Infrastructure.csproj package Microsoft.Extensions.Configuration.Abstractions
dotnet add src/CleanArchitecture.TodoApp.Infrastructure/CleanArchitecture.TodoApp.Infrastructure.csproj package Microsoft.Extensions.DependencyInjection.Abstractions

# Para o projeto WebApi
dotnet add src/CleanArchitecture.TodoApp.WebApi/CleanArchitecture.TodoApp.WebApi.csproj package MediatR.Extensions.Microsoft.DependencyInjection
dotnet add src/CleanArchitecture.TodoApp.WebApi/CleanArchitecture.TodoApp.WebApi.csproj package Microsoft.EntityFrameworkCore.Design
dotnet add src/CleanArchitecture.TodoApp.WebApi/CleanArchitecture.TodoApp.WebApi.csproj package Swashbuckle.AspNetCore

Implementando a Camada de Domínio

A camada de domínio contém as entidades de negócio, objetos de valor, enumerações e exceções de domínio. É a camada mais interna e não deve depender de nenhuma outra camada.

Entidades

Vamos criar nossa entidade principal, TodoItem:

// src/CleanArchitecture.TodoApp.Domain/Entities/TodoItem.cs
namespace CleanArchitecture.TodoApp.Domain.Entities
{
    public class TodoItem
    {
        public Guid Id { get; private set; }
        public string Title { get; private set; }
        public string Description { get; private set; }
        public bool IsCompleted { get; private set; }
        public DateTime CreatedAt { get; private set; }
        public DateTime? CompletedAt { get; private set; }

        // Construtor privado para EF Core
        private TodoItem() { }

        public TodoItem(string title, string description)
        {
            if (string.IsNullOrWhiteSpace(title))
                throw new ArgumentException("Title cannot be empty", nameof(title));

            Id = Guid.NewGuid();
            Title = title;
            Description = description ?? string.Empty;
            IsCompleted = false;
            CreatedAt = DateTime.UtcNow;
        }

        public void MarkAsComplete()
        {
            if (!IsCompleted)
            {
                IsCompleted = true;
                CompletedAt = DateTime.UtcNow;
            }
        }

        public void MarkAsIncomplete()
        {
            if (IsCompleted)
            {
                IsCompleted = false;
                CompletedAt = null;
            }
        }

        public void UpdateTitle(string title)
        {
            if (string.IsNullOrWhiteSpace(title))
                throw new ArgumentException("Title cannot be empty", nameof(title));

            Title = title;
        }

        public void UpdateDescription(string description)
        {
            Description = description ?? string.Empty;
        }
    }
}

Interfaces de Repositório

Definimos as interfaces de repositório na camada de domínio, mas a implementação será na camada de infraestrutura:

// src/CleanArchitecture.TodoApp.Domain/Repositories/ITodoItemRepository.cs
using CleanArchitecture.TodoApp.Domain.Entities;

namespace CleanArchitecture.TodoApp.Domain.Repositories
{
    public interface ITodoItemRepository
    {
        Task<TodoItem> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
        Task<IEnumerable<TodoItem>> GetAllAsync(CancellationToken cancellationToken = default);
        Task<IEnumerable<TodoItem>> GetCompletedAsync(CancellationToken cancellationToken = default);
        Task<IEnumerable<TodoItem>> GetIncompleteAsync(CancellationToken cancellationToken = default);
        Task AddAsync(TodoItem todoItem, CancellationToken cancellationToken = default);
        Task UpdateAsync(TodoItem todoItem, CancellationToken cancellationToken = default);
        Task DeleteAsync(TodoItem todoItem, CancellationToken cancellationToken = default);
    }
}

Interface de Unidade de Trabalho

Para garantir a consistência transacional, vamos definir uma interface de Unidade de Trabalho:

// src/CleanArchitecture.TodoApp.Domain/Repositories/IUnitOfWork.cs
namespace CleanArchitecture.TodoApp.Domain.Repositories
{
    public interface IUnitOfWork
    {
        Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
    }
}

Implementando a Camada de Aplicação

A camada de aplicação contém os casos de uso da aplicação. Ela depende da camada de domínio, mas é independente de frameworks externos.

DTOs (Data Transfer Objects)

Primeiro, vamos criar os DTOs que serão usados para transferir dados entre as camadas:

// src/CleanArchitecture.TodoApp.Application/DTOs/TodoItemDto.cs
namespace CleanArchitecture.TodoApp.Application.DTOs
{
    public class TodoItemDto
    {
        public Guid Id { get; set; }
        public string Title { get; set; }
        public string Description { get; set; }
        public bool IsCompleted { get; set; }
        public DateTime CreatedAt { get; set; }
        public DateTime? CompletedAt { get; set; }
    }
}

Mapeamento entre Entidades e DTOs

Vamos criar uma classe para mapear entre entidades e DTOs:

// src/CleanArchitecture.TodoApp.Application/Mapping/TodoItemMapper.cs
using CleanArchitecture.TodoApp.Application.DTOs;
using CleanArchitecture.TodoApp.Domain.Entities;

namespace CleanArchitecture.TodoApp.Application.Mapping
{
    public static class TodoItemMapper
    {
        public static TodoItemDto ToDto(this TodoItem entity)
        {
            return new TodoItemDto
            {
                Id = entity.Id,
                Title = entity.Title,
                Description = entity.Description,
                IsCompleted = entity.IsCompleted,
                CreatedAt = entity.CreatedAt,
                CompletedAt = entity.CompletedAt
            };
        }
    }
}

Casos de Uso com CQRS e MediatR

Vamos implementar os casos de uso usando o padrão CQRS (Command Query Responsibility Segregation) com a biblioteca MediatR:

Consultas (Queries)

// src/CleanArchitecture.TodoApp.Application/TodoItems/Queries/GetAllTodoItems/GetAllTodoItemsQuery.cs
using CleanArchitecture.TodoApp.Application.DTOs;
using MediatR;

namespace CleanArchitecture.TodoApp.Application.TodoItems.Queries.GetAllTodoItems
{
    public class GetAllTodoItemsQuery : IRequest<IEnumerable<TodoItemDto>>
    {
    }
}

// src/CleanArchitecture.TodoApp.Application/TodoItems/Queries/GetAllTodoItems/GetAllTodoItemsQueryHandler.cs
using CleanArchitecture.TodoApp.Application.DTOs;
using CleanArchitecture.TodoApp.Application.Mapping;
using CleanArchitecture.TodoApp.Domain.Repositories;
using MediatR;

namespace CleanArchitecture.TodoApp.Application.TodoItems.Queries.GetAllTodoItems
{
    public class GetAllTodoItemsQueryHandler : IRequestHandler<GetAllTodoItemsQuery, IEnumerable<TodoItemDto>>
    {
        private readonly ITodoItemRepository _todoItemRepository;

        public GetAllTodoItemsQueryHandler(ITodoItemRepository todoItemRepository)
        {
            _todoItemRepository = todoItemRepository;
        }

        public async Task<IEnumerable<TodoItemDto>> Handle(GetAllTodoItemsQuery request, CancellationToken cancellationToken)
        {
            var todoItems = await _todoItemRepository.GetAllAsync(cancellationToken);
            return todoItems.Select(item => item.ToDto());
        }
    }
}

// src/CleanArchitecture.TodoApp.Application/TodoItems/Queries/GetTodoItemById/GetTodoItemByIdQuery.cs
using CleanArchitecture.TodoApp.Application.DTOs;
using MediatR;

namespace CleanArchitecture.TodoApp.Application.TodoItems.Queries.GetTodoItemById
{
    public class GetTodoItemByIdQuery : IRequest<TodoItemDto>
    {
        public Guid Id { get; set; }
    }
}

// src/CleanArchitecture.TodoApp.Application/TodoItems/Queries/GetTodoItemById/GetTodoItemByIdQueryHandler.cs
using CleanArchitecture.TodoApp.Application.DTOs;
using CleanArchitecture.TodoApp.Application.Mapping;
using CleanArchitecture.TodoApp.Domain.Repositories;
using MediatR;

namespace CleanArchitecture.TodoApp.Application.TodoItems.Queries.GetTodoItemById
{
    public class GetTodoItemByIdQueryHandler : IRequestHandler<GetTodoItemByIdQuery, TodoItemDto>
    {
        private readonly ITodoItemRepository _todoItemRepository;

        public GetTodoItemByIdQueryHandler(ITodoItemRepository todoItemRepository)
        {
            _todoItemRepository = todoItemRepository;
        }

        public async Task<TodoItemDto> Handle(GetTodoItemByIdQuery request, CancellationToken cancellationToken)
        {
            var todoItem = await _todoItemRepository.GetByIdAsync(request.Id, cancellationToken);

            if (todoItem == null)
                return null;

            return todoItem.ToDto();
        }
    }
}

Comandos (Commands)

// src/CleanArchitecture.TodoApp.Application/TodoItems/Commands/CreateTodoItem/CreateTodoItemCommand.cs
using MediatR;

namespace CleanArchitecture.TodoApp.Application.TodoItems.Commands.CreateTodoItem
{
    public class CreateTodoItemCommand : IRequest<Guid>
    {
        public string Title { get; set; }
        public string Description { get; set; }
    }
}

// src/CleanArchitecture.TodoApp.Application/TodoItems/Commands/CreateTodoItem/CreateTodoItemCommandHandler.cs
using CleanArchitecture.TodoApp.Domain.Entities;
using CleanArchitecture.TodoApp.Domain.Repositories;
using MediatR;

namespace CleanArchitecture.TodoApp.Application.TodoItems.Commands.CreateTodoItem
{
    public class CreateTodoItemCommandHandler : IRequestHandler<CreateTodoItemCommand, Guid>
    {
        private readonly ITodoItemRepository _todoItemRepository;
        private readonly IUnitOfWork _unitOfWork;

        public CreateTodoItemCommandHandler(ITodoItemRepository todoItemRepository, IUnitOfWork unitOfWork)
        {
            _todoItemRepository = todoItemRepository;
            _unitOfWork = unitOfWork;
        }

        public async Task<Guid> Handle(CreateTodoItemCommand request, CancellationToken cancellationToken)
        {
            var todoItem = new TodoItem(request.Title, request.Description);

            await _todoItemRepository.AddAsync(todoItem, cancellationToken);
            await _unitOfWork.SaveChangesAsync(cancellationToken);

            return todoItem.Id;
        }
    }
}

// src/CleanArchitecture.TodoApp.Application/TodoItems/Commands/UpdateTodoItem/UpdateTodoItemCommand.cs
using MediatR;

namespace CleanArchitecture.TodoApp.Application.TodoItems.Commands.UpdateTodoItem
{
    public class UpdateTodoItemCommand : IRequest<bool>
    {
        public Guid Id { get; set; }
        public string Title { get; set; }
        public string Description { get; set; }
    }
}

// src/CleanArchitecture.TodoApp.Application/TodoItems/Commands/UpdateTodoItem/UpdateTodoItemCommandHandler.cs
using CleanArchitecture.TodoApp.Domain.Repositories;
using MediatR;

namespace CleanArchitecture.TodoApp.Application.TodoItems.Commands.UpdateTodoItem
{
    public class UpdateTodoItemCommandHandler : IRequestHandler<UpdateTodoItemCommand, bool>
    {
        private readonly ITodoItemRepository _todoItemRepository;
        private readonly IUnitOfWork _unitOfWork;

        public UpdateTodoItemCommandHandler(ITodoItemRepository todoItemRepository, IUnitOfWork unitOfWork)
        {
            _todoItemRepository = todoItemRepository;
            _unitOfWork = unitOfWork;
        }

        public async Task<bool> Handle(UpdateTodoItemCommand request, CancellationToken cancellationToken)
        {
            var todoItem = await _todoItemRepository.GetByIdAsync(request.Id, cancellationToken);

            if (todoItem == null)
                return false;

            todoItem.UpdateTitle(request.Title);
            todoItem.UpdateDescription(request.Description);

            await _todoItemRepository.UpdateAsync(todoItem, cancellationToken);
            await _unitOfWork.SaveChangesAsync(cancellationToken);

            return true;
        }
    }
}

// src/CleanArchitecture.TodoApp.Application/TodoItems/Commands/DeleteTodoItem/DeleteTodoItemCommand.cs
using MediatR;

namespace CleanArchitecture.TodoApp.Application.TodoItems.Commands.DeleteTodoItem
{
    public class DeleteTodoItemCommand : IRequest<bool>
    {
        public Guid Id { get; set; }
    }
}

// src/CleanArchitecture.TodoApp.Application/TodoItems/Commands/DeleteTodoItem/DeleteTodoItemCommandHandler.cs
using CleanArchitecture.TodoApp.Domain.Repositories;
using MediatR;

namespace CleanArchitecture.TodoApp.Application.TodoItems.Commands.DeleteTodoItem
{
    public class DeleteTodoItemCommandHandler : IRequestHandler<DeleteTodoItemCommand, bool>
    {
        private readonly ITodoItemRepository _todoItemRepository;
        private readonly IUnitOfWork _unitOfWork;

        public DeleteTodoItemCommandHandler(ITodoItemRepository todoItemRepository, IUnitOfWork unitOfWork)
        {
            _todoItemRepository = todoItemRepository;
            _unitOfWork = unitOfWork;
        }

        public async Task<bool> Handle(DeleteTodoItemCommand request, CancellationToken cancellationToken)
        {
            var todoItem = await _todoItemRepository.GetByIdAsync(request.Id, cancellationToken);

            if (todoItem == null)
                return false;

            await _todoItemRepository.DeleteAsync(todoItem, cancellationToken);
            await _unitOfWork.SaveChangesAsync(cancellationToken);

            return true;
        }
    }
}

// src/CleanArchitecture.TodoApp.Application/TodoItems/Commands/CompleteTodoItem/CompleteTodoItemCommand.cs
using MediatR;

namespace CleanArchitecture.TodoApp.Application.TodoItems.Commands.CompleteTodoItem
{
    public class CompleteTodoItemCommand : IRequest<bool>
    {
        public Guid Id { get; set; }
    }
}

// src/CleanArchitecture.TodoApp.Application/TodoItems/Commands/CompleteTodoItem/CompleteTodoItemCommandHandler.cs
using CleanArchitecture.TodoApp.Domain.Repositories;
using MediatR;

namespace CleanArchitecture.TodoApp.Application.TodoItems.Commands.CompleteTodoItem
{
    public class CompleteTodoItemCommandHandler : IRequestHandler<CompleteTodoItemCommand, bool>
    {
        private readonly ITodoItemRepository _todoItemRepository;
        private readonly IUnitOfWork _unitOfWork;

        public CompleteTodoItemCommandHandler(ITodoItemRepository todoItemRepository, IUnitOfWork unitOfWork)
        {
            _todoItemRepository = todoItemRepository;
            _unitOfWork = unitOfWork;
        }

        public async Task<bool> Handle(CompleteTodoItemCommand request, CancellationToken cancellationToken)
        {
            var todoItem = await _todoItemRepository.GetByIdAsync(request.Id, cancellationToken);

            if (todoItem == null)
                return false;

            todoItem.MarkAsComplete();

            await _todoItemRepository.UpdateAsync(todoItem, cancellationToken);
            await _unitOfWork.SaveChangesAsync(cancellationToken);

            return true;
        }
    }
}

Configuração de Injeção de Dependência

Vamos configurar a injeção de dependência para a camada de aplicação:

// src/CleanArchitecture.TodoApp.Application/DependencyInjection.cs
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;

namespace CleanArchitecture.TodoApp.Application
{
    public static class DependencyInjection
    {
        public static IServiceCollection AddApplication(this IServiceCollection services)
        {
            services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));

            return services;
        }
    }
}

Implementando a Camada de Infraestrutura

A camada de infraestrutura contém implementações concretas de interfaces definidas nas camadas de domínio e aplicação, como repositórios, serviços de persistência, etc.

Contexto do Entity Framework Core

// src/CleanArchitecture.TodoApp.Infrastructure/Persistence/ApplicationDbContext.cs
using CleanArchitecture.TodoApp.Domain.Entities;
using Microsoft.EntityFrameworkCore;

namespace CleanArchitecture.TodoApp.Infrastructure.Persistence
{
    public class ApplicationDbContext : DbContext
    {
        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
            : base(options)
        {
        }

        public DbSet<TodoItem> TodoItems { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<TodoItem>(entity =>
            {
                entity.HasKey(e => e.Id);
                entity.Property(e => e.Title).IsRequired().HasMaxLength(200);
                entity.Property(e => e.Description).HasMaxLength(2000);
            });
        }
    }
}

Implementação do Repositório

// src/CleanArchitecture.TodoApp.Infrastructure/Persistence/Repositories/TodoItemRepository.cs
using CleanArchitecture.TodoApp.Domain.Entities;
using CleanArchitecture.TodoApp.Domain.Repositories;
using Microsoft.EntityFrameworkCore;

namespace CleanArchitecture.TodoApp.Infrastructure.Persistence.Repositories
{
    public class TodoItemRepository : ITodoItemRepository
    {
        private readonly ApplicationDbContext _context;

        public TodoItemRepository(ApplicationDbContext context)
        {
            _context = context;
        }

        public async Task<TodoItem> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
        {
            return await _context.TodoItems.FindAsync(new object[] { id }, cancellationToken);
        }

        public async Task<IEnumerable<TodoItem>> GetAllAsync(CancellationToken cancellationToken = default)
        {
            return await _context.TodoItems.ToListAsync(cancellationToken);
        }

        public async Task<IEnumerable<TodoItem>> GetCompletedAsync(CancellationToken cancellationToken = default)
        {
            return await _context.TodoItems
                .Where(t => t.IsCompleted)
                .ToListAsync(cancellationToken);
        }

        public async Task<IEnumerable<TodoItem>> GetIncompleteAsync(CancellationToken cancellationToken = default)
        {
            return await _context.TodoItems
                .Where(t => !t.IsCompleted)
                .ToListAsync(cancellationToken);
        }

        public async Task AddAsync(TodoItem todoItem, CancellationToken cancellationToken = default)
        {
            await _context.TodoItems.AddAsync(todoItem, cancellationToken);
        }

        public Task UpdateAsync(TodoItem todoItem, CancellationToken cancellationToken = default)
        {
            _context.Entry(todoItem).State = EntityState.Modified;
            return Task.CompletedTask;
        }

        public Task DeleteAsync(TodoItem todoItem, CancellationToken cancellationToken = default)
        {
            _context.TodoItems.Remove(todoItem);
            return Task.CompletedTask;
        }
    }
}

Implementação da Unidade de Trabalho

// src/CleanArchitecture.TodoApp.Infrastructure/Persistence/UnitOfWork.cs
using CleanArchitecture.TodoApp.Domain.Repositories;

namespace CleanArchitecture.TodoApp.Infrastructure.Persistence
{
    public class UnitOfWork : IUnitOfWork
    {
        private readonly ApplicationDbContext _context;

        public UnitOfWork(ApplicationDbContext context)
        {
            _context = context;
        }

        public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
        {
            return await _context.SaveChangesAsync(cancellationToken);
        }
    }
}

Configuração de Injeção de Dependência

// src/CleanArchitecture.TodoApp.Infrastructure/DependencyInjection.cs
using CleanArchitecture.TodoApp.Domain.Repositories;
using CleanArchitecture.TodoApp.Infrastructure.Persistence;
using CleanArchitecture.TodoApp.Infrastructure.Persistence.Repositories;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace CleanArchitecture.TodoApp.Infrastructure
{
    public static class DependencyInjection
    {
        public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
        {
            services.AddDbContext<ApplicationDbContext>(options =>
                options.UseSqlServer(
                    configuration.GetConnectionString("DefaultConnection"),
                    b => b.MigrationsAssembly(typeof(ApplicationDbContext).Assembly.FullName)));

            services.AddScoped<ITodoItemRepository, TodoItemRepository>();
            services.AddScoped<IUnitOfWork, UnitOfWork>();

            return services;
        }
    }
}

Implementando a Camada de Apresentação (WebApi)

A camada de apresentação é responsável por expor a funcionalidade da aplicação para o mundo exterior. No nosso caso, vamos implementar uma API REST.

Controladores

// src/CleanArchitecture.TodoApp.WebApi/Controllers/TodoItemsController.cs
using CleanArchitecture.TodoApp.Application.DTOs;
using CleanArchitecture.TodoApp.Application.TodoItems.Commands.CompleteTodoItem;
using CleanArchitecture.TodoApp.Application.TodoItems.Commands.CreateTodoItem;
using CleanArchitecture.TodoApp.Application.TodoItems.Commands.DeleteTodoItem;
using CleanArchitecture.TodoApp.Application.TodoItems.Commands.UpdateTodoItem;
using CleanArchitecture.TodoApp.Application.TodoItems.Queries.GetAllTodoItems;
using CleanArchitecture.TodoApp.Application.TodoItems.Queries.GetTodoItemById;
using MediatR;
using Microsoft.AspNetCore.Mvc;

namespace CleanArchitecture.TodoApp.WebApi.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class TodoItemsController : ControllerBase
    {
        private readonly IMediator _mediator;

        public TodoItemsController(IMediator mediator)
        {
            _mediator = mediator;
        }

        [HttpGet]
        public async Task<ActionResult<IEnumerable<TodoItemDto>>> GetAll()
        {
            var query = new GetAllTodoItemsQuery();
            var result = await _mediator.Send(query);
            return Ok(result);
        }

        [HttpGet("{id}")]
        public async Task<ActionResult<TodoItemDto>> GetById(Guid id)
        {
            var query = new GetTodoItemByIdQuery { Id = id };
            var result = await _mediator.Send(query);

            if (result == null)
                return NotFound();

            return Ok(result);
        }

        [HttpPost]
        public async Task<ActionResult<Guid>> Create(CreateTodoItemCommand command)
        {
            var result = await _mediator.Send(command);
            return CreatedAtAction(nameof(GetById), new { id = result }, result);
        }

        [HttpPut("{id}")]
        public async Task<IActionResult> Update(Guid id, UpdateTodoItemCommand command)
        {
            if (id != command.Id)
                return BadRequest();

            var result = await _mediator.Send(command);

            if (!result)
                return NotFound();

            return NoContent();
        }

        [HttpDelete("{id}")]
        public async Task<IActionResult> Delete(Guid id)
        {
            var command = new DeleteTodoItemCommand { Id = id };
            var result = await _mediator.Send(command);

            if (!result)
                return NotFound();

            return NoContent();
        }

        [HttpPatch("{id}/complete")]
        public async Task<IActionResult> Complete(Guid id)
        {
            var command = new CompleteTodoItemCommand { Id = id };
            var result = await _mediator.Send(command);

            if (!result)
                return NotFound();

            return NoContent();
        }
    }
}

Configuração do Startup

// src/CleanArchitecture.TodoApp.WebApi/Program.cs
using CleanArchitecture.TodoApp.Application;
using CleanArchitecture.TodoApp.Infrastructure;
using Microsoft.OpenApi.Models;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new OpenApiInfo { Title = "CleanArchitecture.TodoApp.WebApi", Version = "v1" });
});

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "CleanArchitecture.TodoApp.WebApi v1"));
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

Configuração do appsettings.json

// src/CleanArchitecture.TodoApp.WebApi/appsettings.json
{
  "ConnectionStrings": {
    "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=CleanArchitectureTodoApp;Trusted_Connection=True;MultipleActiveResultSets=true"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*"
}

Implementando Testes Unitários

Vamos implementar alguns testes unitários para garantir que nossa lógica de negócio funcione corretamente:

// tests/CleanArchitecture.TodoApp.UnitTests/Domain/TodoItemTests.cs
using CleanArchitecture.TodoApp.Domain.Entities;
using Xunit;

namespace CleanArchitecture.TodoApp.UnitTests.Domain
{
    public class TodoItemTests
    {
        [Fact]
        public void CreateTodoItem_WithValidParameters_ShouldCreateItem()
        {
            // Arrange
            string title = "Test Todo";
            string description = "Test Description";

            // Act
            var todoItem = new TodoItem(title, description);

            // Assert
            Assert.Equal(title, todoItem.Title);
            Assert.Equal(description, todoItem.Description);
            Assert.False(todoItem.IsCompleted);
            Assert.Null(todoItem.CompletedAt);
            Assert.NotEqual(Guid.Empty, todoItem.Id);
        }

        [Theory]
        [InlineData(null)]
        [InlineData("")]
        [InlineData(" ")]
        public void CreateTodoItem_WithInvalidTitle_ShouldThrowException(string invalidTitle)
        {
            // Arrange & Act & Assert
            Assert.Throws<ArgumentException>(() => new TodoItem(invalidTitle, "Description"));
        }

        [Fact]
        public void MarkAsComplete_ShouldSetIsCompletedToTrue()
        {
            // Arrange
            var todoItem = new TodoItem("Test", "Description");

            // Act
            todoItem.MarkAsComplete();

            // Assert
            Assert.True(todoItem.IsCompleted);
            Assert.NotNull(todoItem.CompletedAt);
        }

        [Fact]
        public void MarkAsIncomplete_ShouldSetIsCompletedToFalse()
        {
            // Arrange
            var todoItem = new TodoItem("Test", "Description");
            todoItem.MarkAsComplete();

            // Act
            todoItem.MarkAsIncomplete();

            // Assert
            Assert.False(todoItem.IsCompleted);
            Assert.Null(todoItem.CompletedAt);
        }

        [Fact]
        public void UpdateTitle_WithValidTitle_ShouldUpdateTitle()
        {
            // Arrange
            var todoItem = new TodoItem("Test", "Description");
            string newTitle = "Updated Title";

            // Act
            todoItem.UpdateTitle(newTitle);

            // Assert
            Assert.Equal(newTitle, todoItem.Title);
        }

        [Theory]
        [InlineData(null)]
        [InlineData("")]
        [InlineData(" ")]
        public void UpdateTitle_WithInvalidTitle_ShouldThrowException(string invalidTitle)
        {
            // Arrange
            var todoItem = new TodoItem("Test", "Description");

            // Act & Assert
            Assert.Throws<ArgumentException>(() => todoItem.UpdateTitle(invalidTitle));
        }

        [Fact]
        public void UpdateDescription_ShouldUpdateDescription()
        {
            // Arrange
            var todoItem = new TodoItem("Test", "Description");
            string newDescription = "Updated Description";

            // Act
            todoItem.UpdateDescription(newDescription);

            // Assert
            Assert.Equal(newDescription, todoItem.Description);
        }
    }
}

Executando a Aplicação

Para executar a aplicação, siga estes passos:

  1. Certifique-se de que o SQL Server LocalDB está instalado e em execução.

  2. Navegue até o diretório do projeto WebApi:

     cd src/CleanArchitecture.TodoApp.WebApi
    
  3. Execute as migrações do Entity Framework Core:

     dotnet ef migrations add InitialCreate
     dotnet ef database update
    
  4. Execute a aplicação:

     dotnet run
    
  5. Abra o navegador e acesse https://localhost:5001/swagger para ver a documentação da API e testá-la.

Conclusão

Neste artigo, implementamos uma aplicação seguindo os princípios da Arquitetura Limpa usando .NET 8. Vimos como estruturar o projeto em camadas, como implementar cada camada e como elas se comunicam entre si.

A Arquitetura Limpa nos proporciona vários benefícios:

  1. Testabilidade: As regras de negócio podem ser testadas independentemente da UI, banco de dados ou qualquer outro elemento externo.

  2. Independência de frameworks: A arquitetura não depende de bibliotecas ou frameworks específicos.

  3. Manutenibilidade: O código é organizado de forma que as mudanças em uma camada não afetam as outras.

  4. Escalabilidade: A separação clara de responsabilidades facilita a escalabilidade do sistema.

Ao seguir esses princípios, você estará construindo aplicações mais robustas, flexíveis e fáceis de manter a longo prazo.

Próximos Passos

Para continuar aprimorando esta aplicação, você pode:

  1. Implementar autenticação e autorização

  2. Adicionar validação de entrada usando FluentValidation

  3. Implementar logging e tratamento de exceções

  4. Adicionar testes de integração

  5. Implementar uma UI usando Blazor ou Angular

O código completo deste projeto está disponível no GitHub: [Link para repositório Git]


Espero que este artigo tenha sido útil para entender como implementar a Arquitetura Limpa em projetos .NET 8. Se você tiver alguma dúvida ou sugestão, deixe um comentário abaixo!


Referências:

  1. Martin, R. C. (2017). Clean Architecture: A Craftsman's Guide to Software Structure and Design.

  2. Microsoft Docs - Arquitetura de Aplicações .NET

  3. Jason Taylor - Clean Architecture Template

  4. Steve Smith - Clean Architecture in ASP.NET Core

1
Subscribe to my newsletter

Read articles from Eduardo Cintra directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Eduardo Cintra
Eduardo Cintra