Sagas Architecture .NET

Ricardo Alexandre Silva de Andrade
Popcodemobile
Published in
10 min readDec 1, 2022

O objetivo deste artigo é explicar um pouco o padrão Choreography da arquitetura Sagas, utilizando alguns frameworks em um ambiente de Clean Architecture e Micro Serviços. Como, na data de publicação deste artigo, é um assunto com pouco material relacionado, espero que este trabalho possa contribuir para o entendimento sobre o assunto.

Principais features, frameworks e serviços utilizados nesse projeto:

  • Sagas Choreography Architecture
  • Arquitetura em Camadas
  • CQRS
  • Micro Serviços
  • Mensageria com RabbitMQ
  • Api Gateway com Ocelot
  • Service Discovery com Eureka
  • Bancos de Dados Relacionais com PostgreSQL
  • Orquestração de Containers com Docker
  • Clean Architecture

Explicação Inicial

O Saga Architecture é um padrão arquitetural que possui a finalidade de otimizar a comunicação entre os micro serviços a fim de manter a integridade do banco de dados. Bom, como assim?

Cada micro serviço tem o seu próprio banco de dados, e existem algumas operações que necessitam de transações entre micro serviços, cada um dando um commit no seu próprio banco. Por exemplo (e justamente o exemplo que implementamos nesse repositório do Github, cujo link está na conclusão desse artigo): Quando um usuário faz uma compra na internet, ele emite um pedido de compra (Serviço de Pedidos), esse pedido de compra precisa saber se todos os produtos estão disponíveis para venda (Serviço de Produtos).Por último, o processo é finalizado com a emissão de uma nota de pagamento (Serviço de Pagamento). Assumindo que todos esses serviços realizem alterações em seus próprios bancos de dados, o problema é: e se ocorrer alguma falha em qualquer uma dessas transações? Todos os commits feitos irão persistir nos seus bancos até que você tome uma medida contra isso. E é justamente aí que entra a arquitetura Saga.

Caso ocorra uma falha em qualquer uma dessas transações, todas as anteriores precisam dar um rollback. E o princípio para isso é: para cada transação implementada, deverá existir uma transação compensatória. Portanto, se eu faço uma compra e esta, por sua vez, reserva um produto no estoque até que eu pague por ela, eu preciso de uma ação de compensação que coloque de volta esse produto no estoque caso aconteça um problema com a emissão do boleto ou o período de pagamento determinado esteja expirado.

Aqui está uma imagem de contexto de como ficará a arquitetura da nossa aplicação:

Ao iniciarmos os componentes, cada micro serviço irá registrar o seu endereço e porta no Discovery Service. Há uma vantagem importante aqui. Ao escalonarmos nossa aplicação horizontalmente, não precisaremos nos preocupar com endereços e portas, podemos deixar totalmente a cargo do Service Discovery. Prosseguindo, quando alguém realizar uma requisição ao Gateway, este, por sua vez, irá fazer uma requisição ao Discovery Service para saber o endereço do serviço desejado e, logo em seguida, irá redirecionar a requisição para o serviço em si. Ao salvarmos com sucesso uma alteração no banco de dados, o micro serviço irá enviar essa informação a uma fila específica ao Message Broker, que irá enviar a mensagem para o serviço que registrado e assim por diante.

E como nós implementamos isso?

Ponto primordial: os micro serviços precisam se comunicar, para que quando uma transação ocorra com sucesso, eles passem essa informação para o próximo micro serviço e caso a próxima transação falhe, os anteriores deem rollback. Para isso há duas abordagens para pensarmos:

  • Requisição HTTP.
  • Eventos/Mensagens

A primeira abordagem consiste em chamar a controladora do outro serviço sempre que a transação for concluída, e como toda abordagem, tem os seus contras. Nesta aplicação, como citado no review da arquitetura, estamos utilizando o Service Discovery para o registro de serviços e portanto não precisamos deixar nenhuma porta ou endereço ‘hard-coded’, mas isso seria um contra. Além disso, nessa abordagem, o serviço requisitado precisa obrigatoriamente estar disponível no momento em que for chamado. Com o plus de que você vai ter que fazer muito mais endpoints para lidar com esses casos.

A segunda abordagem foi a escolhida para este repositório. Nós não precisamos nos preocupar com endereços IP’s, portas ou se o serviço estará disponível no momento em que o anterior realizou sua transação.

Para realizar a comunicação entre os micro serviços, foi utilizado o RabbitMQ. O componente poderia ser substituído pelo SQS ou Kafka se fosse hospedado em Cloud. Ademais, foi utilizado o BackgroundService para rodar em segundo plano e ouvir quando as mensagens pertinentes àquele serviço chegaram. Novamente, se fosse hospedado em Cloud, poderíamos substituir essa abordagem por Lambda Functions ou Azure Functions.

Neste projeto, nós temos nosso serviço de mensageria chamado de MessageBroker. O código para o consumo do RabbitMq se encontra nele e todo serviço que queira publicar ou receber mensagens, precisa chamar ele. Além disso, nele estão concentrados os nomes das filas que os serviços irão publicar/consumir, com as models que irão publicar e retornar. Tudo que temos que fazer agora é: sempre que quisermos criar um novo evento, criar dentro de EventModels os seus atributos, e criar o nome de sua fila específica.

Para conseguirmos rodar os repositórios, recomendo utilizar o docker com o comando “docker-compose up” na raiz do projeto. Assim, você poderá fazer a seguinte requisição:

[POST] http://localhost:9000/Order/Order/MakeOrder
{
"productId":1,
"quantity":1
}

Veja que nós acessamos o Api Gateway, até porque os nossos serviços não são acessíveis externamente, apenas através do Gateway. O primeiro /Order, se refere ao serviço que o Api Gateway deve procurar no Service Discovery e o Order/MakeOrder é a rota para o endpoint da controladora. E devemos sempre seguir esse esquema.

Docker

Primeiro vamos analisar o Dockerfile de um microserviço e em seguida o docker-compose que faz a orquestração dos nossos containers.

FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base
ENV ASPNETCORE_URLS=http://+:9004
EXPOSE 9004
WORKDIR /app

FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
WORKDIR /src
COPY ["Services/Order/Order.csproj", "Service/Order/"]
COPY ["Services/Order/Controllers/OrderController.cs", "Service/Order/"]
COPY ["Services/Order/Program.cs", "Service/Order/"]
COPY ["Application/Order/Application.Order.csproj", "Application/Order/"]
COPY ["Domain/Order/Domain.Order.csproj", "Domain/Order/"]
COPY ["MessageBroker/MessageBroker.csproj", "MessageBroker/"]
COPY ["Infra/Order/Infra.Order.csproj", "Infra/Order/"]
RUN dotnet restore "Service/Order/Order.csproj"
COPY . .
WORKDIR "/src/Service/Order"
# RUN dotnet build "AspnetRunBasics.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "Order.csproj" -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Order.dll"]

As observações aqui são: por ser uma arquitetura em camadas, um dos princípios do Clean Architecture, nós precisamos referenciar cada um dos csproj utilizados pelo nosso webapi dentro do nosso container. Sendo que a estrutura de arquivos e pastas devem ser as mesmas, caso contrário, quando fizéssemos o restore não conseguiríamos encontrar os arquivos referenciados no csproj. Além disso a variável de ambiente ASPNETCORE_URLS é uma escolha pessoal minha para o serviço rodar nessa porta, não sendo algo obrigatório.

version: '3.8'

services:

sagasDb:
image: postgres
volumes:
- ./Database/init.sql:/docker-entrypoint-initdb.d/init.sql
environment:
- POSTGRES_PASSWORD=${DATABASE_PASSWORD}
- POSTGRES_DB=Sagas
networks:
- database-network
ports:
- "5432:5432"

messageBroker:
image: rabbitmq:3-management
hostname: "rabbitmq"
healthcheck:
test: rabbitmq-diagnostics -q ping
interval: 5s
timeout: 15s
retries: 3
networks:
- message-network
- services-network
ports:
- "15672:15672"
- "5672:5672"
environment:
RABBITMQ_DEFAULT_USER: "admin"
RABBITMQ_DEFAULT_PASS: "password"

serviceDiscovery:
image: steeltoeoss/eureka-server
hostname: eureka-server
networks:
- discovery-network
ports:
- "8761:8761"

gateway:
image: ${DOCKER_REGISTRY-}apigateway
restart: on-failure
depends_on:
serviceDiscovery:
condition: service_started
networks:
- services-network
- discovery-network
build:
context: .
dockerfile: Services/ApiGateway/Dockerfile
ports:
- "9000:80"
volumes:
- ./Services/ApiGateway/appsettings.json:/app/appsettings.json
- ./Services/ApiGateway/ocelot.json:/app/ocelot.json

order-api:
image: ${DOCKER_REGISTRY-}orderapi
hostname: orderapi
environment:
- Eureka__Client__ServiceUrl=${SERVICE_DISCOVERY_URL}
- Eureka__Client__ShouldRegisterWithEureka=true #Exemplo de como tulizar variáveis de ambientes para definir
- Eureka__Client__ValidateCertificates=false # as configurações que estão dentro do appSettings.
- Eureka__Instance__NonSecurePort=${ORDER_INSTANCE_NONSECUREPORT} # Exemplo de como utilizar um arquivo de variáveis
- Eureka__Instance__HostName=${ORDER_INSTANCE_HOSTNAME} # de ambiente a fim de não deixar variáveis expostas
- Eureka__Instance__InstanceId=${ORDER_INSTANCE_INSTANCEID} # e organizá-las melhor. O arquivo em questão
- DatabaseConnection__ConnectionString=${DATABASE_CONNECTION} # é o .env na pasta raiz.
networks:
- services-network
- database-network
- message-network
- discovery-network
depends_on:
- serviceDiscovery
- sagasDb
build:
context: .
dockerfile: Services/Order/Dockerfile

product-api:
image: ${DOCKER_REGISTRY-}productapi
hostname: productapi
restart: on-failure
networks:
- services-network
- database-network
- message-network
- discovery-network
volumes:
- ./Services/Products/appsettings.json:/app/appsettings.json #Exemplo de como utilizar volumes para definir o appSettings.
depends_on:
sagasDb:
condition: service_started
messageBroker:
condition: service_healthy
serviceDiscovery:
condition: service_started
build:
context: .
dockerfile: Services/Products/Dockerfile

payment-api:
image: ${DOCKER_REGISTRY-}paymentapi
hostname: paymentapi
restart: on-failure
environment:
- Eureka__Client__ServiceUrl=${SERVICE_DISCOVERY_URL}
- Eureka__Client__ShouldRegisterWithEureka=true
- Eureka__Client__ValidateCertificates=false
- Eureka__Instance__NonSecurePort=${PAYMENT_INSTANCE_NONSECUREPORT}
- Eureka__Instance__HostName=${PAYMENT_INSTANCE_HOSTNAME} # Nós precisamos colocar o hostname como o nome do
- Eureka__Instance__InstanceId=${PAYMENT_INSTANCE_INSTANCEID} # próprio serviço, neste caso "payment-api", pois é
- DatabaseConnection__ConnectionString=${DATABASE_CONNECTION} # ele quem o gateway tentará chamar. Portanto quando
networks: # nós passarmos nossa url: http://localhost:9000/Payment
- services-network # no qual 9000 é a porta do Gateway e Payment é o nome
- database-network # do serviço cadastrado no Service Discovery, o nosso Gateway
- message-network # irá traduzir "Payment" para "payment-api".
- discovery-network
depends_on:
sagasDb:
condition: service_started
messageBroker:
condition: service_healthy
serviceDiscovery:
condition: service_started
build:
context: .
dockerfile: Services/Payment/Dockerfile



networks:
services-network:
database-network:
message-network:
discovery-network:

Vamos falar apenas do que vocês podem estranhar.
No sagasDb, foi utilizado o volume para mapeamento inicial do Script de criação do banco de dados de cada serviço.
Eu passei dados de configuração para dentro do container de duas formas distintas: variáveis de ambientes e volumes.
Por exemplo, no serviço de produtos eu passei o appSettings inteiro através de volumes. Mas se vocês olharem, nos outros serviços eu utilizei environments.
Há um arquivo .env na raiz do projeto que contem todas as variáveis de ambiente. Para acessarmos ela dentro do compose, nós utilizamos ${CHAVE_DA_VARIÁVEL}.
Precisamos utilizar health checks em alguns serviços, já que eles possuem uma relação de dependência. Dessa forma, sempre que um serviço falhar ao inicializar por depender de outro que ainda não terminou de ser inicializado, precisamos reiniciá-lo.
Foram criadas networks por recomendação do Docker, assim, apenas serviços que estão na mesma network podem se comunicar, oferecendo uma proteção extra à nossa aplicação, sendo que o serviço deve ter o mínimo de networks possíveis, apenas o essencial.
O resto é mais do mesmo.

Service Discovery e Ocelot

O Service Discovery realmente não possui muita magia. A imagem faz tudo pra você, o essencial aqui é como o Ocelot se comunica com ele. Só precisamos nos atentar em utilizar o pacote nuget correto dentro do csproj.
Vale ressaltar que nesse projeto foi utilizado o Eureka como Service Discovery mas há outra alternativa, que é o Consul, o mais utilizado dentre os dois.
O Ocelot funcionará como um Api Gateway para nós.
O Api Gateway funciona como um WebApi, porém sem uma controladora.
As configurações precisam ser feitas dentro do appSettings, e do Program.cs (lembrando que o repositório está com a versão 6.0 do .NET, portanto, optou-se por não utilizar a classe Startup). Ademais, precisamos criar um arquivo ocelot.json dentro dessa WebApi.

builder.WebHost.ConfigureAppConfiguration(configuration => configuration
.AddJsonFile(Path.Combine("appsettings.json"), true, true)
.AddJsonFile(Path.Combine("ocelot.json")));
builder.Services.AddOcelot().AddEureka();
builder.Services.AddDiscoveryClient(builder.Configuration);

Essas 3 linhas de código fazem a mágica dentro do program.cs, que basicamente injeta as configurações do service discovery que nós iremos utilizar.

"Eureka": {
"Client": {
"ServiceUrl": "http://serviceDiscovery:8761/eureka/",
"ValidateCertificates": false,
"ShouldRegisterWithEureka": false
},
"Instance": {
"NonSecurePort": 80,
"HostName": "Gateway",
"InstanceId": "Gateway,Port:80",
"HealthCheckUrlPath": "/api/values/healthcheck",
"StatusPageUrlPath": "/api/values/status"
}
},

O appSettings do Gateway para registrar a URL do Eureka e a instância do Gateway.

{
"Routes": [],
"Aggregates": [],
"GlobalConfiguration": {
"BaseUrl": null,
"UseServiceDiscovery": true,
"DownstreamScheme": "http",
"ServiceDiscoveryProvider": {
"Host": "serviceDiscovery",
"Port": 8761,
"Type": "Eureka",
"Token": null,
"ConfigurationKey": null
},
"LoadBalancerOptions": {
"Type": "RoundRobin",
"Key": null,
"Expiry": 0
}
}
}

E aqui temos o ocelot.json. Percebam que “Routes” é apenas uma lista vazia, isso porque estou utilizando a feature de rotas dinâmicas do Ocelot. Uma segunda abordagem consiste em registrar dentro de “Routes” todo endpoint criado, de acordo com a documentação do Ocelot. Graças a Deus não precisamos, porque poderia ser deveras trabalhoso à medida que a aplicação crescesse. Então, apenas colocamos a configuração do servidor do Eureka dentro de “GlobalConfiguration” e o método de load balancing.
Percebam que o host “serviceDiscovery” é exatamente como está escrito o serviço de Service Discovery no docker-compose, pois esse é o método de uma aplicação conhecer o endereço de outra dentro do Docker.

RabbitMq

O RabbitMq irá atuar como o Message Broker. Obviamente o nosso sistema não precisa saber que nós estamos utilizando o RabbitMq como entregador de mensagens. Na verdade, de acordo com a Clean Architecture, isso não deveria importar. Por isso nós fazemos classes com interfaces de configuração e de implementação dos métodos de entrega e recebimento de mensagem. São bem Simples.

public static class MessageBrokerConfig
{
public static IModel ChannelConfig()
{

var channel = new ConnectionFactory() {HostName = "messageBroker", UserName = "admin", Password = "password", Port = 5672}.CreateConnection();

return channel.CreateModel();
}
}

Como citado anteriormente, o HostName recebe “messageBroker” porque é como o serviço foi nomeado no docker-compose. É uma classe bem simples, mas é válido observar um ponto aqui. A execução do receiver roda a partir de uma classe chamada BackgroundService, que é como se fosse um serviço de fundo do .NET, a qual precisa ser implementada utilizando um Singleton. Então, enquanto nossa aplicação está no ar, um serviço de fundo fica “escutando” mensagens que chegam.

Porém, é válido ressaltar que há outras abordagens que podem ser utilizadas, por exemplo, o Mass Transit. Foi de minha intenção utilizar o RabbitMq do modo mais básico possível, apenas para dar a ideia do funcionamento, mas o uso do Mass Transit facilita o desenvolvimento, bem como o uso do NServiceBus.

private readonly IModel _channel;

public MessageBroker(IModel channel)
{
_channel = channel;
}
public void PublishMessage<T>(T command, string eventQueue, string exchange)
{
var message = JsonSerializer.Serialize(command);
var body = Encoding.UTF8.GetBytes(message);
_channel.BasicPublish(exchange: exchange,
routingKey: eventQueue,
basicProperties: null,
body: body);
}

public void ReceiveMessage<T>(string eventQueue, Action<T> appServiceCall)
{
var consumer = new EventingBasicConsumer(_channel);

consumer.Received += (model, ea) =>
{
var body = ea.Body.ToArray();
var message = Encoding.UTF8.GetString(body);
T? commandEvent = JsonSerializer.Deserialize<T>(message);
if (commandEvent != null)
appServiceCall(commandEvent);
};

_channel.BasicConsume(queue: eventQueue,
autoAck: true,
consumer: consumer);
}

E está é a classe que implementa os métodos de publicar e receber mensagens, foi utilizado apenas a documentação do RabbitMq.

channel.ExchangeDeclare(QueueExchange.CreateProductExchange, ExchangeType.Fanout);
channel.ExchangeDeclare(QueueExchange.RollbackProductExchange, ExchangeType.Fanout);
channel.ExchangeDeclare(QueueExchange.CreatePaymentExchange, ExchangeType.Fanout);
channel.QueueDeclare(EventQueue.ValidateProductQueue,false, false, false, null);
channel.QueueDeclare(EventQueue.RollbackProductQueue, false, false, false, null);
channel.QueueBind(queue: EventQueue.ValidateProductQueue, exchange: QueueExchange.CreateProductExchange, routingKey: "");
channel.QueueBind(queue: EventQueue.RollbackProductQueue, exchange: QueueExchange.RollbackProductExchange, routingKey: "");

Esse é a configuração de filas e exchanges utilizados no projeto, dentro da classe Products/Program.cs. É importante ler a documentação do próprio Rabbit mas, em resumo, é possível mandar mensagens para uma fila sem precisar de um “exchange”. Porém, não é o que o Rabbit recomenda. Digamos que ele faz o papel do carteiro. E as queues são as caixinhas de correios.

O que nós precisamos fazer é vincular o carteiro à sua respectiva caixinha, sendo que ele pode estar vinculado à mais de uma. E uma outra observação, é que uma “routing key” deveria ser necessária, porém não para o tipo Fanout de Exchange que nós estamos utilizando, então não precisamos ter uma.

Conclusão

De resto, a nossa arquitetura se resume ao Clean Architecture. A orquestração da comunicação de cada componente é a parte mais complexa desse projeto, já que cada componente possui suas próprias configurações, e é preciso ler a documentação de cada uma. Mas com esses recursos dá para fazer uma aplicação complexa e grátis. Se lembrem que esse é apenas um exemplo de configuração e de como os serviços funcionam, não deixem de inovar e melhorar cada vez mais. Aqui está o repositório do github.

--

--