Design: Monolitos Modulares - Parte 3

William Santos - May 8 '23 - - Dev Community

Olá!

Este post é uma sequência de Design: Monolitos Modulares - Parte 2, e nele demonstraremos os conceitos abordados no post anterior a partir do código de uma aplicação de demonstração.

Disclaimer: este é um código didático! Portanto, recomendo fortemente que nenhuma de suas porções seja incorporada a códigos de produção. Muitas implementações foram simplificadas, não provendo as funcionalidades necessárias ao ambiente produtivo.

Vamos lá!

A Anatomia da Solução

Junto às porções de código vamos relembrar, resumidamente, os conceitos apresentados até aqui.

Conforme vimos na Parte 2, nossa aplicação de exemplo tem o propósito de permitir o envio de ordens de negociação de ações à bolsa de valores. Para isso, precisamos de uma conta corrente que proveja os recursos necessários para compar ações, de uma carteira (portfólio) para armazenar as ações compradas e permitir sua venda e, por fim, uma integração com a bolsa para sermos notificados sobre a execução da ordem, ou cancelá-la a pedido do cliente.

Na imagem abaixo vemos como a solução foi organizada:

Image description

Temos um assembly chamado Commons, onde estão tipos genéricos utilizados por diferentes projetos, mas que não possuem relação direta com os subdomínios da aplicação.

Em seguida, temos nossos módulos, cada um nomeado de acordo com seu significado no domínio, e um assembly chamado Shared, que possui alguns tipos genéricos assim como Commons mas que, diferentemente deste, possui tipos com significado no domínio compartilhados entre os módulos.

Por fim, temos o projeto da Web API, responsável por expor nossos subdomínios ao mundo externo, e tratar de questões de infraestrutura.

Vejamos agora exemplos de como esta solução atende aos requisitos de modularização.

Encapsulamento

O encapsulamento nos garante que as funcionalidades de um dado contexto estarão disponíveis a outros apenas por meio de sua API. Isso significa que teremos uma superfície de acesso que orquestrará todos os comportamentos do contexto, e que nosso modelo de domínio será acessível apenas por seu próprio assembly.

Abaixo temos um exemplo resumido sobre como o módulo de Conta Corrente lida com esta questão:

public sealed class AccountService : IAccountService
{
    private readonly IAccountStore _store;

    public Result<Account> Create(AccountId accountId, Money initialDeposit)
    {
        ...
    }

    public Balance GetBalance(AccountId accountId)
    {
        ...
    }

    public Result Credit(AccountId accountId, Money amount)
    {
        ...
    }

    public Result Debit(AccountId accountId, Money amount)
    {
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Repare que todos os métodos deste serviço são públicos, o que permite o acesso às funções de nosso modelo de domínio que desejamos expor. Ou seja, o serviço de Conta Corrente é a API de seu módulo.

Além disso, temos um contrato (interface) que define o modo como nosso modelo de domínio será armazenado, o que permite à aplicação implementar o mecanismo de persistência de acordo com o que nele foi previsto. Uma vez que o encapsulamento prevê, também, o isolamento dos dados, o serviço conhece, apenas, o contrato de armazenamento do modelo de domínio que visa expor.

Nota: Você pode ter notado que em vez de repository foi utilizado o termo store, e isso tem um bom motivo: é deixado em aberto para a implementação de infraestrutura como ela deve ser implementada e, como veremos no código, o padrão repository não foi aplicado. Esta nomenclatura é aberta o bastante para dar flexibilidade à implementação e, ao mesmo tempo, expressiva em relação a seu propósito.

Vejamos agora nosso modelo de domínio:


public sealed class Account : Entity<AccountId>
{
    public static readonly Account Default = new(AccountId.Empty, new List<Entry>());

    public Balance Balance => _entries.DefaultIfEmpty(Entry.Empty)
                                      .Sum(e => e.Value);

    private readonly IList<Entry> _entries;
    public IReadOnlyCollection<Entry> Entries => _entries.AsReadOnly();

    private Account(AccountId id, IList<Entry> entries) : base(id)
    {
        _entries = entries;
    }

    internal static Result<Account> Create(AccountId accountId, Money initialDeposit)
    {
        ...
    }

    internal Result Credit(Money amount)
    {
        ...
    }

    internal Result Debit(Money amount)
    {
        ...
    }
}

Enter fullscreen mode Exit fullscreen mode

Aqui temos uma demonstração do encapsulamento: nosso modelo de domínio é público, o que permite a outros módulos conhecer seu estado e, a partir dele, tomar decisões.
Entretanto, repare que todos os comportamentos deste modelo são internos ao assembly que o contém, o que é determinado pelo modificador de acesso internal, impedindo que outros módulos alterem seu estado preservando, assim, não apenas o encapsulamento como a integridade do estado de nosso modelo de domínio.

Modelos Ricos

O código acima nos mostra algo mais. Nesta aplicação fizemos uso dos chamados Modelos Ricos. Isso porque, desta forma, mantemos estado e comportamento juntos, o que facilita o encapsulamento, uma vez que só precisamos nos preocupar com controle de acesso em nossas Entidades e não em outros serviços que seriam responsáveis por manipular seu estado, bem como o estado de nossos Objetos de Valor (que, aliás, são imutáveis).
Desta forma aumentamos a coesão de nossos módulos tornando mais simples a compreensão de suas responsabilidades.

Obsessão por Tipos Primitivos

Um outro detalhe do código acima é que não há qualquer operação realizada com Tipos Primitivos. Todas as operações se baseiam em Objetos de Valor com significado claro para o domínio. Este é outro mecanismo importante para garantir o correto estado de nosso modelo, uma vez que cada tipo se responsabiliza pela validação do próprio estado, deixando a Entidade livre para se preocupar, apenas, com suas regras de negócio.

Nota: além dos Objetos de Valor com significado no domínio, também foram utilizadas a classe Result, sobre a qual falamos neste post, para operações que podem resultar em falhas conhecidas (como validações), e structs que implementam interface IError, para tipificar estas mesmas falhas (ambas disponíveis no assembly Commons).
Uma vez que os controllers fazem a interface com o mundo externo, haverá a tradução destes tipos para mensagens legíveis por pessoas, a fim de preservar a semântica do domínio e, ao mesmo tempo, tornar as falhas inteligíveis aos clientes da Web API.

Integração

Aqui temos a parte mais interessante da implementação: a forma como diferentes módulos podem se comunicar. Assim como dito na Parte 2 temos duas formas de comunicação: síncrona, por meio de chamada direta à API dos diferentes módulos; e assíncrona, por meio de troca de mensagens.

Chamada Direta (Síncrona)

Como dito na Parte 2, a melhor forma de se trabalhar com chamadas diretas é por meio de façades, que conheçam e coordenem o uso dos diversos módulos.

Abaixo vemos como o Controller responsável pelo envio de ordens, a partir da Web API, implementa este padrão:

public class OrdersController : ControllerBase
{
    private readonly IOrderService _orderService;
    private readonly Account.IAccountService _accountService;
    private readonly Portifolio.IPortfolioService _portfolioService;

    ...

    public IActionResult Send(SendOrderRequest request)
    {
        if (request.Side == OrderSide.Buy)
        {
            var balance = _accountService.GetBalance(Constants.DefaultAccountId);
            if (balance < (request.Quantity * request.Price))
                return Conflict("Não há recursos disponíveis para esta operação.");
        }

        if(request.Side == OrderSide.Sell)
        {
            var entry = _portfolioService.GetEntryBySymbol(Constants.DefaultAccountId, request.Symbol);
            if(entry.Quantity < request.Quantity)
                return Conflict("Não há ativos disponiveis para esta operação.");
        }

        var sendResult = _orderService.Send(Constants.DefaultAccountId, request.Side, 
                                            request.Quantity, request.Symbol,                                                       request.Price);
    }

    ...
}

Enter fullscreen mode Exit fullscreen mode

Aqui nosso controller utiliza três módulos: Conta Corrente, Portfólio e Ordens. A intenção é verificar se, no caso de uma ordem de compra, há recursos disponíveis em conta corrente e, em caso de uma ordem de venda, haja ativos disponíveis para tal.

Desta forma é possível verificar o estado de outros módulos para permitir ou não o uso da funcionalidade desejada.

Mensagens (Assíncrona)

Vamos tratar agora da segunda forma de comunicação entre módulos, a troca de mensagens. Por simplicidade, como dito na Parte 2, nossa implementação utilizará um Event Bus em memória, cujo comportamento é análogo a implementações que rodam fora do processo. Pela mesma razão, não trabalharemos com cópias dos tipos que representam os eventos, uma vez que os mesmos estão dispostos na infraestrutura da aplicação, o que não viola o encapsulamento do domínio.

Vejamos a seguir como nosso Store de ordens lida com a publicação de eventos:

public sealed class OrderStore : IOrderStore
{
    private readonly Dictionary<OrderId, Order> _orders = new();
    private readonly IEventBus _eventBus;

    public OrderStore(IEventBus eventBus)
    {
        _eventBus = eventBus;
    }

    ...

    public void AddCreated(Order order)
    {
        _orders.Add(order.Id, order);
        _eventBus.Publish(OrderCreatedEvent.From(order));
    }

    ...

    public void UpdateCanceled(Order order)
    {
        _orders[order.Id] = order;
        _eventBus.Publish(OrderCanceledEvent.Create(order.Id));
    }

Enter fullscreen mode Exit fullscreen mode

Tão logo ocorra a transação que persiste o estado de nosso modelo, neste caso nas operações de criação e cancelamento de uma ordem, um evento de integração é publicado para que outros módulos interessados tomem decisões a partir de seus valores.

Nota: Seria possível, também, publicar um evento após a execução de uma ordem (quando um negócio é fechado) mas, por simplicidade, preferi trabalhar com o menor número significativo de eventos.

Idempotência

Para evitarmos o processamento repetido de nossos manipuladores de eventos (Event Handlers) precisamos implementar um mecanismo de idempotência. Este mecanismo é implementado logo após o consumo da mensagem de evento, para evitar que uma operação que possa corromper o estado de nosso modelo de domínio seja realizada.

Vejamos um exemplo abaixo, no processamento do evento de cancelamento de uma ordem por um manipulador responsável por atualizar a conta corrente:

public sealed class OrderCanceledEventHandler : IEventHandler<OrderCanceledEvent>
{
    private readonly IList<OrderId> _handledOrderIds = new List<OrderId>();

    ...

    public void Handle(OrderCanceledEvent @event)
    {
        if (_handledOrderIds.Contains(@event.OrderId))
            return;

        var order = _orderService.GetById(@event.OrderId);
        if (order.Side == OrderSide.Sell)
            return;

        _accountService.Credit(order.AccountId, order.Quantity * order.Price);
        _handledOrderIds.Add(order.Id);
    }
}
Enter fullscreen mode Exit fullscreen mode

Com este simples mecanismo de verificação do ID de uma ordem é possível impedir que haja dois créditos na conta corrente por conta de seu cancelamento.

Nota: neste exemplo, foi implementada uma lista em memória por simplicidade. Em um cenário real, faz mais sentido utilizar uma solução de cache distribuído ou mesmo um banco de dados ou outra forma de persistência externa.

Mostre-me o Código!

Hora de ver por si mesmo os detalhes desta implementação a partir do código disponibilizado no Github. Para testar basta iniciar o projeto.
Por simplicidade, uma conta corrente é criada na inicialização da aplicação e começa com o saldo de R$ 1 milhão - então você pode simular a compra de ações à vontade!

O módulo de Conta Corrente, primeiro da lista no Swagger, te permitirá verificar seu saldo e, ao mesmo tempo, creditar novos valores.
O módulo de Ordens, o segundo, vai te permitir enviar ordens, consultar as já enviadas, conferir o estado de uma ordem específica e pedir seu cancelamento.
O módulo de Exchange (o simulador da bolsa de valores) vai te permitir verificar as ordens enviadas e fechar negócio sobre uma delas.
Por fim, o módulo de Portfólio vai te permitir verificar as ações que você tem sob custódia para saber o que pode ou não vender.

Também por simplicidade, o identificador da conta corrente foi definido em uma constante. Desta forma, não é preciso se preocupar com ele em nenhuma das operações e se concentrar em testar as regras de negócio.

Mas e os Testes?

Os testes serão abordados na Parte 4. Veremos que estamos usando um padrão bastante conhecido para implementar esta aplicação, o que vai tornar os testes mais simples e permitir uma correta verificação dos comportamentos esperados em nossos modelos de domínio.

Voltamos em Breve!

Gostou desta Parte 3? Me deixe saber pelos indicadores. Tem dúvidas ou sugestões? Deixe um comentário ou me procure pelas redes sociais.

Até a próxima!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .