.NET - Usando a abordagem DDD : Padrões e práticas - III


 Hoje veremos como podemos usar a abordagem Domain Driven Design na plataforma .NET apresentando os padrões e práticas relacionadas.

Continuando a segunda parte do artigo vamos apresentar as práticas e padrões mais usadas nas camadas Application e Infrastructure.

Application : Casos de uso , CQRS e MediatR

A camada Application na abordagem DDD serve como ponte entre a camada de domínio e as camadas externas do sistema, como a interface do usuário ou serviços externos. Ela é responsável por implementar casos de uso, coordenar tarefas e lidar com questões transversais como validação e autorização. Neste artigo, discutiremos como implementar a camada Application  abordaremos conceitos-chave como comandos, consultas, manipuladores e o padrão mediador.

Na abordagem DDD, a camada Application ou camada de aplicação é responsável pelas seguintes tarefas:

  1. Coordenação das ações do aplicativo: A camada Application coordena a execução das operações do aplicativo, decidindo quais casos de uso específicos devem ser acionados em resposta a ações do usuário ou eventos externos.
     
  2. Mapeamento de DTOs (Data Transfer Objects): Ela lida com a conversão de dados entre os objetos do domínio e os objetos de transferência de dados (DTOs) usados para comunicação com a interface do usuário ou sistemas externos. Isso ajuda a manter o domínio isolado de detalhes de como os dados são apresentados ou consumidos.
     
  3. Gerenciamento de transações: Em muitos casos, a camada Application é responsável por gerenciar transações e garantir a consistência dos dados durante operações complexas que envolvem várias etapas.
     
  4. Regras de negócios de alto nível: A camada Application pode conter regras de negócios de alto nível que não se encaixam bem no domínio, mas que não são específicas da interface do usuário ou da infraestrutura. Essas regras podem ser aplicadas aqui para garantir que as operações do aplicativo atendam aos requisitos de negócios.
     
  5. Segurança e autorização: A camada Application também é responsável por gerenciar a segurança e a autorização, garantindo que os usuários tenham permissão para realizar determinadas ações.
     
  6. Tratamento de exceções e erros: Ela lida com o tratamento de exceções e erros que ocorrem durante a execução das operações do aplicativo, convertendo-os em mensagens significativas para o usuário ou registrando logs para fins de depuração.
     
  7. Chamadas à camada de Infraestrutura: A camada Application pode fazer chamadas à camada de Infraestrutura para interagir com serviços externos, como bancos de dados, serviços da web, sistemas de mensagens, etc.

Os casos de uso na camada de aplicação podem ser implementados como comandos e consultas. Os comandos representam ações que modificam o estado do sistema, enquanto as consultas recuperam dados sem causar efeitos colaterais.

Na plataforma .NET, Comandos e Consultas podem ser implementados como classes, com classes Handler correspondentes responsáveis por executar o comportamento desejado. Os manipuladores interagem com objetos e serviços de domínio, bem como com componentes de infraestrutura, como repositórios, para atender ao caso de uso.

Exemplo de Comando:

// Command
public class UpdateCustomerEmailCommand : IRequest
{
    public Guid CustomerId { get; set; }
    public string NewEmail { get; set; }
}

// Handler
public class UpdateCustomerEmailHandler : IRequestHandler<UpdateCustomerEmailCommand>
{
    private readonly ICustomerRepository _customerRepository;

    public UpdateCustomerEmailHandler(ICustomerRepository customerRepository)
    {
        _customerRepository = customerRepository;
    }

    public async Task<Unit> Handle(UpdateCustomerEmailCommand request, CancellationToken cancellationToken)
    {
        var customer = await _customerRepository.GetByIdAsync(request.CustomerId);
        if (customer == null)
        {
            throw new NotFoundException("Customer not found.");
        }

        customer.UpdateEmail(request.NewEmail);
        await _customerRepository.UpdateAsync(customer);

        return Unit.Value;
    }
}

Exemplo de consulta:

// Classe de Consulta para obter informações do cliente
public class GetCustomerQuery
{
    public int CustomerId { get; set; }
}
// Manipulador (Handler) para a consulta
public class GetCustomerQueryHandler
{
    private readonly ICustomerRepository _customerRepository;
    public GetCustomerQueryHandler(ICustomerRepository customerRepository)
    {
        _customerRepository = customerRepository;
    }
    public Customer Handle(GetCustomerQuery query)
    {
        // Recupera o cliente pelo Id
        return _customerRepository.GetById(query.CustomerId);
    }
}

Preocupações transversais

Preocupações transversais como validação e autorização podem ser tratadas usando middleware, filtros ou decoradores na camada de aplicação. Por exemplo, a validação de Comandos e Consultas pode ser realizada utilizando a biblioteca FluentValidation, que permite aos desenvolvedores definir regras de validação em uma classe separada, tornando o código mais fácil de manter e testável.

Como exemplo de uso de Fluent Validation temos a seguir a definição de uma validação para o comando para criar um Customer:

using FluentValidation;

public class CreateCustomerCommandValidator : AbstractValidator<CreateCustomerCommand>
{
    public CreateCustomerCommandValidator()
    {
        RuleFor(command => command.Name)
            .NotEmpty().WithMessage("O nome do cliente é obrigatório.")
            .MaximumLength(100).WithMessage("O nome do cliente não pode ter mais de 100 caracteres.");

        RuleFor(command => command.Email)
            .NotEmpty().WithMessage("O email do cliente é obrigatório.")
            .MaximumLength(100).WithMessage("O email do cliente não pode ter mais de 100 caracteres.")
            .EmailAddress().WithMessage("O email do cliente não é válido.");
    }
}

Lembrando que para usar a Fluent Validation devemos instalar o respectivo pacote nuget e fazer o registro no container DI:


builder.Services.AddControllers()
        .AddFluentValidation(fv => fv.RegisterValidatorsFromAssemblyContaining<Application>());    

 

A seguir  podemos usar os validadores criados para validar os modelos nas ações dos controladores. Aqui está um exemplo de um controlador que usa a validação:

[ApiController]
[Route("api/customers")]
public class CustomerController : ControllerBase
{
    private readonly IValidator<Customer> _customerValidator;

    public CustomerController(IValidator<Customer> customerValidator)
    {
        _customerValidator = customerValidator;
    }


    [HttpPost]
    public IActionResult CreateCustomer([FromBody] Customer customer)
    {
        var validationResult = _customerValidator.Validate(customer);

        if (!validationResult.IsValid)
        {
            return BadRequest(validationResult.Errors);
        }


        // Se a validação passar, continue com a lógica de criação do cliente.
        // ...

    }
}

O Padrão Mediador

O Padrão Mediador é um padrão de design comportamental que promove acoplamento fraco entre objetos centralizando a comunicação entre eles. No contexto da camada de aplicação, o Padrão Mediador pode ser usado para dissociar a execução de Comandos e Consultas de sua implementação real.

Uma biblioteca popular que implementa o padrão Mediator é a biblioteca MediatR que simplifica o processo de envio e manipulação de comandos e consultas, permitindo que os desenvolvedores se concentrem na implementação dos casos de uso sem se preocupar com a fiação entre os diferentes componentes.

A seguir temos um exemplo de como usar o MediatR com o controlador CustomerController:

Primeiro, temos que instalar o pacote NuGet MediatR para configurar a injeção de dependência.

 dotnet add package MediatR
 dotnet add package MediatR.Extensions.Microsoft.DependencyInjection

A seguir temos que configurar o MediatR :

builder.Services.AddMediatR(typeof(Startup));

Agora podemos criar a classe CreateCustomerCommand :

public class CreateCustomerCommand : IRequest<Customer>
{
    public string Name { get; set; }
    public string Email { get; set; }
}

Em seguida, vamos criar um manipulador CreateCustomerCommandHandler para o comando CreateCustomerCommand que será responsável por realizar a lógica de criação do cliente.

public class CreateCustomerCommandHandler : IRequestHandler<CreateCustomerCommand, Customer>
{
    private readonly ICustomerRepository _customerRepository;

    public CreateCustomerCommandHandler(ICustomerRepository customerRepository)
    {
        _customerRepository = customerRepository;
    }

    public async Task<Customer> Handle(CreateCustomerCommand request, CancellationToken cancellationToken)
    {
        var newCustomer = new Customer
        {
            Name = request.Name,
            Email = request.Email
        };

        _customerRepository.Add(newCustomer);

        return newCustomer;
    }
}

Agora vamos criar uma classe de comando que representa a ação de criação do cliente. Vamos chamá-lo de CreateCustomerCommand.

Agora, podemos atualizar o controlador para usar o MediatR e executar o comando:

[ApiController]
[Route("api/customers")]
public class CustomerController : ControllerBase
{
    private readonly IMediator _mediator;
    public CustomerController(IMediator mediator)
    {
        _mediator = mediator;
    }
    [HttpPost]
    public async Task<IActionResult> CreateCustomer([FromBody] CreateCustomerCommand command)
    {
        var customer = await _mediator.Send(command);
        return CreatedAtAction(nameof(GetCustomer), new { customerId = customer.Id }, customer);
    }
    [HttpGet("{customerId}")]
    public IActionResult GetCustomer(int customerId)
    {
        // Lógica para recuperar um cliente específico
        // ...
    }
}

Aqui, o controlador usa o IMediator para enviar o comando CreateCustomerCommand.

O MediatR cuida de localizar o manipulador apropriado (no caso, CreateCustomerCommandHandler) e executar a lógica de criação do cliente. Quando a criação do cliente é bem-sucedida, o controlador retorna um código de status 201 (Created) com um cabeçalho de localização para o recurso recém-criado.

Infrastruture :  Persistência e serviços externos

A camada de infraestrutura é responsável por fornecer serviços técnicos e implementações que suportam as camadas superiores do sistema, como mecanismos de persistência, integrações de serviços externos e configuração.

A camada de infraestrutura desempenha um papel crucial na implementação de DDD realizando as seguintes operações

- Implementar lógica de persistência e acesso a dados para objetos de domínio;
- Integrar com serviços externos, como filas de mensagens e APIs de terceiros;
- Fornecer serviços técnicos e utilitários, como registro, armazenamento em cache e configuração;

Na plataforma .NET , a persistência pode ser implementada usando ferramentas ORM como Entity Framework Core, que permite aos desenvolvedores trabalhar com objetos de domínio e mapeá-los para o esquema de banco de dados subjacente. O Entity Framework Core fornece um conjunto de abstrações e convenções para implementar repositórios, gerenciar migrações de banco de dados e consultar dados.

Exemplo de classe de contexto que herda de DbContext do EF Core:

public class ApplicationDbContext : DbContext
{
    public DbSet<Customer> Customers { get; set; }

    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {   }
    
}

Na camada de infrastructure também são implementados os repositórios cujas interfaces foram definidos no projeto Domain.

Lidando com integração de serviços externos

A integração de serviços externos, como filas de mensagens e APIs de terceiros, pode ser implementada usando HttpClient ou bibliotecas de cliente dedicadas. É uma boa prática criar classes de cliente personalizadas que encapsulam a lógica de comunicação e fornecem uma abstração limpa para a camada de aplicação interagir com esses serviços.

Exemplo:

ublic interface IExternalServiceClient
{
    Task<ExternalServiceData> GetDataAsync();
}

public class ExternalServiceClient : IExternalServiceClient
{
    private readonly HttpClient _httpClient;

    public ExternalServiceClient(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<ExternalServiceData> GetDataAsync()
    {
        var response = await _httpClient.GetAsync("/api/data");
        response.EnsureSuccessStatusCode();
        var content = await response.Content.ReadAsStringAsync();
        return JsonConvert.DeserializeObject<ExternalServiceData>(content);
    }
}

Neste exemplo, a classe ExternalServiceClient encapsula o HttpClient e fornece um método simples para recuperar dados de um serviço externo.

Integração de eventos de domínio para escalabilidade

Os eventos de domínio são um mecanismo poderoso em DDD que permite acoplamento fraco e melhora a escalabilidade do sistema. Eles representam ocorrências importantes dentro do domínio e são acionados por agregados quando ocorre uma mudança de estado.

Benefícios do uso de eventos de domínio

Os eventos de domínio oferecem diversas vantagens em uma implementação DDD como :

- Promover o acoplamento fraco, permitindo que os componentes reajam aos eventos sem estarem diretamente vinculados à fonte
- Melhorar a escalabilidade permitindo que os eventos sejam processados de forma assíncrona ou em paralelo
- Encapsular preocupações transversais e efeitos colaterais, como envio de notificações ou atualização de sistemas externos

Existem diferentes abordagens para implementar o envio e tratamento de eventos de domínio, como:

Em processo: Os eventos são despachados e tratados dentro do mesmo processo, normalmente usando um mediador ou um barramento de eventos. Essa abordagem é mais simples e fornece menor latência, mas pode ser menos escalonável.

Fora do processo: os eventos são enviados para corretores de mensagens externos ou arquiteturas orientadas a eventos, como Apache Kafka ou Azure Event Grid. Essa abordagem oferece melhor escalabilidade e tolerância a falhas, mas introduz complexidade e latência adicionais.

A implementação de eventos de domínio em C# envolve a criação de classes de eventos, editores, assinantes e manipuladores. Os exemplos de código a seguir demonstram como implementar um mecanismo de envio e manipulação de eventos de domínio em processo.

public interface IDomainEvent
{
    DateTime OccurredOn { get; }
}

public class CustomerEmailChangedEvent : IDomainEvent
{
    public Customer Customer { get; }
    public string OldEmail { get; }
    public DateTime OccurredOn { get; }

    public CustomerEmailChangedEvent(Customer customer, string oldEmail)
    {
        Customer = customer;
        OldEmail = oldEmail;
        OccurredOn = DateTime.UtcNow;
    }
}

Implementando um evento publicador :

public interface IDomainEventPublisher
{
    Task PublishAsync<TEvent>(TEvent domainEvent) where TEvent : IDomainEvent;
}
public class InProcessDomainEventPublisher : IDomainEventPublisher
{
    private readonly IServiceProvider _serviceProvider;
    public InProcessDomainEventPublisher(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }
    public async Task PublishAsync<TEvent>(TEvent domainEvent) where TEvent : IDomainEvent
    {
        var handlers = _serviceProvider.GetServices<IDomainEventHandler<TEvent>>();
        foreach (var handler in handlers)
        {
            await handler.HandleAsync(domainEvent);
        }
  }

Implementando um manipulador de evento de dominio:

public interface IDomainEventHandler<in TEvent> where TEvent : IDomainEvent
{
    Task HandleAsync(TEvent domainEvent);
}
public class CustomerEmailChangedEventHandler : IDomainEventHandler<CustomerEmailChangedEvent>
{
    public Task HandleAsync(CustomerEmailChangedEvent domainEvent)
    {
        // Handle the event, e.g., send a notification or update an external system
        Console.WriteLine($"Customer email changed from {domainEvent.OldEmail} to {domainEvent.Customer.Email}");
        return Task.CompletedTask;
    }
}

Disparando o evento e manipulando :

// Atualiza o email e dispara o evento no domain
customer.UpdateEmail(newEmail);
await _domainEventPublisher.PublishAsync(new CustomerEmailChangedEvent(customer, oldEmail));

// Registra o event publisher e handler no containeir DI
services.AddSingleton<IDomainEventPublisher, InProcessDomainEventPublisher>();
services.AddTransient<IDomainEventHandler<CustomerEmailChangedEvent>, CustomerEmailChangedEventHandler>();

Neste exemplo, o CustomerEmailChangedEvent é acionado quando o e-mail de um cliente é atualizado e o CustomerEmailChangedEventHandler trata o evento enviando uma notificação ou atualizando um sistema externo. O editor e o manipulador de eventos são registrados no contêiner de injeção de dependência para garantir que sejam resolvidos adequadamente em tempo de execução.

E estamos conversados...

"E Jesus lhe disse: Vai, a tua fé te salvou. E logo viu, e seguiu a Jesus pelo caminho."
Marcos 10:52

Referências:


José Carlos Macoratti