Quando falamos sobre arquiteturas e padrões de projetos, sempre lembramos dos clássicos modelos apresentados na faculdade ou em livros de programação mais convencionais. Porém, existe um outro mundo de padrões que não estão muito bem (ou nada) documentados. Um desses padrões mais incomuns é o que chamamos de Event Sourcing.
A primeira definição que vemos de event sourcing quando fazemos uma busca simples é a definição de Martin Fowler em seu artigo sobre o assunto, em 2005:
- “Nós podemos buscar o estado de uma aplicação para encontrar o estado atual dela como um todo e, no geral, isso é suficiente e responde muitas perguntas. Entretanto, há momentos onde não queremos somente ver onde estamos, mas também queremos saber como chegamos aqui.”
Isso mesmo, esta citação é de 2005. Ou seja, já era uma realidade na época criar aplicações seguindo este modelo, mas por algum motivo ele acabou se perdendo e nunca mais ouvimos falar dele até agora.
O problema
O grande problema que a maioria dos padrões modernos resolvem são por conta da arquitetura de microsserviços. Quando temos um único local onde temos toda a nossa lógica e ações ocorrendo, é fácil controlar o estado como um todo, no entanto, quando nossas aplicações se distribuem mas nosso estado continua compartilhado – como é o caso dos microsserviços – começamos a ter problemas que não tínhamos antes.
Em geral, estes problemas são oriundos de problemas do tipo race condition ou então problemas de estados transientes chamados de State mutation, que é o modelo mais conhecido para se armazenar dados no geral, principalmente em bancos de dados relacionais. Nós alteramos o estado final da entidade a cada atualização da mesma. O famoso “update” do CRUD.
Imagine que estamos desenvolvendo um sistema para uma empresa de logística que trabalha com navios. Nós temos duas entidades principais: o porto e o próprio navio. Sempre que um navio sai de um porto levando alguma carga, não só o estoque do porto e do navio devem ser atualizados, como também a lista de navios atracados no porto deverá ser modificada.
Quando o navio chega em seu destino, ele deve ser atualizado com o porto atual no qual ele atracou. A lista de navios atracados daquele porto deve ser atualizada e os dois estoques devem ser atualizados novamente.
Porém, precisamos de uma informação a mais. Nossa cliente quer saber exatamente por quais portos cada navio passou, bem como o as datas que eles estiveram nestes lugares.
Além disso, ela também precisa saber, para fins de auditoria, quais foram as movimentações de estoque que foram feitas (no navio e no porto), item a item, bem como todos os navios que estiveram lá durante a existência deste porto.
Este problema pode ser facilmente resolvido com uma abordagem relacional. Criaremos uma tabela de logs que é atualizada sempre que um navio ou um porto receber alguma atualização. Resolveria todos os nossos problemas, certo? Errado.
E se, por algum motivo, nosso serviço de logs não responder e produzir um erro, não salvando o log no final do processo?
Nosso estado seria atualizado mas não seria logado. Isso significa que um item do estoque teria desaparecido sem registros. Além disso, estamos criando outra estrutura e duplicando nosso volume de dados, já que temos que salvar a mesma informação em lugares diferentes.
Como resolvemos este problema?
Event sourcing
Eventos são uma abordagem tradicional para resolver problemas de race condition e também problemas de orquestração de serviços como um todo, já tendo alguns padrões bem definidos como o Saga. A ideia é que todas as ações realizadas sobre uma entidade seja representada por um evento que é capturado e transformado em uma ação.
Event sourcing significa ter como “fonte” de tudo, um evento.
Portanto, é impossível que o estado final seja alterado, a não ser que um evento seja disparado para fazê-lo. Desta forma, garantimos a atomicidade, pois o salvamento de um evento é uma ação única e, portanto, atômica. Também garantimos a integridade do estado final, uma vez que cada evento disponível irá gerar uma alteração no estado em campos definidos.
Quando utilizamos event sourcing, além de utilizarmos os eventos como fontes de mudanças, como fazemos com o Saga, vamos também armazenar estes eventos em nosso banco de dados de forma que, ao iterarmos sobre eles, possamos reconstruir o estado completo de uma entidade em qualquer ponto no tempo. Então, ao invés de termos algo como o que tínhamos antes, vamos ter algo deste tipo:
{
"id": 1,
"eventos": [
{
"id": "eid1",
"nome": "navio-foi-criado",
"data": "01/12/2018",
"dados": {
"nome": "Pérola Negra",
"nomePorto": "Santos"
}
},
{
"id": "eid2",
"nome": "chegou-em-porto",
"data": "02/02/2019",
"dados": {
"nomePorto": "Santos"
}
},
{
"id": "eid3",
"nome": "pertiu-do-porto",
"data": "03/02/2019",
"dados": {}
},
{
"id": "eid4",
"nome": "chegou-em-porto",
"data": "07/02/2019",
"dados": {
"nomePorto": "Roterdã"
}
}
]
}
Note que cada evento possui um ID próprio, tornando-o único na aplicação inteira. Isto nos permite ter não só uma auditoria 100% confiável do que houve durante todo o período de execução de nossas aplicações, como também nos permite “voltar no tempo”, de forma que podemos realizar uma busca por qualquer evento, em qualquer ponto ou data no tempo e verificar o estado até aquele determinado momento. Este é um conceito muito aplicado em padrões como o Redux.
Bancos de dados
Você deve ter percebido que utilizamos uma tabela para representar o primeiro exemplo e depois utilizamos um documento JSON. Não é por acaso – quando estamos falando de arquiteturas de eventos – especialmente aquelas que salvam eventos de forma persistente – nós estamos, quase que inerentemente, falando de um modelo não relacional, tanto que grande parte da indústria que utiliza tal tipo de arquitetura é fortemente dependente de abordagens não relacionais.
Quando temos um banco relacional, ficamos presos ao schema daquele banco, ao modelo daquela tabela. Isso nos limita, pois cada evento tem seu próprio conjunto de dados. Por exemplo, o evento chegou-em-porto obrigatoriamente precisa informar qual é o porto ao qual o navio chegou, porém, o evento partiu-do-porto não precisa informar este dado explicitamente, pois ele sempre aparecerá após o evento de chegada em algum porto.
Essa característica schemaless é justamente o que faz os bancos NoSQL se destacarem tanto, pois podemos armazenar diversos tipos de eventos e dados em um único documento, fazendo com que toda a estrutura, dados e histórico daquela entidade esteja contido em um único lugar. Isso facilita muito na hora de buscar os eventos e os reduzir para o estado final.
Dito isto, não estou obrigatoriamente falando que event sourcing só funciona com bancos não relacionais. Muito pelo contrário, o padrão pode ser aplicado a qualquer arquitetura. Porém, a aplicação do padrão em um banco relacional será muito mais complicada e trará muito mais trabalho desnecessário.
Estado
O estado em event sourcing é calculado a partir da redução do array de eventos. Por redução, dizemos a aplicação iterativa dos dados dos eventos um a um sobre um único objeto final, que é exatamente o que a função reduce do JavaScript faz.
Em essência, estamos utilizando um acumulador (o estado) para incluir novas propriedades e alterar outras iterativamente até o momento em que não houver mais nenhum evento a ser aplicado. Chegamos ao estado final, mas o que fazemos com ele?
Chegamos a um ponto de discordância. Existem várias ideias sobre o que precisamos fazer com o estado final. Uma delas prega que, já que reduzimos o estado sempre, não há necessidade de armazená-lo no banco.
Contudo, em contrapartida, se não armazenamos o estado no banco, nossas queries se tornarão absurdamente complexas, pois teremos que filtrar a partir de um array de eventos e isso é muito mais custoso do que reduzir o estado duas vezes.
Então, como boa prática é aconselhável armazenar pelo menos o último estado no banco de dados. Desta forma, nosso documento final ficaria:
{
"id": 1,
"eventos": [
{
"id": "eid1",
"nome": "navio-foi-criado",
"data": "01/12/2018",
"dados": {
"nome": "Pérola Negra",
"dataCriação": "01/12/2018",
"nomePorto": "Santos"
}
},
{
"id": "eid2",
"nome": "chegou-em-porto",
"data": "02/02/2019",
"dados": {
"nomePorto": "Santos"
}
},
{
"id": "eid3",
"nome": "pertiu-do-porto",
"data": "03/02/2019",
"dados": {}
},
{
"id": "eid4",
"nome": "chegou-em-porto",
"data": "07/02/2019",
"dados": {
"nomePorto": "Roterdã"
}
}
],
"estado": {
"nome": "Pérola Negra",
"dataCriação": "01/12/2018",
"nomePorto": "Roterdã"
}
}
Desta forma, podemos realizar nossas queries diretamente no banco de dados sem reduzir o estado a cada chamada, pois o objeto estado já é o resultado da redução do array eventos com os dados sendo aplicados um a um sobre o estado final.
Prós e contras
Como tudo em tecnologia, temos vantagens e desvantagens.
Prós:
- Temos um histórico inerente a cada entidade sem precisar de programação adicional
- Podemos voltar no tempo a qualquer ponto da entidade
- Fácil correção de erros e bugs (basta desfazer o evento)
- Sempre vamos incluir eventos, nunca vamos modificar o retirá-los
- Eventos são atômicos
- Utilização de eventos forçará o desenvolvedor a tomar uma abordagem mais voltada para domínio, como o DDD (embora a utilização de MVC também seja possível)
Contras:
- Com muitos eventos, a obtenção do estado final pode se tornar onerosa
- Não existe muito conteúdo online sobre o assunto
- Você vai escrever mais código, porém será mais fácil de dar manutenção
- Banco de dados pode crescer substancialmente
Conclusão
Nos próximos artigos trarei um passo a passo do desenvolvimento de uma aplicação para controle de navios em Node.js utilizando TypeScript. Utilizaremos duas bibliotecas: a paradox e a tardis, que vão facilitar muito a nossa vida em termos de desenvolvimento.
Fiquem ligados e até a próxima!