--- name: outbox-pattern description: Transactional Outbox Pattern - đảm bảo atomicity khi publish events. Use for reliable messaging, at-least-once delivery, và event consistency. compatibility: ".NET 10+, EF Core, MassTransit, PostgreSQL" metadata: author: Velik Ho version: "1.0" --- # Outbox Pattern / Transactional Outbox Pattern đảm bảo tính nhất quán khi publish events từ microservices. ## When to Use This Skill / Khi Nào Sử Dụng Use this skill when: - Publishing integration events reliably / Publish integration events tin cậy - Ensuring at-least-once delivery / Đảm bảo gửi ít nhất một lần - Avoiding dual-write problem / Tránh vấn đề dual-write - Distributed transactions not feasible / Không khả thi dùng distributed transactions ## Core Concepts / Khái Niệm Cốt Lõi ### The Dual-Write Problem / Vấn Đề Dual-Write ``` ┌─────────────────────────────────────────────────────────────┐ │ ❌ DUAL-WRITE PROBLEM │ ├─────────────────────────────────────────────────────────────┤ │ │ │ Service A │ │ ┌─────────────────────────────────────────────┐ │ │ │ 1. await _db.SaveChangesAsync(); ✅ │ │ │ │ 2. await _bus.PublishAsync(event); ❌ FAIL! │ │ │ └─────────────────────────────────────────────┘ │ │ │ │ Result: DB updated BUT message NOT sent = INCONSISTENCY │ │ │ └─────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────┐ │ ✅ OUTBOX PATTERN │ ├─────────────────────────────────────────────────────────────┤ │ │ │ Service A │ │ ┌─────────────────────────────────────────────┐ │ │ │ BEGIN TRANSACTION │ │ │ │ 1. INSERT INTO Orders ... │ │ │ │ 2. INSERT INTO Outbox (event) │ │ │ │ COMMIT │ │ │ └─────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────┐ │ │ │ Outbox Processor (Background) │ │ │ │ 1. SELECT * FROM Outbox WHERE Sent = false │ │ │ │ 2. Publish to Message Bus │ │ │ │ 3. UPDATE Outbox SET Sent = true │ │ │ └─────────────────────────────────────────────┘ │ │ │ │ Result: ATOMIC - Both or Neither │ │ │ └─────────────────────────────────────────────────────────────┘ ``` ### Outbox Flow / Luồng Outbox ``` ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Command │───▶│ Handler │───▶│ DB + Outbox│───▶│ Processor │ │ (API) │ │ (Domain) │ │ (Same Tx) │ │ (Background)│ └─────────────┘ └─────────────┘ └─────────────┘ └──────┬──────┘ │ ▼ ┌─────────────┐ │ Message Bus │ │ (RabbitMQ) │ └─────────────┘ ``` ### Key Components / Thành Phần Chính | Component | Purpose | Implementation | |-----------|---------|----------------| | **Outbox Table** | Store pending events | EF Core entity | | **Outbox Processor** | Publish pending events | BackgroundService | | **Idempotency** | Prevent duplicates | Event ID tracking | ## Key Patterns / Mẫu Chính ### Outbox Entity / Entity Outbox ```csharp /// /// EN: Outbox message entity for transactional messaging. /// VI: Entity outbox message cho transactional messaging. /// public class OutboxMessage { public Guid Id { get; set; } public string EventType { get; set; } = default!; public string Payload { get; set; } = default!; public DateTime CreatedAt { get; set; } public DateTime? ProcessedAt { get; set; } public bool IsProcessed { get; set; } public int RetryCount { get; set; } public string? Error { get; set; } } ``` ### DbContext Configuration / Cấu Hình DbContext ```csharp /// /// EN: DbContext with outbox support. /// VI: DbContext với hỗ trợ outbox. /// public class OrderDbContext : DbContext { public DbSet Orders => Set(); public DbSet OutboxMessages => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity(entity => { entity.ToTable("OutboxMessages"); entity.HasKey(e => e.Id); entity.Property(e => e.EventType) .HasMaxLength(500) .IsRequired(); entity.Property(e => e.Payload) .HasColumnType("jsonb") .IsRequired(); entity.HasIndex(e => new { e.IsProcessed, e.CreatedAt }) .HasFilter("\"IsProcessed\" = false"); }); } } ``` ### Unit of Work with Outbox / Unit of Work với Outbox ```csharp /// /// EN: Unit of Work that saves domain events to outbox. /// VI: Unit of Work lưu domain events vào outbox. /// public interface IUnitOfWork { Task SaveChangesAsync(CancellationToken ct = default); } public class UnitOfWork : IUnitOfWork { private readonly OrderDbContext _context; private readonly ILogger _logger; public UnitOfWork(OrderDbContext context, ILogger logger) { _context = context; _logger = logger; } public async Task SaveChangesAsync(CancellationToken ct = default) { // EN: Convert domain events to outbox messages // VI: Chuyển domain events thành outbox messages var aggregates = _context.ChangeTracker .Entries() .Where(e => e.Entity.DomainEvents.Any()) .Select(e => e.Entity) .ToList(); foreach (var aggregate in aggregates) { foreach (var domainEvent in aggregate.DomainEvents) { var outboxMessage = new OutboxMessage { Id = Guid.NewGuid(), EventType = domainEvent.GetType().AssemblyQualifiedName!, Payload = JsonSerializer.Serialize(domainEvent, domainEvent.GetType()), CreatedAt = DateTime.UtcNow, IsProcessed = false }; _context.OutboxMessages.Add(outboxMessage); } aggregate.ClearDomainEvents(); } var result = await _context.SaveChangesAsync(ct); _logger.LogDebug("Saved {Count} changes with outbox messages", result); return result; } } ``` ### Outbox Processor / Outbox Processor ```csharp /// /// EN: Background service that processes outbox messages. /// VI: Background service xử lý outbox messages. /// public class OutboxProcessor : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; private readonly TimeSpan _pollingInterval = TimeSpan.FromSeconds(5); private const int BatchSize = 100; private const int MaxRetries = 5; public OutboxProcessor( IServiceScopeFactory scopeFactory, ILogger logger) { _scopeFactory = scopeFactory; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken ct) { _logger.LogInformation("Outbox Processor started"); while (!ct.IsCancellationRequested) { try { await ProcessPendingMessagesAsync(ct); } catch (Exception ex) { _logger.LogError(ex, "Error processing outbox messages"); } await Task.Delay(_pollingInterval, ct); } } private async Task ProcessPendingMessagesAsync(CancellationToken ct) { using var scope = _scopeFactory.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); var publishEndpoint = scope.ServiceProvider.GetRequiredService(); var messages = await context.OutboxMessages .Where(m => !m.IsProcessed && m.RetryCount < MaxRetries) .OrderBy(m => m.CreatedAt) .Take(BatchSize) .ToListAsync(ct); foreach (var message in messages) { try { var eventType = Type.GetType(message.EventType); if (eventType == null) { _logger.LogWarning("Unknown event type: {EventType}", message.EventType); continue; } var @event = JsonSerializer.Deserialize(message.Payload, eventType); await publishEndpoint.Publish(@event!, eventType, ct); message.IsProcessed = true; message.ProcessedAt = DateTime.UtcNow; _logger.LogDebug("Published outbox message {Id}", message.Id); } catch (Exception ex) { message.RetryCount++; message.Error = ex.Message; _logger.LogWarning(ex, "Failed to publish message {Id}, retry {Retry}", message.Id, message.RetryCount); } } await context.SaveChangesAsync(ct); } } ``` ### Command Handler with Outbox / Command Handler với Outbox ```csharp /// /// EN: Command handler that raises domain events saved to outbox. /// VI: Command handler phát domain events được lưu vào outbox. /// public class CreateOrderCommandHandler : IRequestHandler { private readonly IOrderRepository _repository; private readonly ILogger _logger; public CreateOrderCommandHandler( IOrderRepository repository, ILogger logger) { _repository = repository; _logger = logger; } public async Task Handle(CreateOrderCommand request, CancellationToken ct) { var order = new Order(request.UserId, request.ShippingAddress); foreach (var item in request.Items) { order.AddItem(item.ProductId, item.Quantity, item.UnitPrice); } // EN: Domain events are raised inside aggregate // VI: Domain events được phát trong aggregate await _repository.AddAsync(order, ct); // EN: SaveChanges converts domain events to outbox messages // VI: SaveChanges chuyển domain events thành outbox messages await _repository.UnitOfWork.SaveChangesAsync(ct); _logger.LogInformation("Order created: {OrderId}", order.Id); return new OrderResult(order.Id); } } ``` ## Common Mistakes / Lỗi Thường Gặp ### 1. Publishing Before Saving ```csharp // ❌ BAD: Publish before DB commit await _publishEndpoint.Publish(new OrderCreatedEvent(order.Id)); await _dbContext.Orders.AddAsync(order); await _dbContext.SaveChangesAsync(); // If SaveChanges fails, message is already sent! // ✅ GOOD: Use outbox pattern order.AddDomainEvent(new OrderCreatedEvent(order.Id)); await _dbContext.Orders.AddAsync(order); await _dbContext.SaveChangesAsync(); // Saves both order AND outbox in same transaction ``` ### 2. No Idempotency in Consumers ```csharp // ❌ BAD: No idempotency check public async Task Consume(ConsumeContext context) { await _service.ProcessOrder(context.Message.OrderId); // May process duplicate messages! } // ✅ GOOD: Idempotent consumer public async Task Consume(ConsumeContext context) { var eventId = context.MessageId; if (await _processedMessages.ExistsAsync(eventId)) return; // Already processed await _service.ProcessOrder(context.Message.OrderId); await _processedMessages.MarkAsync(eventId); } ``` ### 3. No Retry Limit ```csharp // ❌ BAD: Infinite retries var messages = await context.OutboxMessages .Where(m => !m.IsProcessed) // Will keep retrying failed messages forever .ToListAsync(); // ✅ GOOD: Retry limit with dead letter var messages = await context.OutboxMessages .Where(m => !m.IsProcessed && m.RetryCount < MaxRetries) .ToListAsync(); // Move failed messages to dead letter after max retries ``` ## Quick Reference / Tham Chiếu Nhanh ### Outbox Table Schema | Column | Type | Purpose | |--------|------|---------| | Id | UUID | Primary key | | EventType | VARCHAR(500) | Full type name for deserialization | | Payload | JSONB | Serialized event data | | CreatedAt | TIMESTAMP | When event was created | | ProcessedAt | TIMESTAMP | When event was published | | IsProcessed | BOOLEAN | Processing status | | RetryCount | INT | Failed attempts | | Error | TEXT | Last error message | ### Delivery Guarantees | Pattern | Guarantee | Trade-off | |---------|-----------|-----------| | Direct publish | Best-effort | May lose messages | | Outbox | At-least-once | May duplicate messages | | Inbox + Outbox | Exactly-once | More complexity | ## Resources / Tài Nguyên - [Detailed Examples](./references/REFERENCE.md) - Full code examples - [Event Sourcing](../event-sourcing/SKILL.md) - Event-based persistence - [Inter-service Communication](../inter-service-communication/SKILL.md) - MassTransit - [SAGA Pattern](../saga-pattern/SKILL.md) - Distributed transactions - [Repository Pattern](../repository-pattern/SKILL.md) - Unit of Work