Skip to content

Implementation of Choreography-based Saga in .NET Microservices

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 .
Published in.NET CoreArchitecturalASP.NET CoreMessagingMicroservicesRabbitMQTasarım Kalıpları (Design Patterns)Uncategorized

5 Comments

  1. Nizamuddin Nizamuddin

    Great explanation, keep up the good work

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.