DEV Community

Cover image for Design: Monolitos Modulares - Parte 3
William Santos
William Santos

Posted on

Design: Monolitos Modulares - Parte 3

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!

Top comments (13)

Collapse
 
wmscode profile image
Willian Menezes

Opa mestre! Otimo conteudo...

Tenho uma duvida, na controler voce tomou algumas decisoes para definir se eh uma ordem de compra ou venda.

Essa implementacao nao da muita responsabilidade para a controller? O Ideal nao seria ter um manipulador para gerir esse fluxo da aplicacao, deixando assim a controller apenas como um ponto de entrada e saida?

Abracos, seus artigos sao otimos...

Collapse
 
wsantosdev profile image
William Santos

Fala, xará!

Abordei a questão desta forma por simplicidade e, em boa parte dos casos é o suficiente, diga-se.
A ideia de utilizar Handlers (normalmente associados ao MediatR) acaba sendo um exagero em muitos casos porque um Controller bem implementado tende a ter poucos métodos e a não mudar com frequência.

Eu poderia ter implementado um Domain Service para injetar no Controller mas, pensa comigo: qual seria o ganho? Escrever mais código apenas pra fazer uma separação lógica numa aplicação simples faria sentido?

Essa é a pegadinha! Haha!

Muito obrigado pelo carinho de sempre. 💙

Collapse
 
wmscode profile image
Willian Menezes

Tudo vai da sensibilidade na hora de fazer o codigo entao?

Esse pensamento que voce colocou serviria tambem para aplicacoes maiores e corporativas.

Thread Thread
 
wsantosdev profile image
William Santos

Cada caso é um caso. A necessidade é quem vai ditar a complexidade técnica.

Manter-se simples vale pra toda e qualquer aplicação. O que vai variar é o momento em que aumentar a complexidade pode ser necessário.

No fim, design é sobre isso: organizar seus componentes da forma mais simples e viável possível.

Thread Thread
 
wmscode profile image
Willian Menezes

É isso... valeu pelas dicas, tamo junto!

Collapse
 
thiagochfc profile image
Thiago Christopher

Excelente conteúdo!

Você conseguiria abordar no futuro uma diferença clara entre evento de domínio e evento de integração e como ela se comportaria nessa aplicação? Acredito que seria um conteúdo incrível para sintetizar os conhecimentos.

Collapse
 
wsantosdev profile image
William Santos

Fala, Thiago. Tudo bom?
Muito obrigado pelo comentário.

Esta questão já está prevista para a minha série, que vai se chamar Modelagem de Domínios (ou algo do tipo. Haha!). A intenção é mostrar, do início ao fim, os aspectos de uma boa modelagem partindo do DDD.

Exemplificando a partir da demo deste post, e bem a grosso modo, o que posso dizer é que eventos de domínio são aqueles que afetam diretamente o estado de entidades dentro de um mesmo domínio (contexto delimitado / bounded context). Sua principal característica do ponto de vista da aplicação, é que são disparados e ouvidos no mesmo processo (em memória).

Já os eventos de integração são aqueles que tem por finalidade disponibilizar informações para outros contextos delimitados e, não raro, são aqueles que costumam ser propagados por meio de mensageria, fora do processo da aplicação (o que foi simulado na demo, por exemplo, quando uma Ordem é criada e a Exchange precisa ficar sabendo).

Ficou claro? Qualquer coisa, fica à vontade pra perguntar.

Valeu!

Collapse
 
thiagochfc profile image
Thiago Christopher

Opa, William Santos. Tudo sim e você?

Consegui compreender e fez sentido sim.

Aguardando essa sua nova série, é um assunto em particular que eu tenho muito interesse.

Mais uma dúvida, a Order possui alguns status que foram representados na própria classe mas não poderia ser do tipo Enum?

Thread Thread
 
wsantosdev profile image
William Santos

Fala, Thiago! Tudo em ordem por aqui.

Boa pergunta. E a resposta é: sim, mas melhor não.

Embora um enum seja uma opção viável, ele apresenta dois problemas:

  1. É preciso sempre se lembrar de usar o atributo Description, para que seu valor numérico não seja usado no método ToString.
  2. Não é possível adicionar propriedades e comportamentos a um enum. Assim sendo, caso algum dado complementar seja necessário ao OrderStatus, por exemplo, seria impossível adicionar.

Esta implementação é um pattern chamado enum type (ou enumeration classes), e você pode conferir mais detalhes no link abaixo:

lostechies.com/jimmybogard/2008/08...

Valeu! ✌🏾

Collapse
 
rafaelporto profile image
Rafael Monteiro Porto

Muito bom post, Will! Uma pergunta: o que você acha de mover os controllers da API para dentro dos módulos, mantendo todo o código concentrado? Desta forma o módulo poderia mais facilmente evoluir para um sistema deparado, caso necessário.

Collapse
 
wsantosdev profile image
William Santos

Fala, Rafa! Tudo bom?
Muito obrigado pela comentário, e é uma ótima pergunta!

Não incluir os Controllers nos módulos foi uma decisão arbitrária considerando flexibilidade. Ou seja, da forma como estão implementados, os módulos podem ser utilizados em qualquer tipo de aplicação (Web, Desktop, CLI etc).

Não é um problema incluir os Controllers nos módulos quando já está definido que a aplicação será exclusivamente Web. Entretanto, faço uma observação: é preciso ter cuidado na interação entre os módulos. Porque, se o Controller for utilizado como ponto de entrada no lugar de serviços, haverá acoplamento entre eles em situações onde um serviço de domínio seria necessário (como no envio de ordens do exemplo).

Portanto, é preciso conhecer bem os casos de uso para entender se essa abordagem faz sentido.

Valeu!

Collapse
 
wiliambuzatto profile image
Wiliam Buzatto

Boa!

Collapse
 
wsantosdev profile image
William Santos

Valeu, xará! ✌🏾💙