No meu post anterior, falei sobre os blocos básicos para implementar arquitetura orientada a eventos (EDA) com serviços gerenciados da AWS. Este post é sobre o básico avançado. É avançado porque, pela minha experiência como consultor, pouquíssimas empresas aplicam as práticas que quero discutir. Mesmo assim, continuo considerando isso o básico, já que essas práticas não são opcionais — são essenciais para construir sistemas confiáveis baseados em mensageria.
Embora os exemplos deste post usem serviços da AWS, o conteúdo se aplica a qualquer sistema, independentemente da infraestrutura em que ele rode. A implementação pode variar, mas os princípios fundamentais são os mesmos.
Como este post trata de um padrão — e padrões, por definição, são soluções repetíveis para problemas recorrentes —, quero começar falando de um problema que costuma passar despercebido em sistemas baseados em EDA.
O Problema
No post anterior, sugeri usar o AWS SNS para publicar mensagens em um sistema orientado a eventos. Veja um exemplo comum de como essa publicação costuma ser feita:
...
sns.publish(
TopicArn=users_topic_arn,
Message=json.dumps({
'event_type': 'user_registered',
'event_id': str(uuid.uuid4()),
'user_id': 'USER12345',
'name': 'John Doe',
'email': '[email protected]',
'source': 'mobile_app',
'registration_date': '2024-10-11T20:01:00Z'
})
)
...
No exemplo acima, um evento do tipo user_registered é publicado em um tópico do SNS. Mas será que esse evento surgiu do nada? Será que os serviços só fazem isso, publicar mensagens? Claro que não. O evento faz parte de um processo de negócio maior, que normalmente envolve atualizar algum estado em um banco de dados operacional antes de notificar componentes externos. Uma representação mais fiel desse processo seria assim:
...
# Persist state changes
users_table.put_item(
Item={
'user_id': user_id,
'name': name,
'password_hash': password_hash,
'email': email,
'source': source,
'registration_date': registration_date,
'created_at': datetime.utcnow().isoformat()
}
)
# Publish corresponding events
sns.publish(
TopicArn=users_topic_arn,
Message=json.dumps({
'event_type': 'user_registered',
'event_id': str(uuid.uuid4()),
'user_id': user_id,
'name': name,
'email': email,
'source': source,
'registration_date': registration_date
})
)
...
Primeiro o novo usuário é persistido e, em seguida, a notificação é publicada. O código parece simples, mas considere estas três perguntas:
- O que pode dar errado aqui?
- Quais são as implicações disso?
- Dá para dizer que esse código é confiável?
Pare um instante e reflita sobre as perguntas antes de continuar a leitura.
Beleza, vamos comparar nossas respostas.
Se algo der errado entre a escrita no banco de dados e a publicação da mensagem, o sistema vai parar em um estado inconsistente. O usuário pode receber um erro e supor que toda a operação falhou, mas o registro já terá sido criado no banco. Por outro lado, os assinantes do evento user_registered não serão notificados, porque a mensagem nunca foi publicada. Por que isso aconteceria? O servidor pode reiniciar, a Lambda pode dar timeout, podem ocorrer partições de rede ou inúmeros outros motivos — ainda mais na nuvem.
A consistência de um sistema orientado a eventos depende da capacidade de entregar mensagens entre seus componentes de forma confiável. E não é o caso aqui. Apesar da aparente simplicidade, o código acima não é confiável.
Então, o que fazer? Dá para envolver a escrita no banco e a publicação do evento em uma transação atômica? Não. No passado, houve tentativas nesse sentido (por exemplo, o DTC), mas não acabaram bem. Two-phase commit? Também não resolve, porque sofre com condições de falha parecidas.
A solução confiável é transformar duas transações em uma só. Como? Vamos falar do padrão outbox.
A Solução: Outbox
A ideia por trás do padrão outbox é bem simples. Primeiro, você persiste tanto as mudanças de estado quanto as mensagens de saída no banco de dados operacional em uma única transação atômica. Ou as duas têm sucesso, ou as duas falham — nunca um meio-termo. Depois, um mecanismo externo — o publishing relay — busca as mensagens commitadas e as publica de forma assíncrona em um message bus.

Figura 1: O padrão outbox
Implementação: Geral
Não existe uma forma única de implementar o padrão outbox. Os detalhes da implementação dependem da stack tecnológica em uso, principalmente do banco de dados.
Primeiro, o banco de dados determina os recursos que você tem (ou não tem) para commitar os dados atualizados e as mensagens de saída em uma transação atômica. Se ele suporta transações multi-tabela (por exemplo, bancos relacionais, DynamoDB, etc.), dá para persistir as mensagens em uma tabela dedicada, geralmente chamada de "outbox". Se não suporta, tanto o estado atualizado quanto as mensagens precisam ser persistidos em um único registro.
Segundo, você precisa de uma forma confiável de buscar as mensagens persistidas. Alguns bancos viabilizam o modelo push: o próprio banco tem como acionar o publishing relay e passar as novas mensagens. É o caso, por exemplo, dos triggers de Lambda no DynamoDB ou de um mecanismo de change data capture (CDC) em bancos relacionais.
No fim das contas, a forma como você persiste e busca as mensagens é o que define como vai garantir que a mesma mensagem não seja capturada e republicada sem necessidade.
Implementação: Exemplo
Vamos voltar ao exemplo do início. Como o código usa DynamoDB, a forma mais fácil de implementar o padrão outbox neste caso é aproveitar a capacidade dele de fazer transações multi-tabela e adicionar os eventos de saída a uma tabela dedicada:
...
with dynamodb.meta.client.transact_write_items(
TransactItems=[\
{\
'Put': {\
'TableName': users_table.name,\
'Item': {\
'user_id': user_id,\
'name': name,\
'password_hash': password_hash,\
'email': email,\
'source': source,\
'registration_date': registration_date,\
'created_at': datetime.utcnow().isoformat()\
}\
}\
},\
{\
'Put': {\
'TableName': outbox_table.name,\
'Item': {\
'event_id': event_id,\
'data': {\
'event_type': 'user_registered',\
'event_id': event_id,\
'user_id': user_id,\
'name': name,\
'email': email,\
'source': source,\
'registration_date': registration_date\
}\
}\
}\
}\
]
)
...
O próximo passo é decidir como, de fato, publicar os eventos no tópico do SNS. A solução mais simples é usar o DynamoDB Streams na tabela outbox para acionar uma função Lambda a cada novo registro e publicar seus eventos no tópico do SNS.
Por fim, é preciso decidir o que fazer com as mensagens que já foram publicadas. A função de publicação pode excluir os registros da tabela outbox assim que receber a confirmação de que a publicação no SNS foi concluída com sucesso. Como alternativa, pode manter o registro e atualizá-lo com o timestamp da publicação efetiva.
A Figura 2 resume a solução completa:

Figura 2: O padrão outbox implementado com AWS DynamoDB, Lambda e SNS
Em comparação com a solução ingênua de simplesmente publicar os eventos de saída em um tópico SNS, a implementação do padrão outbox resulta em um sistema mais complexo, com mais partes móveis. Em contrapartida, a solução é confiável. Uma vez que a transação original foi commitada, não importa o que aconteça em runtime: os eventos correspondentes serão publicados e entregues aos assinantes.
Posts da Série
- Arquitetura Orientada a Eventos na AWS, Parte I: O Básico
- Arquitetura Orientada a Eventos na AWS, Parte II: O Básico Avançado (Post Atual)
- Arquitetura Orientada a Eventos na AWS, Parte III: O Básico Difícil
Publicado originalmente em https://vladikk.com .
A consistência de um sistema orientado a eventos depende da capacidade de entregar mensagens entre seus componentes de forma confiável. O padrão outbox permite atualizar o estado do sistema e publicar os eventos resultantes como uma transação atômica, mesmo quando a infraestrutura subjacente não oferece esse tipo de transação entre serviços. A confiabilidade que o outbox traz para o sistema compensa, e muito, o esforço de implementar o padrão.