In today’s technology era, almost all of us talk about microservices and try to develop applications. When we just talk about microservices before get started to implement, everything might seems very clear and easy to implement. But, especially when the topic comes to distributed transaction management, then things start to get complicated.
Because we need to ensure the consistency of the data in order to make our business reliable/sustainable and reach the desired business outcome.
In this article, I will try to show how we can perform transactions in distributed environments with Choreography-based Saga pattern.
The saga pattern provides us two different approaches as “Choreography” and “Orchestration” for transaction management in distributed environments.
In this article which I wrote in 2017, I had tried to explain how we can implement the saga pattern in orchestration way. Now I will try to show how we can implement the saga pattern in a loosely-coupled way without having any orchestrator.
The main idea behind the choreography-based saga approach is that each microservice performs its responsibilities individually and acts together to ensure consistency.
In other words, when each microservice performs its responsibility, it must trigger the next phase with a related business event in order to continue the transaction as distributed and asynchronously.
Let’s assume that we work for an e-commerce company and we have a simple flow to make payments asynchronously.
When we look at the happy-path flow above;
Before explaining the implementation part, you can find the full sample project here.
In addition the happy-path flow above, let’s assume we also have another business requirements as follows.
This is the first entry point of our example. It has a controller and service to create an order as follows.
[ApiController] [Route("[controller]")] public class OrdersController : ControllerBase { private readonly IOrderService _orderService; public OrdersController(IOrderService orderService) { _orderService = orderService; } [HttpPost] public async Task<IActionResult> CreateOrder(CreateOrderRequest request) { await _orderService.CreateOrderAsync(request); return Accepted(); } }
public class OrderService : IOrderService { private readonly IBus _bus; public OrderService(IBus bus) { _bus = bus; } public async Task CreateOrderAsync(CreateOrderRequest request) { // Order creation logic in "Pending" state. await _bus.PubSub.PublishAsync(new OrderCreatedEvent { UserId = 1, OrderId = 1, WalletId = 1, TotalAmount = request.TotalAmount, }); } public Task CompleteOrderAsync(int orderId) { // Change the order status as completed. return Task.CompletedTask; } public Task RejectOrderAsync(int orderId, string reason) { // Change the order status as rejected. return Task.CompletedTask; } }
“CreateOrderAsync” method is the first point where we create the order in pending state. Because at this phase, we don’t know the stock status of the products or whether we can perform the payment successfully.
After the order is created, we publish an event called “OrderCreatedEvent” to provide data and process consistency as distributed. In other words, we are triggering another phase of the transaction chain.
On the other hand, we will use the “CompleteOrderAsync” method to change the order status from pending to completed when all transactions are successfully completed.
In order for Order Service to understand whether the transactions have been completed successfully or not, there is a consumer which listens “PaymentCompletedEvent” and this event is the last part of the transaction chain.
public class PaymentCompletedEventConsumer : IConsumeAsync<PaymentCompletedEvent> { private readonly IOrderService _orderService; public PaymentCompletedEventConsumer(IOrderService orderService) { _orderService = orderService; } public async Task ConsumeAsync(PaymentCompletedEvent message, CancellationToken cancellationToken = default) { await _orderService.CompleteOrderAsync(message.OrderId); } }
Also we will use the “RejectOrderAsync” method to make the relevant order rejected in case of any error that may occur during the payment process.
In order for Order Service to set the relevant order status as rejected, it consumes the “StocksReleasedEvent“, which is published by Stock Service after releasing the relevant product stocks again.
public class StocksReleasedEventConsumer : IConsumeAsync<StocksReleasedEvent> { private readonly IOrderService _orderService; public StocksReleasedEventConsumer(IOrderService orderService) { _orderService = orderService; } public async Task ConsumeAsync(StocksReleasedEvent message, CancellationToken cancellationToken = default) { await _orderService.RejectOrderAsync(message.OrderId, message.Reason); } }
When an order is created in pending status in the system, stock of the products are reserved by Stock Service.
For this, there is a consumer that listens to “OrderCreatedEvent” as follows.
public class OrderCreatedEventConsumer : IConsumeAsync<OrderCreatedEvent> { private readonly IStockService _stockService; private readonly IBus _bus; public OrderCreatedEventConsumer(IStockService stockService, IBus bus) { _stockService = stockService; _bus = bus; } public async Task ConsumeAsync(OrderCreatedEvent message, CancellationToken cancellationToken = default) { await _stockService.ReserveStocksAsync(message.OrderId); await _bus.PubSub.PublishAsync(new StocksReservedEvent { UserId = message.UserId, OrderId = message.OrderId, WalletId = message.WalletId, TotalAmount = message.TotalAmount }); } }
In this consumer, we perform the stocks reservation operation and then we publish an event called “StockReservedEvent“. Thus, one more phase of the order transaction chain will be completed and the next phase will be triggered.
In addition, it has a consumer listening to “PaymentRejectedEvent” in order to release the reserved stock of the products in case of any error that may occur in payment transactions.
public class PaymentRejectedEventConsumer : IConsumeAsync<PaymentRejectedEvent> { private readonly IStockService _stockService; private readonly IBus _bus; public PaymentRejectedEventConsumer(IStockService stockService, IBus bus) { _stockService = stockService; _bus = bus; } public async Task ConsumeAsync(PaymentRejectedEvent message, CancellationToken cancellationToken = default) { await _stockService.ReleaseStocksAsync(message.OrderId); await _bus.PubSub.PublishAsync(new StocksReleasedEvent { OrderId = message.OrderId, Reason = message.Reason }); } }
When stock of the products are reserved, the payment operation will be carried out by the Payment Service.
In this service, there is a consumer which listens “StocksReservedEvent” in order to perform the payment operations as follows.
public class StocksReservedEventConsumer : IConsumeAsync<StocksReservedEvent> { private readonly IPaymentService _paymentService; private readonly IBus _bus; public StocksReservedEventConsumer(IPaymentService paymentService, IBus bus) { _paymentService = paymentService; _bus = bus; } public async Task ConsumeAsync(StocksReservedEvent message, CancellationToken cancellationToken = default) { Tuple<bool, string> isPaymentCompleted = await _paymentService.DoPaymentAsync(message.WalletId, message.UserId, message.TotalAmount); if (isPaymentCompleted.Item1) { await _bus.PubSub.PublishAsync(new PaymentCompletedEvent { OrderId = message.OrderId }); } else { await _bus.PubSub.PublishAsync(new PaymentRejectedEvent { OrderId = message.OrderId, Reason = isPaymentCompleted.Item2 }); } } }
Simply here we perform the payment operations. If the payment operation is completed successfully, “PaymentCompletedEvent” event will be published in order for Order Service to set the related order status as completed.
If the payment operations is not completed successfully, “PaymentRejectedEvent” will be published. Thus, the Stock Service will be able to release again reserved stock of the products according to our sample business requirement.
In this way, the order transaction will be completed in a distributed way as loosely coupled and consistent across all the microservices.
As each design pattern offers a solution to a specific business problem, the saga pattern offers us a method for transaction management in distributed environments. The basic logic behind this method is based on the act in a flow and harmony of our applications as a member of a dance team.
Although it looks like a simple pattern to implement, but there are some disadvantages/difficulties.
{:tr} Makalenin ilk bölümünde, Software Supply Chain güvenliğinin öneminden ve containerized uygulamaların güvenlik risklerini azaltabilmek…
{:tr}Bildiğimiz gibi modern yazılım geliştirme ortamında containerization'ın benimsenmesi, uygulamaların oluşturulma ve dağıtılma şekillerini oldukça değiştirdi.…
{:tr}Bildiğimiz gibi bir ürün geliştirirken olabildiğince farklı cloud çözümlerinden faydalanmak, harcanacak zaman ve karmaşıklığın yanı…
{:tr}Bazen bazı senaryolar vardır karmaşıklığını veya eksi yanlarını bildiğimiz halde implemente etmekten kaçamadığımız veya implemente…
{:tr}Bildiğimiz gibi microservice architecture'ına adapte olmanın bir çok artı noktası olduğu gibi, maalesef getirdiği bazı…
{:tr}Bir önceki makale serisinde Dapr projesinden ve faydalarından bahsedip, local ortamda self-hosted mode olarak .NET…
View Comments
Great explanation, keep up the good work
Thanks!
https://github.com/GokGokalp/choreography-saga-dotnet