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.

Choreography-based Saga

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.

https://cirendanceclub.org.uk/wp-content/uploads/2018/09/12.jpg

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.

Scenario

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;

  1. The client performs the order operation via “Order Service” and this service creates order in “Pending” state. Then it publishes “OrderCreatedEvent”.
  2. Stock Service“, which is responsible from stock operations, listens “OrderCreatedEvent” and reserves stocks of the related products. Then publishes an event called “StockReservedEvent”.
  3. Payment Service“, which is responsible from payment operations, listens “StockReservedEvent” and performs payment operations. If the payment operation is successfully completed then it publishes a “PaymentCompletedEvent”.
  4. Order Service” listens “PaymentCompletedEvent” to finalize the transaction and updates the state of the order from “Pending” to “Completed”. Thus, the transaction process will be performed as distributed and consistent between each microservices.

Let’s Implement

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.

  • If any error occurs during the asynchronous payment process, an event will be published called “PaymentRejectedEvent“.
  • PaymentRejectedEvent” event will be consumed by Stock Service and reserved stock of the products will be released again. Then “StocksReleasedEvent” will be published.
  • StocksReleasedEvent” also will be consumed by Order Service to update the related order state from pending to rejected.

Order API

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);
    }
}

Stock Service

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
        });
    }
}

Payment Service

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.

Let’s wrap it up

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.

  • The system should be designed properly by considering each scenario and it should be clear for all relevant team members.
  • Each service in the transaction chain must provide the relevant compensating methods.
  • Resiliency is an important topic since the consistency will be go through events. For this, it should be ensured that related events can be published successfully with the help of patterns such as outbox.
  • In cases where many different services need to be included in the transaction chain, the system may start to become complex. In such cases, it may be a more appropriate approach to prefer orchestration instead of choreography .
Gökhan Gökalp

View Comments

Recent Posts

Securing the Supply Chain of Containerized Applications to Reduce Security Risks (Policy Enforcement-Automated Governance with OPA Gatekeeper and Ratify) – Part 2

{:tr} Makalenin ilk bölümünde, Software Supply Chain güvenliğinin öneminden ve containerized uygulamaların güvenlik risklerini azaltabilmek…

5 months ago

Securing the Supply Chain of Containerized Applications to Reduce Security Risks (Security Scanning, SBOMs, Signing&Verifying Artifacts) – Part 1

{: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.…

8 months ago

Delegating Identity & Access Management to Azure AD B2C and Integrating with .NET

{:tr}Bildiğimiz gibi bir ürün geliştirirken olabildiğince farklı cloud çözümlerinden faydalanmak, harcanacak zaman ve karmaşıklığın yanı…

12 months ago

How to Order Events in Microservices by Using Azure Service Bus (FIFO Consumers)

{:tr}Bazen bazı senaryolar vardır karmaşıklığını veya eksi yanlarını bildiğimiz halde implemente etmekten kaçamadığımız veya implemente…

2 years ago

Providing Atomicity for Eventual Consistency with Outbox Pattern in .NET Microservices

{:tr}Bildiğimiz gibi microservice architecture'ına adapte olmanın bir çok artı noktası olduğu gibi, maalesef getirdiği bazı…

2 years ago

Building Microservices by Using Dapr and .NET with Minimum Effort – 02 (Azure Container Apps)

{:tr}Bir önceki makale serisinde Dapr projesinden ve faydalarından bahsedip, local ortamda self-hosted mode olarak .NET…

2 years ago