Back-End

26 fev, 2019

Event Sourcing – Desenvolvendo sua primeira aplicação

Publicidade

No artigo anterior demos uma pequena introdução sobre o que é e como funciona o padrão Event Sourcing e também mostramos quais são as vantagens e desvantagens do uso desse tipo de modelo de desenvolvimento, mas ainda não colocamos a mão na massa! Como podemos começar a desenvolver uma aplicação utilizando esse modelo?

Arquitetura do projeto

Antes de tudo, vamos falar um pouco sobre como nosso projeto vai ser arquitetado e como cada parte vai se comunicar. Isso é extremamente importante para entender como vamos construir nossas aplicações de forma confiável.

Originalmente, na Nextcode (onde trabalho e de onde essa arquitetura se originou), fizemos uma reunião para definir quais eram as nossas prioridades quando falamos de arquitetura de sistemas e design de código (seja utilizando ou não event sourcing). As principais características podem ser enumeradas em poucos tópicos:

  • Menor acoplamento possível, de forma que seja possível não só compor objetos a partir de outros objetos, mas também facilitar os processos de testes automatizados
  • Maior reusabilidade de código, que seria o efeito do item anterior – prezamos muito pela reutilização de códigos em outros locais, seja através de libs ou através de arquivos compartilhados
  • Independente de tecnologia, assim podemos estender o conceito para outros projetos sem nos preocupar em ficar presos a uma ou mais tecnologias

Entre outras ideias, chegamos a algo próximo do modelo DDD (Domain Driven Design) com algumas modificações e adições de outros modelos padrões como o MVC. No final montamos um mapa:

A separação em camadas é uma das partes mais importantes não só desta arquitetura, mas de todo o tipo de arquitetura de software.

Uma camada é responsável por abstrair toda a funcionalidade e regras presentes nela, de forma que camadas superiores possam acessar estes dados de forma simples e rápida sem precisar se preocupar com o que está acontecendo dentro delas.

Além disso, separar o projeto em camadas faz com que ele se torne independente. Isto porque é possível alterar as camadas mais superiores sem precisar mudar nenhuma informação das camadas inferiores. Agora, alterar as camadas inferiores pode refletir em alterações de camadas superiores.

Domínios

Um conceito importante nesta arquitetura é o de domínios. Um domínio é uma fronteira de responsabilidades, mas o que é isso?

Imagine que estamos trabalhando com um sistema de gerenciamento de portos e navios. Podemos ver claramente que teremos duas entidades: o porto e o navio.

Cada uma dessas entidades corresponde a um domínio separado – o navio só pode saber de dados e informações relativas a ele como entidade e o mesmo se aplica ao porto que não pode saber nenhum outro dado do navio. Será no domínio, também, que definiremos todos os nossos eventos e erros relativos às nossas entidades.

Quando utilizamos este tipo de abordagem, estamos separando as regras de negócio, visto que cada domínio só poderá saber dos seus próprios eventos e de suas próprias regras.

Mas o que aconteceria se nós precisássemos, por exemplo, ter um estoque tanto no navio quanto no porto, e então tivéssemos que transferir esse estoque do porto para o navio ou então do navio para o porto? Isso criaria uma dependência circular, o que não pode acontecer.

Neste caso poderíamos criar um serviço à parte, que manipularia as duas entidades separadamente. O serviço de estoque seria responsável por manipular tudo o que é relativo a estoque de todas as nossas entidades.

Isso permite a composição de objetos, de forma que o estoque poderá acessar dados tanto do navio quanto do porto, respeitando as regras de negócio de cada entidade em particular, ao mesmo tempo em que criamos operações mais complexas para nossos domínios.

Então o estoque em si poderia ser considerado um domínio à parte? Mesmo não havendo nenhuma entidade somente para ele?

A palavra manipulação é a chave nesta seção. O domínio por si só não realiza nenhum tipo de ação sobre outras entidades que não as suas e também não é ativo, mas sim passivo.

Alguém, em algum lugar, deve dar um comando para que o domínio possa realizar o seu trabalho, e a esta ação, nós damos o nome de manipulação de domínio.

No mapa, percebemos que todas as camadas podem manipular o domínio. Isso significa que podemos acessar nossa regra de negócio de qualquer lugar e também responde a nossa pergunta sobre o domínio de estoque.

Como o estoque seria algo ativo e não passivo, isso por si só não o classificaria como um domínio à parte. Além disso, todo o domínio deve ser representado por uma entidade que pode ou não ser persistida em banco de dados, como estamos apenas manipulando dois outros domínios, o estoque não é caracterizado como um.

Um domínio é sempre unitário, ou seja, ele não pode levar como parâmetros outros domínios e também não pode levar como parâmetros outras estruturas de outras camadas do sistema. Pense nele como sendo o menor bloco de construção que temos. Não é composto por nada mas compõe os demais objetos.

O que pode acontecer é: determinados métodos receberem como parâmetros outras instâncias de uma entidade. Por exemplo, um porto que possui uma lista de navios atualmente aportados pode receber uma instância de Ship para seu método aportar:

Presentation layer – A camada de apresentação

A camada de apresentação é o local por onde todas as nossas requisições chegarão e por onde todo o nosso sistema começa a ser executado. Essa camada é responsável pelos seguintes pontos:

  • Abrigar o servidor web que estará servindo nossa API
  • Ser o ponto de definição de todas as rotas existentes
  • Conter toda a lógica que é realizada por uma rota uma vez que ela é chamada
  • Tratamento de erros e retorno de códigos de status para o cliente
  • Tratamento das respostas que a API dará
  • Conversão e validação de dados enviados pelo usuário através da API

Em poucas palavras, a camada de apresentação vai lidar diretamente com o usuário. Ela é a porta de entrada para a nossa aplicação e será nela que todos os dados do cliente devem ser validados antes de serem passados para as demais camadas da aplicação.

Além disso, essa camada será o entrypoint da aplicação, possuindo as configurações de criação do mesmo e também para a execução.

Por fim, essa camada será responsável por converter dados enviados pelo usuário para os formatos aceitos na aplicação. Por exemplo, uma data enviada no formato ISO é uma string, porém, a aplicação deve trabalhar somente com objetos de Data.

Portanto, a conversão deste formato para o objeto de data deve ser feita aqui, bem como os tratamentos dos erros que serão enviados pelas demais camadas através de um throw new Error.

Toda a rota da camada de apresentação levará como parâmetro pelo menos um serviço da camada de baixo. Isso tornará possível a interação entre a entrada da aplicação e as camadas mais internas.

Service layer – A camada de serviços

Em um modelo MVC, essa camada seria relativa ao C dos controllers. Essa é uma das camadas mais importantes porque a lógica de fluxo estará contida nela, bem como algumas responsabilidades:

  • Manipulação da camada inferior de persistência
  • Manipulação do domínio para inclusão ou exclusão de informações
  • Controle do fluxo da lógica da aplicação
  • É o handler das rotas da camada de apresentação

Nesta camada vamos receber os dados já tratados e já validados do cliente. Portanto, não há necessidade de fazermos validações extras, mas é a partir daqui que começamos a ter a responsabilidades de tratar os erros possíveis da nossa aplicação.

Ou seja, imagine que queremos buscar um navio, mas esse navio não existe. A rota receberá um ID e irá passá-lo adiante. Quando chegarmos na nossa camada de serviços vamos chamar a camada de persistência e pedir a entidade do navio que estamos buscando.

Como essa entidade não existe, temos que executar um throw new ShipNotFoundError, que será propagado para as camadas superiores e tratado na camada de apresentação conforme nosso exemplo anterior.

É importante notar que, por padrão, serviços não são responsáveis por manipular ou modificar mensagens de erro para que elas sejam amigáveis. Isso é trabalho da camada superior, mas é nosso trabalho garantir que não haja nenhum erro genérico (do tipo Error).

Isso significa que, para todos os erros que tivermos mapeado em nossa aplicação, teremos que ter uma classe estendida a partir do erro pai para termos um melhor stack trace da nossa aplicação.

Um serviço jamais deve receber uma rota ou então diretamente uma entidade do domínio. Serviços devem receber um – e somente um – repositório de entidade (vamos falar deles) ou então outros serviços. Além disso, cada serviço só pode manipular uma entidade do domínio por vez.

Ou seja, se tivéssemos um método que atualizaria tanto o navio como o porto no qual aquele navio está aportado, não poderíamos simplesmente manipular a camada de dados do porto, mas teríamos que realizar esta ação através do serviço relativo ao porto – este sim manipularia o repositório da entidade, como no exemplo a seguir:

Data layer – A camada de persistência/dados

Essa camada é a base de toda a aplicação, pois ela é a responsável por gerenciar e manipular as fontes de dados. Isto significa que será aqui que vamos manipular não só bancos de dados, mas também toda e qualquer outra fonte de informação como: outras APIs, sistemas de arquivos e etc.

Essa camada tem como responsabilidades:

  • Fazer a comunicação com bancos de dados
  • Inserir, buscar e manipular dados brutos
  • Realizar comunicações com serviços externos que podem ou não serem outras APIs

Aqui teremos o conceito de repositories: um repository é, como o próprio nome diz, um repositório de informações. A diferença está na origem destas informações – se elas vêm da sua própria aplicação (como um banco de dados ou um sistema de arquivos), chamamos de repository.

Caso contrário, se a sua informação vem de outros serviços ou APIs que precisam ser acessados através da rede, damos o nome de client. Isso influencia em uma parte crucial da arquitetura:

Repositórios do tipo repository podem apenas retornar instâncias de entidades do domínio, enquanto clients podem retornar respostas em dados brutos.

Neste momento é importante entender a diferença entre um dado bruto e um dado tratado. O dado bruto, por exemplo, seria o JSON retornado a partir de uma consulta no MongoDB. Já o dado tratado seria a entidade Navio, que foi construída a partir deste JSON e de seus eventos (no caso de event sourcing).

Um repositório local (por exemplo, um MongoDB) não pode, de forma alguma, retornar dados brutos – apenas entidades do domínio. Ou seja, se tivermos um ShipRepository, isso significa que ele só poderá nos retornar instâncias de Ship:

Por outro lado, se estamos tratando com um client, então estamos acessando uma fonte de dados externa. Esses tipos de repositórios podem retornar dados que não sejam instâncias de uma entidade.

Isso nos ajuda porque, durante o fluxo de nossa aplicação poderemos manipular dados diretamente em memória através dos métodos da nossa entidade e, por fim, salvar o seu estado modificado no banco de dados como forma de persistência.

Utility bus – Helpers e utilitários

Como não podemos prever tudo que acontecerá com o desenvolvimento, é necessário um barramento de utilidades – aquela famosa pasta helpers que encontramos por ai.

Esse barramento é literalmente um local onde podemos criar funções e outros utilitários que serão utilizados no sistema mas não fazem parte de um domínio específico. Por exemplo, uma função de validação de CPF que será utilizada em várias partes do sistema mas não é de um domínio.

A nomenclatura bus dá a entender que esta seção pode ser utilizada por qualquer uma das camadas sem precisar de um intermediário, assim como o Domain Bus, esta “camada” poderá ser utilizada por qualquer outra camada do sistema.

Concluindo

Para finalizar, vamos passar pelo nosso mapa e incluir alguns comentários sobre o que acabamos de estudar:

Veja que a separação dessas camadas e a arquitetura do sistema ajuda a manter tudo bem organizado e também facilita na hora de termos que pensar em algum tipo de funcionalidade, pois sabemos quais são as responsabilidades de cada parte e como elas se conectam. Isto por si só já facilita – e muito – o trabalho de mantenimento e manutenção de um sistema.

Se quiser saber mais sobre essa arquitetura, dê uma olhada no guia oficial de desenvolvimento da Nextcode, que contém algumas explicações mais detalhadas.

Nos próximos artigos vamos colocar a mão na massa e construir uma API de controle de portos como exemplificamos aqui, utilizando event sourcing e também nossa arquitetura atual, vejo vocês por lá!