ASP.NET Core Web API - Clean Architecture - I


 Hoje vamos criar um projeto ASP.NET Core Web API aplicando o padrão de arquitetura Clean Architecture.

A Clean Architecture (Arquitetura Limpa) é um conjunto de princípios e diretrizes para projetar e organizar sistemas de software de forma a promover a separação de preocupações, a modularidade, a testabilidade e a manutenção facilitada. Ela foi proposta por Robert C. Martin, também conhecido como Uncle Bob, e se baseia em princípios de design sólidos e na separação clara das diferentes camadas de um sistema.

A ideia por trás da Clean Architecture é criar um design que minimize as dependências externas e facilite a evolução do sistema ao longo do tempo. Ela enfatiza a separação de preocupações em várias camadas, cada uma com um propósito específico, e estabelece uma hierarquia de dependências que mantém o núcleo da aplicação independente de detalhes de implementação.

Vamos mostrar a implementação da Clean Architecture em um projeto ASP.NET Core Web API que vai gerenciar informações sobre usuários definidos pela entidade User que vai conter as propriedades Email e Name. (Estamos simplificando o modelo de domínio)

Para aplicar a Arquitetura Limpa, podemos dividir a aplicação em quatro camadas:

A estrutura do projeto criado é mostrado a seguir:

Podemos identificar neste projeto os seguintes componentes:

  1. Uma solução em branco - Blank Solution - CleanArchitecture;
  2. 4 Solution Folders :  Core , Infrastructure, Presentation e Test;
  3. Core :  Contém dois projetos do tipo Class Library : CleanArchitecdture.Domain e CleanArchitecdture.Application
  4. Infrastructure : Contém um projeto do tipo Class Library : CleanArchitecdture.Persistence
  5. Presentation : Contém um projeto do tipo Asp.Net Core Web Api : CleanArchitecdture.WebAPI
  6. Test : Contém um projeto do tipo xUnit : CleanArchitecdture.UnitTest

Camada de Domínio

A camada de domínio é um componente central da Clean Architecture, representando a lógica e as entidades de negócios de um aplicativo. Ele contém todas as regras de negócios e conhecimento do aplicativo e deve ser independente de quaisquer detalhes ou tecnologias específicas de implementação. A camada de domínio define as entidades, objetos de valor, serviços e regras de negócios que compõem o núcleo do aplicativo.

O objetivo da camada de domínio é encapsular o conhecimento do aplicativo de uma forma que seja facilmente testável, reutilizável e independente de qualquer infraestrutura ou tecnologia específica. Essa camada não deve depender de componentes externos, como bancos de dados ou APIs, devendo apenas interagir com eles por meio de abstrações. Manter a camada de domínio livre de problemas de infraestrutura facilita a alteração ou substituição de componentes de infraestrutura sem afetar a lógica de negócios do aplicativo.

A camada de domínio - Domain - é um projeto Class Library localizado dentro da pasta Core. e não tem referência à outra camada.

Vamos criar no projeto Domain a pasta Entities contendo a classe User e a pasta Common contendo a classe BaseEntity:

  1. Camada Domain:

1- User

using CleanArchitecture.Domain.Common;

namespace CleanArchitecture.Domain.Entities;

public sealed class User : BaseEntity
{
 
public string? Email { get; private set; }
 
public string? Name { get; private set; }
}

2- BaseEntity

namespace CleanArchitecture.Domain.Common;

public abstract class BaseEntity
{
 
public Guid Id { get; set; }
 
public DateTimeOffset DateCreated { get; set; }
 
public DateTimeOffset? DateUpdated { get; set; }
 
public DateTimeOffset? DateDeleted { get; set; }
}

A seguir vamos criar a pasta Interfaces na camada Domain e nesta pasta vamos criar as interfaces IUserRepository, IBaseRepository e IUnitOfWork.

A definição da interface IUserRepository deve estar na camada Domain pois o repositório é um conceito relacionado à persistência de dados e está mais ligado à estrutura das entidades do domínio do que às operações de aplicação em si.

using CleanArchitecture.Domain.Entities;

namespace CleanArchitecture.Domain.Interfaces;

public interface IUserRepository : IBaseRepository<User>
{
   Task<User> GetByEmail(
string email, CancellationToken cancellationToken);
}

A definição da interface IBaseRepository também deve estar na camada Domain pois essa interface é uma abstração genérica para operações de CRUD básicas e, portanto, está mais relacionada ao domínio do que às operações específicas de aplicação.

using CleanArchitecture.Domain.Common;

namespace CleanArchitecture.Domain.Interfaces;

public interface IBaseRepository<T> where T : BaseEntity
{
 
void Create(T entity);
 
void Update(T entity);
 
void Delete(T entity);
  Task<T> Get(Guid id, CancellationToken cancellationToken);
  Task<List<T>> GetAll(CancellationToken cancellationToken);
}

A definição da interface IUnitOfWork deve estar na camada Domain. O padrão Unit of Work é um conceito que está mais relacionado à coordenação das operações de persistência e transações, o que se encaixa bem na camada Domain.

namespace CleanArchitecture.Domain.Interfaces;

public interface IUnitOfWork
{
   Task Commit(CancellationToken cancellationToken);
}

Poderíamos também ter criado estas interfaces na camada Application dependendo dos requisitos e do cenário mas de maneira geral a definição das interfaces é feita no projeto Domain.

Camada de aplicação

A camada de aplicação é um componente da Clean Architecture que atua como uma ponte entre a camada de domínio e as interfaces externas de uma aplicação, como a camada de apresentação ou camada de acesso a dados. Esta camada coordena as interações entre a camada de domínio e os componentes externos e transforma os dados entre as diferentes camadas.

A camada de aplicativo contém serviços de aplicativo e classes que contêm a lógica de negócios do aplicativo. Esses serviços interagem com a camada de domínio para executar tarefas como criar ou atualizar entidades ou invocar serviços de domínio. A camada de aplicação também atua como um intermediário entre a camada de domínio e a camada de apresentação ou camada de acesso a dados, traduzindo objetos de domínio em objetos de apresentação ou objetos de acesso a dados e vice-versa.

A camada de aplicação não deve conter nenhum código específico de infraestrutura e não deve depender de nenhuma tecnologia específica ou mecanismo de acesso a dados. Em vez disso, ele deve usar abstrações e interfaces para interagir com componentes externos, facilitando a alteração ou substituição sem afetar a lógica de negócios principal do aplicativo.

A camada de aplicativo é um projeto Class Library localizado dentro da pasta Core e deve referenciar a camada de domínio -Domain.

Este projeto vai conter os seguintes bibliotecas nuget:

  1. MediatR é uma biblioteca para implementar o padrão mediador em aplicações. O padrão mediador é um padrão de design de software que fornece um local centralizado para gerenciar a comunicação entre diferentes componentes em um aplicativo.
     
  2. AutoMapper é uma biblioteca que fornece uma maneira simples e flexível de mapear objetos de diferentes tipos. É comumente usado em aplicativos que precisam converter dados de um formato para outro, como de objetos de domínio para objetos de transferência de dados (DTOs) ou de DTOs para objetos de domínio.
     
  3. FluentValidation é uma biblioteca para validar objetos e garantir que eles estejam em conformidade com um conjunto de regras.

Vamos criar neste projeto as seguintes pastas:

A estrutura é mostrada na figura abaixo:

Na pasta Services definimos o método de extensão ConfigureApplication que estende IServiceCollection :

using CleanArchitecture.Application.Shared.Behavior;
using
FluentValidation;
using
MediatR;
using
Microsoft.Extensions.DependencyInjection;
using
System.Reflection;

namespace CleanArchitecture.Application.Services;

public static class ServiceExtensions
{
 
public static void ConfigureApplication(this IServiceCollection services)
  {
    services.AddAutoMapper(Assembly.GetExecutingAssembly());
    services.AddMediatR(Assembly.GetExecutingAssembly());
    services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
    services.AddTransient(
typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
  }
}

Esse método é usado para configurar diversos serviços e comportamentos relacionados à aplicação, incluindo o uso do MediatR, AutoMapper e FluentValidation. Vou explicar cada parte do código:

  1. services.AddAutoMapper(Assembly.GetExecutingAssembly()): Configura o AutoMapper para mapear objetos entre diferentes tipos. Ele usa o assembly atualmente em execução (que é o assembly onde está o código da aplicação) para identificar os tipos que precisam ser mapeados.
  2. services.AddMediatR(Assembly.GetExecutingAssembly()): Configura o MediatR, uma biblioteca de mediação de solicitações e respostas. Ela registra todos os manipuladores, solicitações e respostas definidas no assembly atualmente em execução.
  3. services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()): Registra automaticamente todos os validadores definidos no assembly atualmente em execução. Ele é usado com a integração do FluentValidation para adicionar os validadores à injeção de dependência.
  4. services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)): Registra o comportamento personalizado ValidationBehavior como um serviço de injeção de dependência. Esse comportamento é usado para realizar a validação das solicitações antes de serem processadas pelos manipuladores. A notação typeof(IPipelineBehavior<,>) indica que estamos registrando um comportamento genérico para todas as solicitações e respostas.

A pasta Shared contém as pastas Behavior e Exceptions. Nesta pasta definimos recursos comuns como helpers, exceptions e comportamentos, etc.

1- Behavior/ValidationBehavior

using CleanArchitecture.Application.Shared.Exceptions;
using
FluentValidation;
using
MediatR;

namespace CleanArchitecture.Application.Shared.Behavior;

public sealed class ValidationBehavior<TRequest, TResponse> :
                       IPipelineBehavior<TRequest, TResponse>
                      
where TRequest : IRequest<TResponse>
  {
 
  private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
    {
       _validators = validators;
    }

   
public async Task<TResponse> Handle(TRequest request,
                                 RequestHandlerDelegate<TResponse> next,
                                 CancellationToken cancellationToken)
    {

      if (!_validators.Any()) return await next();

     
var context = new ValidationContext<TRequest>(request);

     
var errors = _validators
                  .Select(x => x.Validate(context))
                  .SelectMany(x => x.Errors)
                  .Where(x => x !=
null)
                  .Select(x => x.ErrorMessage)
                  .Distinct()
                  .ToArray();

       if (errors.Any())
          
throw new BadRequestException(errors);

      
return await next();
     }
 }

Essa classe é um comportamento (behavior) personalizado do MediatR, um padrão que permite a execução de lógica antes ou depois do processamento da solicitação por um manipulador. No caso, o ValidationBehavior é responsável por executar a validação dos dados de entrada de uma solicitação antes de passá-la para o manipulador apropriado.

Vamos entender o código:

  1. A classe ValidationBehavior é genérica e implementa a interface IPipelineBehavior<TRequest, TResponse>, que é usada para definir comportamentos personalizados em um pipeline de manipulação de requisições;
  2. Ela recebe uma coleção de validadores IValidator<TRequest> no construtor. Esses validadores serão usados para validar a solicitação antes de prosseguir para o manipulador.
  3. O método Handle é o ponto onde o comportamento personalizado é executado. Ele recebe a solicitação, um delegado next que representa o próximo estágio no pipeline (normalmente, o manipulador real) e um token de cancelamento.
  4. Antes de executar a lógica do manipulador, o comportamento verifica se existem validadores disponíveis. Se não houver nenhum, ele pula a validação e passa diretamente para o próximo estágio.
  5. Se houver validadores, o comportamento cria um contexto de validação ValidationContext<TRequest> para a solicitação atual.
  6. Em seguida, ele executa cada validador no contexto e coleta os erros de validação retornados por esses validadores.
  7. Os erros coletados são filtrados para remover erros nulos e duplicados.
  8. Se houver algum erro de validação, o comportamento lança uma exceção BadRequestException, que parece ser uma exceção personalizada para indicar que houve problemas nos dados de entrada da solicitação.
  9. Se não houver erros de validação, o comportamento chama o próximo estágio no pipeline (ou seja, o manipulador real) usando await next().

2- Exceptions/BadRequestException

public class BadRequestException : Exception
{
 
public BadRequestException(string message) : base(message)
  {}

   public BadRequestException(string[] errors) : 
         
base("Multiple errors occurred. See error details.")
   {
     Errors = errors;
   }
   
public string[]? Errors { get; set; }
}

3- Exceptions/NotFoundException

public class NotFoundException : Exception
{
  
public NotFoundException(string message) : base(message)
   {}
}

Na pasta UseCases/Features/CreateUser temos a implementação dos casos de uso para criar usuário e obter todos os usuários usando a biblioteca MediatR para implementar um manipulador de solicitações (handler) que lida com a criação de um usuário.

  1. CreateUserHandler: Esta é uma classe que implementa IRequestHandler<CreateUserRequest, CreateUserResponse>, o que significa que ela lida com uma solicitação do tipo CreateUserRequest e retorna uma resposta do tipo CreateUserResponse. O manipulador cria um usuário com base nos dados fornecidos na solicitação, usa o repositório de usuário para criar o usuário e, em seguida, salva as mudanças por meio do IUnitOfWork. Finalmente, retorna a resposta mapeada.
  2. CreateUserRequest: Esta é uma classe de registro (record) que define os dados necessários para criar um usuário. Ela herda de IRequest<CreateUserResponse>, indicando que é uma solicitação que produzirá uma resposta CreateUserResponse.
  3. CreateUserResponse: Esta é uma classe de registro (record) que define a resposta após criar um usuário. Ela contém as informações relevantes do usuário, como Id, Email e Name.
  4. CreateUserMapper: Esta é uma classe que herda de Profile do AutoMapper. Ela define os mapeamentos necessários para converter objetos entre CreateUserRequest e User, bem como entre User e CreateUserResponse.
  5. CreateUserValidator: Esta é uma classe que define as regras de validação para a solicitação CreateUserRequest. Ela usa o FluentValidation para garantir que os dados fornecidos na solicitação estejam corretos e atendam aos critérios definidos.

A seguir o código definido nestas classes:

1- CreateUserHandler

using AutoMapper;
using
CleanArchitecture.Domain.Entities;
using
CleanArchitecture.Domain.Interfaces;
using
MediatR;

namespace CleanArchitecture.Application.UseCases.Features.CreateUser;

public sealed class CreateUserHandler : IRequestHandler<CreateUserRequest, CreateUserResponse>
{
  
private readonly IUnitOfWork _unitOfWork;
  
private readonly IUserRepository _userRepository;
  
private readonly IMapper _mapper;

  
public CreateUserHandler(IUnitOfWork unitOfWork, IUserRepository userRepository, IMapper mapper)
   {
      _unitOfWork = unitOfWork;
      _userRepository = userRepository;
      _mapper = mapper;
   }

   public async Task<CreateUserResponse> Handle(CreateUserRequest request, CancellationToken cancellationToken)
   {
    
var user = _mapper.Map<User>(request);
     _userRepository.Create(user);
    
await _unitOfWork.Save(cancellationToken);
    
return _mapper.Map<CreateUserResponse>(user);
   }
}

2- CreateUserMapper

using AutoMapper;
using
CleanArchitecture.Domain.Entities;

namespace CleanArchitecture.Application.UseCases.Features.CreateUser;

public sealed class CreateUserMapper : Profile
{
  
public CreateUserMapper()
   {
     CreateMap<CreateUserRequest, User>();
     CreateMap<User, CreateUserResponse>();
   }
}

3- CreateUserRequest

using MediatR;

namespace CleanArchitecture.Application.UseCases.Features.CreateUser;

public sealed record CreateUserRequest(string Email, string Name) : IRequest<CreateUserResponse>;

4- CreateUserResponse

namespace CleanArchitecture.Application.UseCases.Features.CreateUser;

public sealed record CreateUserResponse
{
  
public Guid Id { get; set; }
  
public string? Email { get; set; }
  
public string? Name { get; set; }
}

5- CreateUserValidator

using FluentValidation;

namespace CleanArchitecture.Application.UseCases.Features.CreateUser;

public sealed class CreateUserValidator : AbstractValidator<CreateUserRequest>
{
 
public CreateUserValidator()
  {
    RuleFor(x => x.Email).NotEmpty().MaximumLength(50).EmailAddress();
    RuleFor(x => x.Name).NotEmpty().MinimumLength(3).MaximumLength(50);
  }
}

Na pasta UseCases/Features/GetAllUser temos a implementação dos casos de uso que sa a biblioteca MediatR para implementar um manipulador que obtém todos os usuários (getAll) e mapeia esses usuários para uma lista de respostas específicas.

  1. GetAllUserHandler: Esta é uma classe que implementa IRequestHandler<GetAllUserRequest, List<GetAllUserResponse>>, o que significa que ela lida com uma solicitação do tipo GetAllUserRequest e retorna uma lista de respostas do tipo GetAllUserResponse. O manipulador obtém todos os usuários usando o repositório de usuário e mapeia-os para uma lista de respostas.
  2. GetAllUserMapper: Esta é uma classe que herda de Profile do AutoMapper. Ela define os mapeamentos necessários para converter objetos entre GetAllUserRequest e User, bem como entre User e GetAllUserResponse.
  3. GetAllUserRequest: Esta é uma classe de registro (record) representa a solicitação para obter todos os usuários. Essa classe é parametrizada com um tipo genérico IRequest<List<GetAllUserResponse>>, indicando que esta classe de solicitação deve retornar uma lista de objetos do tipo GetAllUserResponse como resultado.
  4. GetAllUserResponse: Esta é uma classe de registro (record) que representa a resposta que será retornada quando uma solicitação para obter todos os usuários for processada.

Abaixo temos o código usado para definir estas classes:

1- GetAllUserHandler

using AutoMapper;
using
CleanArchitecture.Domain.Interfaces;
using
MediatR;

namespace CleanArchitecture.Application.UseCases.Features.GetAllUser;

public sealed class GetAllUserHandler : IRequestHandler<GetAllUserRequest, List<GetAllUserResponse>>
{
 
private readonly IUserRepository _userRepository;
 
private readonly IMapper _mapper;
 
public GetAllUserHandler(IUserRepository userRepository, IMapper mapper)
  {
     _userRepository = userRepository;
     _mapper = mapper;
  }

  public async Task<List<GetAllUserResponse>> Handle(GetAllUserRequest request, CancellationToken cancellationToken)
  {
  
 var users = await _userRepository.GetAll(cancellationToken);
   
return _mapper.Map<List<GetAllUserResponse>>(users);
  }
}

2- GetAllUserMapper

using AutoMapper;
using
CleanArchitecture.Domain.Entities;

namespace CleanArchitecture.Application.UseCases.Features.GetAllUser;

public sealed class GetAllUserMapper : Profile
{
 
public GetAllUserMapper()
  {
    CreateMap<User, GetAllUserResponse>();
  }
}

3- GetAllUserRequest

using MediatR;

namespace CleanArchitecture.Application.UseCases.Features.GetAllUser;

public sealed record GetAllUserRequest : IRequest<List<GetAllUserResponse>>;

4- GetAllUserResponse

namespace CleanArchitecture.Application.UseCases.Features.GetAllUser;

public sealed record GetAllUserResponse
{
 
public Guid Id { get; set; }
 
public string? Email { get; set; }
 
public string? Name { get; set; }
}

Na próxima parte do artigo vamos implementar os recursos da infraestrutura na pasta Infrastructure.

E estamos conversados...

"Não saia da vossa boca nenhuma palavra torpe, mas só a que for boa para promover a edificação, para que dê graça aos que a ouvem."
Efésios 4:29

Referências:


José Carlos Macoratti