Files
pos-system/microservices/.agent/skills/saga-pattern/references/REFERENCE.md
Ho Ngoc Hai 76d75c753b Migrate
2026-05-23 18:37:02 +07:00

17 KiB

SAGA Pattern - Reference Examples

Complete Implementation Examples

1. Complete MassTransit Configuration

/// <summary>
/// EN: Configure MassTransit with saga support.
/// VI: Cấu hình MassTransit với hỗ trợ saga.
/// </summary>
public static class SagaServiceExtensions
{
    public static IServiceCollection AddSagaSupport(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        services.AddMassTransit(x =>
        {
            // EN: Add saga state machine
            // VI: Thêm saga state machine
            x.AddSagaStateMachine<OrderSaga, OrderSagaState>()
                .EntityFrameworkRepository(r =>
                {
                    r.ConcurrencyMode = ConcurrencyMode.Pessimistic;
                    r.AddDbContext<DbContext, SagaDbContext>((provider, builder) =>
                    {
                        builder.UseNpgsql(configuration.GetConnectionString("SagaDb"));
                    });
                });

            // EN: Add consumers
            x.AddConsumer<ReserveInventoryConsumer>();
            x.AddConsumer<ReleaseInventoryConsumer>();
            x.AddConsumer<ProcessPaymentConsumer>();
            x.AddConsumer<RefundPaymentConsumer>();
            x.AddConsumer<CompleteOrderConsumer>();
            x.AddConsumer<CancelOrderConsumer>();

            x.UsingRabbitMq((context, cfg) =>
            {
                cfg.Host(configuration["RabbitMQ:Host"], "/", h =>
                {
                    h.Username(configuration["RabbitMQ:Username"]!);
                    h.Password(configuration["RabbitMQ:Password"]!);
                });

                // EN: Configure retry policy
                cfg.UseMessageRetry(r => r.Intervals(
                    TimeSpan.FromSeconds(1),
                    TimeSpan.FromSeconds(5),
                    TimeSpan.FromSeconds(15)));

                cfg.ConfigureEndpoints(context);
            });
        });

        return services;
    }
}

2. Saga DbContext

/// <summary>
/// EN: DbContext for saga state persistence.
/// VI: DbContext để lưu trữ saga state.
/// </summary>
public class SagaDbContext : DbContext
{
    public DbSet<OrderSagaState> OrderSagas => Set<OrderSagaState>();

    public SagaDbContext(DbContextOptions<SagaDbContext> options) 
        : base(options) { }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<OrderSagaState>(entity =>
        {
            entity.ToTable("OrderSagas");
            
            entity.HasKey(x => x.CorrelationId);
            
            entity.Property(x => x.CurrentState)
                .HasMaxLength(100)
                .IsRequired();
                
            entity.Property(x => x.UserId)
                .HasMaxLength(100);
                
            entity.Property(x => x.PaymentId)
                .HasMaxLength(100);
                
            entity.Property(x => x.FailureReason)
                .HasMaxLength(500);

            // EN: Optimistic concurrency
            entity.Property(x => x.RowVersion)
                .IsRowVersion();

            entity.HasIndex(x => x.OrderId);
            entity.HasIndex(x => x.CurrentState);
        });
    }
}

/// <summary>
/// EN: Extended saga state with row version.
/// VI: Saga state mở rộng với row version.
/// </summary>
public class OrderSagaState : SagaStateMachineInstance
{
    public Guid CorrelationId { get; set; }
    public string CurrentState { get; set; } = default!;
    
    public Guid OrderId { get; set; }
    public string UserId { get; set; } = default!;
    public decimal TotalAmount { get; set; }
    public string? PaymentId { get; set; }
    public string? FailureReason { get; set; }
    
    // EN: For timeout scheduling
    public Guid? ReservationTimeoutToken { get; set; }
    public Guid? PaymentTimeoutToken { get; set; }
    
    // EN: Optimistic concurrency
    public byte[] RowVersion { get; set; } = null!;
}

3. Complete Saga with Timeouts

/// <summary>
/// EN: Complete order saga with timeout handling.
/// VI: Order saga hoàn chỉnh với xử lý timeout.
/// </summary>
public class OrderSagaWithTimeouts : MassTransitStateMachine<OrderSagaState>
{
    public State OrderCreated { get; private set; } = null!;
    public State InventoryReserved { get; private set; } = null!;
    public State PaymentProcessing { get; private set; } = null!;
    public State Completed { get; private set; } = null!;
    public State Failed { get; private set; } = null!;
    public State Compensating { get; private set; } = null!;

    public Event<OrderSubmitted> OrderSubmittedEvent { get; private set; } = null!;
    public Event<InventoryReserved> InventoryReservedEvent { get; private set; } = null!;
    public Event<InventoryReservationFailed> InventoryFailedEvent { get; private set; } = null!;
    public Event<PaymentProcessed> PaymentProcessedEvent { get; private set; } = null!;
    public Event<PaymentFailed> PaymentFailedEvent { get; private set; } = null!;
    public Event<InventoryReleased> InventoryReleasedEvent { get; private set; } = null!;

    // EN: Timeout schedules / VI: Lịch timeout
    public Schedule<OrderSagaState, ReservationTimeout> ReservationTimeoutSchedule { get; private set; } = null!;
    public Schedule<OrderSagaState, PaymentTimeout> PaymentTimeoutSchedule { get; private set; } = null!;

    public OrderSagaWithTimeouts()
    {
        InstanceState(x => x.CurrentState);

        // EN: Configure events
        ConfigureEvents();
        
        // EN: Configure timeouts
        ConfigureTimeouts();
        
        // EN: Configure state machine
        ConfigureStateMachine();

        SetCompletedWhenFinalized();
    }

    private void ConfigureEvents()
    {
        Event(() => OrderSubmittedEvent, x => x.CorrelateById(m => m.Message.OrderId));
        Event(() => InventoryReservedEvent, x => x.CorrelateById(m => m.Message.OrderId));
        Event(() => InventoryFailedEvent, x => x.CorrelateById(m => m.Message.OrderId));
        Event(() => PaymentProcessedEvent, x => x.CorrelateById(m => m.Message.OrderId));
        Event(() => PaymentFailedEvent, x => x.CorrelateById(m => m.Message.OrderId));
        Event(() => InventoryReleasedEvent, x => x.CorrelateById(m => m.Message.OrderId));
    }

    private void ConfigureTimeouts()
    {
        Schedule(() => ReservationTimeoutSchedule, 
            x => x.ReservationTimeoutToken,
            s => s.Delay = TimeSpan.FromMinutes(5));

        Schedule(() => PaymentTimeoutSchedule,
            x => x.PaymentTimeoutToken,
            s => s.Delay = TimeSpan.FromMinutes(10));
    }

    private void ConfigureStateMachine()
    {
        Initially(
            When(OrderSubmittedEvent)
                .Then(InitializeSagaData)
                .Schedule(ReservationTimeoutSchedule, ctx => new ReservationTimeout
                {
                    OrderId = ctx.Saga.OrderId
                })
                .Publish(ctx => new ReserveInventory
                {
                    OrderId = ctx.Saga.OrderId,
                    Items = ctx.Message.Items
                })
                .TransitionTo(OrderCreated)
        );

        During(OrderCreated,
            When(InventoryReservedEvent)
                .Unschedule(ReservationTimeoutSchedule)
                .Schedule(PaymentTimeoutSchedule, ctx => new PaymentTimeout
                {
                    OrderId = ctx.Saga.OrderId
                })
                .Publish(ctx => new ProcessPayment
                {
                    OrderId = ctx.Saga.OrderId,
                    UserId = ctx.Saga.UserId,
                    Amount = ctx.Saga.TotalAmount
                })
                .TransitionTo(InventoryReserved),

            When(InventoryFailedEvent)
                .Unschedule(ReservationTimeoutSchedule)
                .Then(ctx => ctx.Saga.FailureReason = ctx.Message.Reason)
                .Publish(ctx => new CancelOrder
                {
                    OrderId = ctx.Saga.OrderId,
                    Reason = ctx.Message.Reason
                })
                .TransitionTo(Failed),

            When(ReservationTimeoutSchedule.Received)
                .Then(ctx => ctx.Saga.FailureReason = "Inventory reservation timeout")
                .Publish(ctx => new CancelOrder
                {
                    OrderId = ctx.Saga.OrderId,
                    Reason = "Timeout waiting for inventory"
                })
                .TransitionTo(Failed)
        );

        During(InventoryReserved,
            When(PaymentProcessedEvent)
                .Unschedule(PaymentTimeoutSchedule)
                .Then(ctx => ctx.Saga.PaymentId = ctx.Message.PaymentId)
                .Publish(ctx => new CompleteOrder
                {
                    OrderId = ctx.Saga.OrderId,
                    PaymentId = ctx.Message.PaymentId
                })
                .TransitionTo(Completed)
                .Finalize(),

            When(PaymentFailedEvent)
                .Unschedule(PaymentTimeoutSchedule)
                .Then(ctx => ctx.Saga.FailureReason = ctx.Message.Reason)
                .Publish(ctx => new ReleaseInventory
                {
                    OrderId = ctx.Saga.OrderId
                })
                .TransitionTo(Compensating),

            When(PaymentTimeoutSchedule.Received)
                .Then(ctx => ctx.Saga.FailureReason = "Payment processing timeout")
                .Publish(ctx => new ReleaseInventory
                {
                    OrderId = ctx.Saga.OrderId
                })
                .TransitionTo(Compensating)
        );

        During(Compensating,
            When(InventoryReleasedEvent)
                .Publish(ctx => new CancelOrder
                {
                    OrderId = ctx.Saga.OrderId,
                    Reason = ctx.Saga.FailureReason ?? "Unknown error"
                })
                .TransitionTo(Failed)
        );
    }

    private static void InitializeSagaData(BehaviorContext<OrderSagaState, OrderSubmitted> ctx)
    {
        ctx.Saga.OrderId = ctx.Message.OrderId;
        ctx.Saga.UserId = ctx.Message.UserId;
        ctx.Saga.TotalAmount = ctx.Message.TotalAmount;
    }
}

public record ReservationTimeout { public Guid OrderId { get; init; } }
public record PaymentTimeout { public Guid OrderId { get; init; } }

4. Choreography-Style Saga

/// <summary>
/// EN: Choreography-style saga using domain events.
/// VI: Saga kiểu choreography dùng domain events.
/// </summary>

// EN: Order Service publishes event after creation
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, OrderResult>
{
    private readonly IOrderRepository _repository;
    private readonly IPublishEndpoint _publishEndpoint;

    public async Task<OrderResult> Handle(CreateOrderCommand request, CancellationToken ct)
    {
        var order = new Order(request.UserId, request.Address);
        foreach (var item in request.Items)
            order.AddItem(item.ProductId, item.Quantity, item.UnitPrice);

        await _repository.AddAsync(order, ct);
        await _repository.UnitOfWork.SaveChangesAsync(ct);

        // EN: Publish event for choreography
        // VI: Publish event cho choreography
        await _publishEndpoint.Publish(new OrderCreatedIntegrationEvent
        {
            OrderId = order.Id,
            UserId = order.UserId,
            TotalAmount = order.TotalAmount,
            Items = order.Items.Select(i => new OrderItemInfo(
                i.ProductId, i.Quantity, i.UnitPrice)).ToList()
        }, ct);

        return new OrderResult(order.Id);
    }
}

// EN: Inventory Service reacts to OrderCreated
public class OrderCreatedInventoryConsumer : IConsumer<OrderCreatedIntegrationEvent>
{
    private readonly IInventoryService _inventory;
    private readonly IPublishEndpoint _publishEndpoint;

    public async Task Consume(ConsumeContext<OrderCreatedIntegrationEvent> context)
    {
        try
        {
            foreach (var item in context.Message.Items)
            {
                await _inventory.ReserveAsync(
                    item.ProductId,
                    item.Quantity,
                    context.Message.OrderId);
            }

            // EN: Notify success
            await context.Publish(new InventoryReservedIntegrationEvent
            {
                OrderId = context.Message.OrderId
            });
        }
        catch (InsufficientStockException ex)
        {
            // EN: Notify failure
            await context.Publish(new InventoryReservationFailedIntegrationEvent
            {
                OrderId = context.Message.OrderId,
                Reason = ex.Message
            });
        }
    }
}

// EN: Payment Service reacts to InventoryReserved
public class InventoryReservedPaymentConsumer : IConsumer<InventoryReservedIntegrationEvent>
{
    private readonly IPaymentService _payment;
    private readonly IOrderRepository _orders;
    private readonly IPublishEndpoint _publishEndpoint;

    public async Task Consume(ConsumeContext<InventoryReservedIntegrationEvent> context)
    {
        var order = await _orders.GetByIdAsync(context.Message.OrderId);
        
        try
        {
            var paymentId = await _payment.ChargeAsync(
                order!.UserId,
                order.TotalAmount);

            await context.Publish(new PaymentProcessedIntegrationEvent
            {
                OrderId = context.Message.OrderId,
                PaymentId = paymentId
            });
        }
        catch (PaymentFailedException ex)
        {
            await context.Publish(new PaymentFailedIntegrationEvent
            {
                OrderId = context.Message.OrderId,
                Reason = ex.Message
            });
        }
    }
}

// EN: Inventory Service compensates on payment failure
public class PaymentFailedInventoryConsumer : IConsumer<PaymentFailedIntegrationEvent>
{
    private readonly IInventoryService _inventory;

    public async Task Consume(ConsumeContext<PaymentFailedIntegrationEvent> context)
    {
        // EN: Compensating action
        await _inventory.ReleaseReservationAsync(context.Message.OrderId);
    }
}

5. Saga Monitoring and Querying

/// <summary>
/// EN: Query service for saga status.
/// VI: Service truy vấn trạng thái saga.
/// </summary>
public class SagaQueryService
{
    private readonly SagaDbContext _context;

    public SagaQueryService(SagaDbContext context)
    {
        _context = context;
    }

    public async Task<OrderSagaStatusDto?> GetOrderSagaStatusAsync(
        Guid orderId,
        CancellationToken ct = default)
    {
        var saga = await _context.OrderSagas
            .FirstOrDefaultAsync(s => s.OrderId == orderId, ct);

        if (saga == null)
            return null;

        return new OrderSagaStatusDto
        {
            OrderId = saga.OrderId,
            CurrentState = saga.CurrentState,
            PaymentId = saga.PaymentId,
            FailureReason = saga.FailureReason,
            IsCompleted = saga.CurrentState == "Completed",
            IsFailed = saga.CurrentState == "Failed"
        };
    }

    public async Task<List<OrderSagaStatusDto>> GetStuckSagasAsync(
        TimeSpan maxAge,
        CancellationToken ct = default)
    {
        var cutoff = DateTime.UtcNow - maxAge;

        return await _context.OrderSagas
            .Where(s => s.CurrentState != "Completed" 
                     && s.CurrentState != "Failed"
                     && s.CreatedAt < cutoff)
            .Select(s => new OrderSagaStatusDto
            {
                OrderId = s.OrderId,
                CurrentState = s.CurrentState,
                FailureReason = "Potentially stuck"
            })
            .ToListAsync(ct);
    }
}

public record OrderSagaStatusDto
{
    public Guid OrderId { get; init; }
    public string CurrentState { get; init; } = default!;
    public string? PaymentId { get; init; }
    public string? FailureReason { get; init; }
    public bool IsCompleted { get; init; }
    public bool IsFailed { get; init; }
}

Database Migrations

-- EN: Create OrderSagas table / VI: Tạo bảng OrderSagas
CREATE TABLE "OrderSagas" (
    "CorrelationId" uuid PRIMARY KEY,
    "CurrentState" varchar(100) NOT NULL,
    "OrderId" uuid NOT NULL,
    "UserId" varchar(100),
    "TotalAmount" decimal(18,2) NOT NULL DEFAULT 0,
    "PaymentId" varchar(100),
    "FailureReason" varchar(500),
    "ReservationTimeoutToken" uuid,
    "PaymentTimeoutToken" uuid,
    "RowVersion" bytea NOT NULL,
    "CreatedAt" timestamp with time zone NOT NULL DEFAULT NOW()
);

CREATE INDEX "IX_OrderSagas_OrderId" ON "OrderSagas" ("OrderId");
CREATE INDEX "IX_OrderSagas_CurrentState" ON "OrderSagas" ("CurrentState");
CREATE INDEX "IX_OrderSagas_StuckSagas" 
    ON "OrderSagas" ("CurrentState", "CreatedAt")
    WHERE "CurrentState" NOT IN ('Completed', 'Failed');