# SAGA Pattern - Reference Examples ## Complete Implementation Examples ### 1. Complete MassTransit Configuration ```csharp /// /// EN: Configure MassTransit with saga support. /// VI: Cấu hình MassTransit với hỗ trợ saga. /// 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() .EntityFrameworkRepository(r => { r.ConcurrencyMode = ConcurrencyMode.Pessimistic; r.AddDbContext((provider, builder) => { builder.UseNpgsql(configuration.GetConnectionString("SagaDb")); }); }); // EN: Add consumers x.AddConsumer(); x.AddConsumer(); x.AddConsumer(); x.AddConsumer(); x.AddConsumer(); x.AddConsumer(); 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 ```csharp /// /// EN: DbContext for saga state persistence. /// VI: DbContext để lưu trữ saga state. /// public class SagaDbContext : DbContext { public DbSet OrderSagas => Set(); public SagaDbContext(DbContextOptions options) : base(options) { } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity(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); }); } } /// /// EN: Extended saga state with row version. /// VI: Saga state mở rộng với row version. /// 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 ```csharp /// /// EN: Complete order saga with timeout handling. /// VI: Order saga hoàn chỉnh với xử lý timeout. /// public class OrderSagaWithTimeouts : MassTransitStateMachine { 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 OrderSubmittedEvent { get; private set; } = null!; public Event InventoryReservedEvent { get; private set; } = null!; public Event InventoryFailedEvent { get; private set; } = null!; public Event PaymentProcessedEvent { get; private set; } = null!; public Event PaymentFailedEvent { get; private set; } = null!; public Event InventoryReleasedEvent { get; private set; } = null!; // EN: Timeout schedules / VI: Lịch timeout public Schedule ReservationTimeoutSchedule { get; private set; } = null!; public Schedule 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 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 ```csharp /// /// EN: Choreography-style saga using domain events. /// VI: Saga kiểu choreography dùng domain events. /// // EN: Order Service publishes event after creation public class CreateOrderCommandHandler : IRequestHandler { private readonly IOrderRepository _repository; private readonly IPublishEndpoint _publishEndpoint; public async Task 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 { private readonly IInventoryService _inventory; private readonly IPublishEndpoint _publishEndpoint; public async Task Consume(ConsumeContext 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 { private readonly IPaymentService _payment; private readonly IOrderRepository _orders; private readonly IPublishEndpoint _publishEndpoint; public async Task Consume(ConsumeContext 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 { private readonly IInventoryService _inventory; public async Task Consume(ConsumeContext context) { // EN: Compensating action await _inventory.ReleaseReservationAsync(context.Message.OrderId); } } ``` ### 5. Saga Monitoring and Querying ```csharp /// /// EN: Query service for saga status. /// VI: Service truy vấn trạng thái saga. /// public class SagaQueryService { private readonly SagaDbContext _context; public SagaQueryService(SagaDbContext context) { _context = context; } public async Task 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> 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 ```sql -- 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'); ```