Modular Monoliths — parte 3 | Anatomia de um módulo e modelagem de domínio

Luiz Costa
Beep Engineering
Published in
11 min readSep 1, 2021

--

No último post desta série, mostramos como Domain Driven Design nos ajuda no trabalho de identificação de módulos por meio de Bounded Contexts. No post de hoje, vamos entender melhor como é organizado um módulo e como podemos trabalhar modelagem de domínio para resolver os problemas de negócio de maneira isolada.

1. A anatomia de um Bounded Context

Utilizamos o Bounded Context como ferramenta conceitual, que nos permite definir um limite no uso da linguagem que é falada pelos especialistas de domínio. Esse limite vai nos ajudar a organizar e definir a semântica dos termos usados no vocabulário. Limite é uma palavra interessante porque, no campo conceitual, parece simples estabelecê-lo. Mas, na prática, como estabelecemos esse “limite”? Quando falamos na prática, queremos dizer, em última instância, no código.

Na implementação de Modular Monoliths que fazemos aqui na Beep, um Bounded Context é implementado por um módulo. Ou seja, no código, vai ter um módulo que é a implementação “concreta” de um Bounded Context. É por meio desse módulo que definimos esse limite.

Todo módulo tem uma organização que segue uma espécie de padrão. Neste post, vamos chamá-la de “anatomia”. A anatomia de um módulo nos ajuda a organizar os papéis dos objetos que fazem parte dele e como eles se relacionam para completar suas tarefas.

A imagem mostra como um módulo é organizado e alguns dos papéis que seus objetos assumem

Basicamente, todo módulo tem uma estrutura muito parecida com a imagem acima. Separamos as classes com base no padrão Layers, onde cada layer tem responsabilidades bem definidas. Para os módulos, normalmente utilizamos 3 tipos de camadas: application, domain e infrastructure.

As camadas que implementamos

Camada de aplicação

Os objetos que fazem parte da camada de aplicação (application) são responsáveis por coordenar a execução das lógicas de negócio e expor as capacidades do módulo para o mundo exterior. Esses objetos, normalmente, são implementação do design Pattern Command. Um comando da nossa aplicação representa um caso de uso que necessita ser executado pelo módulo. Por exemplo, no módulo de Vacinação, podemos ter o caso de uso Registrar aplicação de uma Vacina. Para implementação desse caso de uso, vamos ter um comando, provavelmente, com o nome RegistrarAplicacaoDeVacina no código. O mais importante a destacar aqui é que a camada de aplicação não sabe sobre como registrar a aplicação de uma vacina, apenas coordena os objetos necessários.

Camada de domínio

Os objetos dessa camada são responsáveis por representar os conceitos de negócio, seu estado e as suas regras. É nessa camada que são implementadas todas as regras diretamente relacionadas com o problema de negócio em si. Por exemplo, o código que resolve o problema de negócio “deve ser possível aplicar descontos diferentes para tipos de clientes diferentes” está escrito nessa camada, em diferentes objetos que modelam esse problema. Na nossa implementação de Modular Monoliths, todo o nosso código dá ênfase à camada de domínio. É aqui que vamos tomar os maiores cuidados com o design.

Um aspecto importante da modelagem de domínio que vamos discutir é o isolamento. Um bom modelo de domínio deve ser capaz de acomodar mudanças mais facilmente, se ele for isolado de outras partes, porque isso facilita muito o trabalho. Quando falamos de limite, estamos falando, também, de isolamento.

Camada de infraestrutura

Os objetos dessa camada são responsáveis por fornecer a capacidade técnica que suporta as outras camadas. Então, é aqui que vivem os objetos responsáveis por acessar banco de dados, enviar e-mails, logs, integrações com sistemas externos etc. Os objetos mais comuns por aqui são serviços de infraestrutura, DAOs, Loggers etc.

A organização do código em módulos e separação em camadas nos ajuda a manter as responsabilidades e o papel dos objetos bem definidos. O que nos ajuda a estabelecer e controlar o uso da linguagem, e o significado dos termos dentro de um módulo, é o controle do acoplamento e o encapsulamento forte. No final, o que precisamos nos atentar é como não deixar vazar os conceitos de negócio que estão definidos dentro de um módulo para outro.

2. O modelo de domínio

O modelo de domínio é uma representação dos conceitos de negócio que precisam ser modelados e implementados dentro de um software. Martin Fowler descreveu no seu famoso livro, PoEAA, um pattern chamado de Domain Model. De acordo com ele, para determinados domínios, cuja complexidade é alta (e aqui, entendemos como complexidade alta a quantidade de relações que esses objetos podem ter entre si), criar um conjunto de objetos interconectados, com suas responsabilidades bem definidas, pode auxiliar a representar o significado desse modelo e ajudar a lidar com as mudanças de maneira mais fácil. Lembrando que um domain model vive na camada de domínio da aplicação.

2.1. Características de um modelo de domínio

Modelo Rico

No nosso caso, trabalhamos com Ruby, uma linguagem orientada a objetos. Então, nesse caso, para tirar proveito do paradigma, uma característica importante dos objetos que fazem parte do domain model é que eles terão dados+comportamento juntos. Isso significa que os objetos de domínio não são apenas um conjunto de dados. Para ilustrar essa característica, vejamos um exemplo. Dentro do contexto de vacinação, temos a seguinte história de usuário:

Uma enfermeira precisa registrar a aplicação de uma dose de vacina em um paciente e anotar na carteira de vacinação a data, a dose e a vacina aplicada.

Nessa história de usuário, podemos destacar alguns elementos importantes: Enfermeira, Dose, Vacina, Paciente e Carteira de Vacinação. A figura abaixo mostra um possível modelo de domínio para resolução desse problema:

Essa é apenas uma proposta de algumas possibilidades para modelagem desse problema de domínio. É importante ressaltar que tem uma simplificação aqui, mas, para a proposta deste post, já é suficiente para demonstrar algumas coisas:

  1. As classes que estão modelando o domínio têm responsabilidades definidas, e isso inclui o comportamento. Apesar de termos a classe dose sem nenhum método específico ainda, nos outros objetos podemos observar os comportamentos, representados aqui por meio dos métodos, nas classes.
  2. As classes são nomeadas de acordo com o vocabulário utilizado na comunicação da história. Nesse caso, podemos perceber: Enfermeira, Vacina, Paciente, Dose e Carteira de Vacinação destacados no modelo.

É importante ressaltar a importância de ter esses objetos com dados e comportamentos juntos. Um modelo rico de objetos te permite controlar melhor o encapsulamento e, consequentemente, melhorar a sua capacidade de suportar mudanças. Fowler chama os modelos, em que os objetos não têm comportamento, de Anemic Domain Model. Uma discussão interessante sobre o assunto pode ser encontrada nesta referência aqui: AnemicDomainModel.

O modelo de objetos não precisa ser igual ao modelo de dados

Outra característica importante de um domain model é que ele não é, necessariamente, igual ao seu modelo de banco de dados. Essa é uma discussão interessante, pois os frameworks web modernos (rails, django etc.) chamam de model o seu modelo de dados. Não há problema em ter o seu domain model replicando as tabelas do seu banco de dados, mas, o que acontece normalmente, é que você deveria projetar cada um dos dois com intenções diferentes.

A nossa preocupação quando se projeta um modelo de dados, é tentar organizar a informação da melhor maneira possível, tentando evitar dados duplicados, manter integridade entre eles e uma melhor organização para desempenho. No modelo relacional de dados, o processo de normalização de dados nos ajuda a lidar com esse tipo de problema.

Já quando projetamos um modelo de domínio, ou seja, um modelo de objetos, estamos mais preocupados com a estrutura desses objetos, de maneira a evitar acoplamento, suportar mudanças e até como substituir uma implementação em tempo de execução. Outra preocupação pertinente é como seria possível estender e aproveitar partes de um código. Para esse tipo de problema, podemos tirar proveito de outras técnicas, tais como: design patterns, modelagem de objetos, divisão de responsabilidades etc. O que queremos chamar a atenção aqui é que você pode usar seu modelo de banco de dados como modelo de objetos, mas vai acabar perdendo algumas vantagens do paradigma.

Vejamos mais um exemplo de história de usuário:

Uma enfermeira pode ser contratada em regimes de trabalho diferentes. Atualmente, podemos ter 3 tipos de regime:

  • 12x36 — trabalha 12 horas e folga 36;
  • diarista — trabalha todos os dias, mas folga aos fins de semana e feriados;
  • especial — trabalha todos os dias, mas no sábado vai até as 15 horas.

Deve ser possível saber os dias trabalhados de uma enfermeira para disponibilizar slots para agendamento.

Novamente, aqui é só um exemplo, uma simplificação para este post. Mas a solução de banco de dados é bem simples: só precisamos guardar as informações da enfermeira, o regime de trabalho e a data que ela ficou disponível para o trabalho. Vamos precisar, também, lidar com os feriados. Com essas informações, é possível saber quando ela vai estar trabalhando ou de folga. Basicamente, precisamos de duas tabelas:

A figura acima mostra as tabelas de enfermeiras e feriados, que armazenam os dados necessários para cumprir a tarefa de saber quais dias uma enfermeira trabalha. Já o modelo de objetos pode ser um pouco diferente disso, pois estamos interessados em suportar mudanças mais facilmente. Por exemplo: e se quisermos adicionar um novo regime de trabalho? Com essas necessidades em mente, o modelo de objetos pode nos ajudar de outra forma. Repare esta proposta:

Neste modelo, temos um objetivo diferente. O objeto enfermeira conhece os regimes de trabalho e, diferentemente do modelo de dados, aqui, eles se tornaram objetos também. Cada um com sua responsabilidade específica. RegimeDeTrabalho se tornou uma interface que define o contrato obter_dias_que_trabalha.

Agora, é possível tirar vantagem do polimorfismo para variar o comportamento dos regimes de maneira isolada e sem afetar a classe Enfermeira. Com esse modelo, se for preciso inserir um novo regime de trabalho, basta implementar a interface e “vida que segue”. Imagine, por exemplo, que precisamos criar o conceito de escala reduzida, cujo horário de trabalho é de segunda a sexta, de 07h às 12h. É um novo regime. No modelo de dados, nada muda, pois é só mais um tipo que vamos precisar incluir, mas, no código, vamos precisar implementar esse regime. Com o modelo proposto, basta implementar uma nova classe estendendo a interface RegimeDeTrabalho.

Com os modelos de dados e objetos separados, é possível ter mais liberdade para tirar proveito das características de cada um de maneira independente.

3. Conectando com a anatomia de módulo

Uma vez que já vimos como é organizado um módulo, como as classes ficam organizadas e como um modelo de domínio é organizado, veja como fica o desenho do módulo de Vacinação que suporta a história de usuário que vimos anteriormente:

Uma enfermeira precisa registrar a aplicação de uma dose de vacina em um paciente e anotar a data, a dose e a vacina aplicada na carteira de vacinação.

Neste desenho, não entramos em detalhes sobre a camada de infraestrutura — isso vamos falar em outro post. Um ponto importante aqui é mostrar as classes separadas nas camadas e como elas se conectam. O caso de uso RegistrarAplicacaoDeVacina é o único objeto que deve ser exposto para uso fora do módulo.

Novamente, voltando à palavra “limite”, a maneira que conseguimos implementá-lo é fortalecendo o encapsulamento e diminuindo o acoplamento entre as partes. Para que isso aconteça, é preciso se atentar a alguns detalhes. Vejamos a seguir.

3.1 Deve-se reduzir o número de classes públicas

Quando utilizamos módulos para a implementação de um Bounded Context, se espera que ele sirva de barreira para limitar o acesso e o uso dos objetos que vivem dentro dele. Logicamente, o módulo vai definir uma fronteira entre o que está dentro e o que está fora. Se não tomarmos os devidos cuidados, e não criarmos formas de manter esse tipo de consistência, vamos acabar deixando os objetos — que estão dentro do módulo — expostos.

Para evitar que todos os objetos sejam acessados de maneira indiscriminada, é preciso pensar quais deles devem ser expostos. Em algumas linguagens de programação, existem mecanismos para lidar com esse tipo de problema. Em Java e C#, por exemplo, é possível definir a visibilidade de uma classe dentro dos packages ou namespaces, respectivamente. Nós utilizamos Ruby, que é mais complicado para alcançar esse tipo de coisa. Para conseguir reduzir o acesso, nós temos alguns guidelines, que são alguns princípios que nos ajudam a raciocinar quando estamos projetando os módulos.

Somente objetos da camada de aplicação ou infraestrutura são expostos.

Para interagir com módulos, objetos externos só deveriam depender dos comandos ou de algum objeto de suporte diretamente. Objetos de domínio não deveriam ser acessados. Na prática, todo módulo vai expor um conjunto de comandos na sua interface pública e é por meio deles que outros podem se comunicar. Esses comandos são implementações de Caso de Uso. É como se todo módulo tivesse uma espécie de painel de controle para realizar as suas funções.

A figura abaixo mostra um objeto do módulo 1 acessando diretamente os objetos do módulo 2:

Esse tipo de acesso faz com que a fronteira estabelecida pelo módulo 2 seja violada. Quando falamos de limitar o uso da linguagem e o significado dos termos, esse tipo de situação não deveria acontecer. Se, por algum motivo, algum desses objetos tiver sua semântica alterada no módulo 2, isso pode quebrar o módulo 1 — o que nos leva aos problemas de acoplamento que já conhecemos.

Para evitar esse tipo de situação, devemos limitar o número de objetos expostos. Como falamos anteriormente, somente alguns tipos de objetos podem ser acessados.

Repare que, neste exemplo, existe o acoplamento, mas apenas por meio da interface definida pelo Caso de Uso — que, no nosso caso aqui, normalmente é um método simples chamado de execute.

Considere utilizar estrutura de dados simples para compartilhar dados

Se for necessário ter acesso a algum dado específico de objetos que estão em outros módulos, você deveria dar preferência a trocar informações entre esses módulos por meio de estruturas de dados mais simples, como arrays, hashs ou tuplas. Isso vai diminuir o acoplamento e tornar mudanças mais fáceis. Por exemplo, imagine que, por algum motivo, você precise obter alguma informação da caderneta de vacinação do paciente que está no módulo Vacinação. A solução simples seria acessar o objeto CarteiraDeVacinação diretamente ou retornar por meio de algum método. Mas não é isso que fazemos aqui. Nesse caso, utilizamos hash para trafegar esses dados.

Neste exemplo, simulamos algum objeto fazendo a chamada do método obterCarteiraDeVacinacaoDo. O caso de uso é invocado e delega a serialização — aqui, de uma maneira simplificada — para o objeto CarteiraDeVacinacao por meio do método to_hash. Essa serialização apenas converte o estado desse objeto para um hash e retorna para quem chamou.

Esse tipo de técnica diminui o acoplamento e facilita bastante a manutenção. É possível fazer mudanças nos objetos internos do módulo, com impactos menores em outras partes do sistema.

4. Recordando: onde estamos?

Este foi um post um pouco mais denso, pois tinha o objetivo de mostrar como é a implementação de um Bounded Context de maneira mais concreta e mostrar, também, os mecanismos que nos ajudam a lidar com o limite imposto por eles. Vimos que um Bounded Context é implementado por um módulo e que o modelo de domínio é a parte central de toda essa estrutura. Ainda não vimos como conectar isso com a infraestrutura e com o framework, mas fique tranquilo: isso é o que vamos ver no próximo post dessa série.

Até lá!

--

--