From 0e658caad0123fe0f1e10c9fd46ec3212558fea2 Mon Sep 17 00:00:00 2001 From: BALAJI BASHYAM Date: Tue, 28 Jul 2026 15:01:07 +0000 Subject: [PATCH 1/3] Add lightweight transactional outbox sample --- .../transactional-outbox-ef-core/README.md | 154 ++++++++++++++++++ .../TransactionalOutboxMinimal.slnx | 4 + .../Data/OrdersDbContext.cs | 42 +++++ .../Domain/Order.cs | 28 ++++ .../Messaging/IPublisher.cs | 10 ++ .../Messaging/InMemoryPublisher.cs | 19 +++ .../Messaging/OrderPlaced.cs | 7 + .../Messaging/OutboxMessage.cs | 35 ++++ .../Messaging/OutboxRelay.cs | 42 +++++ .../src/TransactionalOutboxMinimal/Program.cs | 76 +++++++++ .../Services/OrderService.cs | 47 ++++++ .../TransactionalOutboxMinimal.csproj | 15 ++ .../OutboxFlowTests.cs | 137 ++++++++++++++++ .../TransactionalOutboxMinimal.Tests.csproj | 24 +++ 14 files changed, 640 insertions(+) create mode 100644 distributed-systems/transactional-outbox-ef-core/README.md create mode 100644 distributed-systems/transactional-outbox-ef-core/TransactionalOutboxMinimal.slnx create mode 100644 distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Data/OrdersDbContext.cs create mode 100644 distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Domain/Order.cs create mode 100644 distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Messaging/IPublisher.cs create mode 100644 distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Messaging/InMemoryPublisher.cs create mode 100644 distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Messaging/OrderPlaced.cs create mode 100644 distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Messaging/OutboxMessage.cs create mode 100644 distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Messaging/OutboxRelay.cs create mode 100644 distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Program.cs create mode 100644 distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Services/OrderService.cs create mode 100644 distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/TransactionalOutboxMinimal.csproj create mode 100644 distributed-systems/transactional-outbox-ef-core/tests/TransactionalOutboxMinimal.Tests/OutboxFlowTests.cs create mode 100644 distributed-systems/transactional-outbox-ef-core/tests/TransactionalOutboxMinimal.Tests/TransactionalOutboxMinimal.Tests.csproj diff --git a/distributed-systems/transactional-outbox-ef-core/README.md b/distributed-systems/transactional-outbox-ef-core/README.md new file mode 100644 index 0000000..50b70d5 --- /dev/null +++ b/distributed-systems/transactional-outbox-ef-core/README.md @@ -0,0 +1,154 @@ +# Transactional Outbox with EF Core — Minimal SQLite Sample + +Full tutorial: [Transactional Outbox Pattern in .NET with EF Core (.NET 10): Fix the Dual-Write Problem](https://www.dotnet-guide.com/tutorials/distributed-systems/transactional-outbox-ef-core/) + +## What this sample demonstrates + +- .NET 10 console application +- EF Core 10 with SQLite relational storage +- One `Order` business entity +- One `OrderPlaced` integration event +- One `OutboxMessage` outbox row +- One atomic `SaveChangesAsync` writing order + outbox together +- One broker-neutral `IPublisher` interface +- One `InMemoryPublisher` for deterministic demonstration +- One one-shot `OutboxRelay` that processes pending messages +- xUnit v3 tests: atomic write, relay processing, no duplicate dispatch +- Normal CI execution (no Docker, no external services) + +## Architecture + +``` +OrderService + | + |-- Order + |-- OutboxMessage + | + |-- one SaveChangesAsync + | + v + OutboxRelay + | + v + InMemoryPublisher +``` + +## File structure + +``` +distributed-systems/ ++-- transactional-outbox-ef-core/ + |-- TransactionalOutboxMinimal.slnx + |-- README.md + |-- src/ + | +-- TransactionalOutboxMinimal/ + | |-- TransactionalOutboxMinimal.csproj + | |-- Program.cs + | |-- Data/ + | | +-- OrdersDbContext.cs + | |-- Domain/ + | | +-- Order.cs + | |-- Messaging/ + | | |-- OrderPlaced.cs + | | |-- OutboxMessage.cs + | | |-- IPublisher.cs + | | |-- InMemoryPublisher.cs + | | +-- OutboxRelay.cs + | +-- Services/ + | +-- OrderService.cs + +-- tests/ + +-- TransactionalOutboxMinimal.Tests/ + |-- TransactionalOutboxMinimal.Tests.csproj + +-- OutboxFlowTests.cs +``` + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) + +No Docker, PostgreSQL, RabbitMQ, or any external service is required. + +## Run + +```powershell +cd distributed-systems\transactional-outbox-ef-core +dotnet restore +dotnet build --configuration Release --no-restore +dotnet test --configuration Release --no-build +dotnet run --project src\TransactionalOutboxMinimal --configuration Release --no-build +``` + +## Expected output + +``` +Order committed: 1 +Outbox rows committed: 1 +Pending before relay: 1 +Published by relay: 1 +Published message IDs: 1 +Pending after relay: 0 +Processed outbox rows: 1 +Second relay run published 0 messages (expected) +Deleted temp DB: /tmp/transactional-outbox-.... +``` + +## Verify + +The output confirms: + +1. One `Order` and one `OutboxMessage` are written by a single `SaveChangesAsync`. +2. The relay finds exactly one pending row and publishes it. +3. The relay marks the row processed. +4. A second relay run publishes zero rows — processed rows are not republished. +5. The temporary SQLite database is cleaned up. + +## Important boundary + +This sample demonstrates the **normal successful path only**. It intentionally omits: + +- PostgreSQL, Npgsql, and provider-specific locking +- RabbitMQ or any external message broker +- ASP.NET Core API endpoints +- Docker Compose +- Concurrent dispatchers and `FOR UPDATE SKIP LOCKED` +- Inbox tables and duplicate-consumer handling +- `SaveChangesInterceptor` +- Aggregate domain-event infrastructure +- Retries, poison-message quarantine, and dead-letter storage +- OpenTelemetry, trace propagation, and health checks +- Change data capture (CDC) and Debezium +- Testcontainers +- Production deployment + +The full tutorial remains the authoritative source for all production outbox behavior. + +## Delivery semantics + +The transactional outbox provides **eventual at-least-once publication**, not exactly-once delivery. + +A relay may publish a message successfully and crash before marking the row processed. That row will be published again. **Production consumers must be idempotent.** + +Inbox implementation, idempotent consumer guidance, and crash recovery patterns are covered in the complete tutorial. + +## Verification details + +| Item | Value | +| --- | --- | +| Target framework | `net10.0` | +| `Microsoft.EntityFrameworkCore` | 10.0.10 | +| `Microsoft.EntityFrameworkCore.Sqlite` | 10.0.10 | +| `xunit.v3` | 3.2.2 | +| `xunit.runner.visualstudio` | 3.1.5 | +| `Microsoft.NET.Test.Sdk` | 17.14.1 | +| Database | Temporary SQLite file (created per run, deleted on exit) | +| External services required | None | +| Containers required | None | +| API keys required | None | +| Last reviewed | July 28, 2026 | +| Release build | Verified | +| Tests | Verified (3/3 passing) | +| Console run | Verified | + +## License + +Sample code in this folder is available under the [MIT License](../LICENSE). \ No newline at end of file diff --git a/distributed-systems/transactional-outbox-ef-core/TransactionalOutboxMinimal.slnx b/distributed-systems/transactional-outbox-ef-core/TransactionalOutboxMinimal.slnx new file mode 100644 index 0000000..558ad43 --- /dev/null +++ b/distributed-systems/transactional-outbox-ef-core/TransactionalOutboxMinimal.slnx @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Data/OrdersDbContext.cs b/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Data/OrdersDbContext.cs new file mode 100644 index 0000000..8e99002 --- /dev/null +++ b/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Data/OrdersDbContext.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore; +using TransactionalOutboxMinimal.Domain; +using TransactionalOutboxMinimal.Messaging; + +namespace TransactionalOutboxMinimal.Data; + +public sealed class OrdersDbContext( + DbContextOptions options) + : DbContext(options) +{ + public DbSet Orders => Set(); + + public DbSet OutboxMessages => + Set(); + + protected override void OnModelCreating( + ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(order => order.Id); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(message => message.Id); + + entity.Property(message => message.Type) + .HasMaxLength(500) + .IsRequired(); + + entity.Property(message => message.Payload) + .IsRequired(); + + entity.HasIndex(message => new + { + message.ProcessedOnUtc, + message.OccurredOnUtc + }); + }); + } +} \ No newline at end of file diff --git a/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Domain/Order.cs b/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Domain/Order.cs new file mode 100644 index 0000000..fed4a98 --- /dev/null +++ b/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Domain/Order.cs @@ -0,0 +1,28 @@ +namespace TransactionalOutboxMinimal.Domain; + +public sealed class Order +{ + private Order() + { + } + + public Order( + Guid id, + Guid customerId, + decimal total, + DateTimeOffset createdOnUtc) + { + Id = id; + CustomerId = customerId; + Total = total; + CreatedOnUtc = createdOnUtc; + } + + public Guid Id { get; private set; } + + public Guid CustomerId { get; private set; } + + public decimal Total { get; private set; } + + public DateTimeOffset CreatedOnUtc { get; private set; } +} \ No newline at end of file diff --git a/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Messaging/IPublisher.cs b/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Messaging/IPublisher.cs new file mode 100644 index 0000000..2536705 --- /dev/null +++ b/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Messaging/IPublisher.cs @@ -0,0 +1,10 @@ +namespace TransactionalOutboxMinimal.Messaging; + +public interface IPublisher +{ + Task PublishAsync( + Guid messageId, + string type, + string payload, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Messaging/InMemoryPublisher.cs b/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Messaging/InMemoryPublisher.cs new file mode 100644 index 0000000..6c75347 --- /dev/null +++ b/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Messaging/InMemoryPublisher.cs @@ -0,0 +1,19 @@ +namespace TransactionalOutboxMinimal.Messaging; + +public sealed class InMemoryPublisher : IPublisher +{ + private readonly List _publishedMessageIds = []; + + public IReadOnlyCollection PublishedMessageIds => + _publishedMessageIds; + + public Task PublishAsync( + Guid messageId, + string type, + string payload, + CancellationToken cancellationToken = default) + { + _publishedMessageIds.Add(messageId); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Messaging/OrderPlaced.cs b/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Messaging/OrderPlaced.cs new file mode 100644 index 0000000..644488f --- /dev/null +++ b/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Messaging/OrderPlaced.cs @@ -0,0 +1,7 @@ +namespace TransactionalOutboxMinimal.Messaging; + +public sealed record OrderPlaced( + Guid OrderId, + Guid CustomerId, + decimal Total, + DateTimeOffset OccurredOnUtc); \ No newline at end of file diff --git a/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Messaging/OutboxMessage.cs b/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Messaging/OutboxMessage.cs new file mode 100644 index 0000000..e056a05 --- /dev/null +++ b/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Messaging/OutboxMessage.cs @@ -0,0 +1,35 @@ +namespace TransactionalOutboxMinimal.Messaging; + +public sealed class OutboxMessage +{ + private OutboxMessage() + { + } + + public OutboxMessage( + Guid id, + string type, + string payload, + DateTimeOffset occurredOnUtc) + { + Id = id; + Type = type; + Payload = payload; + OccurredOnUtc = occurredOnUtc; + } + + public Guid Id { get; private set; } + + public string Type { get; private set; } = string.Empty; + + public string Payload { get; private set; } = string.Empty; + + public DateTimeOffset OccurredOnUtc { get; private set; } + + public DateTimeOffset? ProcessedOnUtc { get; private set; } + + public void MarkProcessed(DateTimeOffset processedOnUtc) + { + ProcessedOnUtc = processedOnUtc; + } +} \ No newline at end of file diff --git a/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Messaging/OutboxRelay.cs b/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Messaging/OutboxRelay.cs new file mode 100644 index 0000000..f8f1644 --- /dev/null +++ b/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Messaging/OutboxRelay.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore; +using TransactionalOutboxMinimal.Data; + +namespace TransactionalOutboxMinimal.Messaging; + +public sealed class OutboxRelay( + OrdersDbContext dbContext, + IPublisher publisher, + TimeProvider timeProvider) +{ + public async Task DispatchPendingAsync( + CancellationToken cancellationToken = default) + { + List messages = + await dbContext.OutboxMessages + .Where(message => + message.ProcessedOnUtc == null) + .ToListAsync(cancellationToken); + + messages = messages + .OrderBy(message => + message.OccurredOnUtc) + .ToList(); + + foreach (OutboxMessage message in messages) + { + await publisher.PublishAsync( + message.Id, + message.Type, + message.Payload, + cancellationToken); + + message.MarkProcessed( + timeProvider.GetUtcNow()); + } + + await dbContext.SaveChangesAsync( + cancellationToken); + + return messages.Count; + } +} \ No newline at end of file diff --git a/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Program.cs b/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Program.cs new file mode 100644 index 0000000..32a0fde --- /dev/null +++ b/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Program.cs @@ -0,0 +1,76 @@ +using Microsoft.EntityFrameworkCore; +using TransactionalOutboxMinimal.Data; +using TransactionalOutboxMinimal.Domain; +using TransactionalOutboxMinimal.Messaging; +using TransactionalOutboxMinimal.Services; + +string dbPath = Path.Combine( + Path.GetTempPath(), + $"transactional-outbox-{Guid.NewGuid():N}.db"); + +try +{ + DbContextOptionsBuilder optionsBuilder = + new DbContextOptionsBuilder() + .UseSqlite($"Data Source={dbPath}"); + + var dbContext = new OrdersDbContext(optionsBuilder.Options); + await dbContext.Database.EnsureCreatedAsync(); + + var timeProvider = TimeProvider.System; + var orderService = new OrderService(dbContext, timeProvider); + var publisher = new InMemoryPublisher(); + var relay = new OutboxRelay(dbContext, publisher, timeProvider); + + // 1. Atomic write: order + outbox in one SaveChangesAsync + Guid orderId = await orderService.CreateAsync( + customerId: Guid.NewGuid(), + total: 199.99m); + + int orderCount = await dbContext.Orders.CountAsync(); + int outboxCount = await dbContext.OutboxMessages.CountAsync(); + + Console.WriteLine($"Order committed: {orderCount}"); + Console.WriteLine($"Outbox rows committed: {outboxCount}"); + + // 2. Confirm pending before relay + int pendingBefore = await dbContext.OutboxMessages + .CountAsync(m => m.ProcessedOnUtc == null); + + Console.WriteLine($"Pending before relay: {pendingBefore}"); + + // 3. Run relay + int published = await relay.DispatchPendingAsync(); + + Console.WriteLine($"Published by relay: {published}"); + Console.WriteLine($"Published message IDs: {published}"); + + // 4. Confirm after relay + int pendingAfter = await dbContext.OutboxMessages + .CountAsync(m => m.ProcessedOnUtc == null); + + int processedCount = await dbContext.OutboxMessages + .CountAsync(m => m.ProcessedOnUtc != null); + + Console.WriteLine($"Pending after relay: {pendingAfter}"); + Console.WriteLine($"Processed outbox rows: {processedCount}"); + + // 5. Verify second relay run publishes nothing + int secondRun = await relay.DispatchPendingAsync(); + if (secondRun == 0) + { + Console.WriteLine("Second relay run published 0 messages (expected)"); + } + else + { + Console.WriteLine($"WARNING: Second relay run published {secondRun} messages"); + } +} +finally +{ + if (File.Exists(dbPath)) + { + File.Delete(dbPath); + Console.WriteLine($"Deleted temp DB: {dbPath}"); + } +} \ No newline at end of file diff --git a/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Services/OrderService.cs b/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Services/OrderService.cs new file mode 100644 index 0000000..3d17212 --- /dev/null +++ b/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/Services/OrderService.cs @@ -0,0 +1,47 @@ +using System.Text.Json; +using TransactionalOutboxMinimal.Data; +using TransactionalOutboxMinimal.Domain; +using TransactionalOutboxMinimal.Messaging; + +namespace TransactionalOutboxMinimal.Services; + +public sealed class OrderService( + OrdersDbContext dbContext, + TimeProvider timeProvider) +{ + public async Task CreateAsync( + Guid customerId, + decimal total, + CancellationToken cancellationToken = default) + { + DateTimeOffset now = timeProvider.GetUtcNow(); + Guid orderId = Guid.NewGuid(); + + var order = new Order( + orderId, + customerId, + total, + now); + + var integrationEvent = new OrderPlaced( + order.Id, + order.CustomerId, + order.Total, + now); + + var outboxMessage = new OutboxMessage( + Guid.NewGuid(), + typeof(OrderPlaced).FullName + ?? nameof(OrderPlaced), + JsonSerializer.Serialize(integrationEvent), + now); + + dbContext.Orders.Add(order); + dbContext.OutboxMessages.Add(outboxMessage); + + await dbContext.SaveChangesAsync( + cancellationToken); + + return orderId; + } +} \ No newline at end of file diff --git a/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/TransactionalOutboxMinimal.csproj b/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/TransactionalOutboxMinimal.csproj new file mode 100644 index 0000000..22b5935 --- /dev/null +++ b/distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/TransactionalOutboxMinimal.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + \ No newline at end of file diff --git a/distributed-systems/transactional-outbox-ef-core/tests/TransactionalOutboxMinimal.Tests/OutboxFlowTests.cs b/distributed-systems/transactional-outbox-ef-core/tests/TransactionalOutboxMinimal.Tests/OutboxFlowTests.cs new file mode 100644 index 0000000..e9550c3 --- /dev/null +++ b/distributed-systems/transactional-outbox-ef-core/tests/TransactionalOutboxMinimal.Tests/OutboxFlowTests.cs @@ -0,0 +1,137 @@ +using Microsoft.EntityFrameworkCore; +using TransactionalOutboxMinimal.Data; +using TransactionalOutboxMinimal.Domain; +using TransactionalOutboxMinimal.Messaging; +using TransactionalOutboxMinimal.Services; + +namespace TransactionalOutboxMinimal.Tests; + +public sealed class OutboxFlowTests +{ + private static OrdersDbContext CreateContext(string dbPath) + { + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={dbPath}") + .Options; + + var context = new OrdersDbContext(options); + context.Database.EnsureCreated(); + return context; + } + + private sealed class FakeTimeProvider : TimeProvider + { + private readonly DateTimeOffset _fixedTime; + + public FakeTimeProvider(DateTimeOffset fixedTime) + { + _fixedTime = fixedTime; + } + + public override DateTimeOffset GetUtcNow() => _fixedTime; + } + + [Fact] + public async Task Creating_order_writes_order_and_outbox_together() + { + string dbPath = Path.Combine( + Path.GetTempPath(), + $"test-{Guid.NewGuid():N}.db"); + try + { + var dbContext = CreateContext(dbPath); + var timeProvider = new FakeTimeProvider( + new DateTimeOffset(2026, 7, 28, 12, 0, 0, TimeSpan.Zero)); + var orderService = new OrderService(dbContext, timeProvider); + + Guid orderId = await orderService.CreateAsync( + customerId: Guid.NewGuid(), + total: 49.99m); + + int orderCount = await dbContext.Orders.CountAsync(); + int outboxCount = await dbContext.OutboxMessages.CountAsync(); + int pendingCount = await dbContext.OutboxMessages + .CountAsync(m => m.ProcessedOnUtc == null); + + Assert.Equal(1, orderCount); + Assert.Equal(1, outboxCount); + Assert.Equal(1, pendingCount); + } + finally + { + if (File.Exists(dbPath)) File.Delete(dbPath); + } + } + + [Fact] + public async Task Relay_publishes_pending_message_and_marks_it_processed() + { + string dbPath = Path.Combine( + Path.GetTempPath(), + $"test-{Guid.NewGuid():N}.db"); + try + { + var dbContext = CreateContext(dbPath); + var timeProvider = new FakeTimeProvider( + new DateTimeOffset(2026, 7, 28, 12, 0, 0, TimeSpan.Zero)); + var orderService = new OrderService(dbContext, timeProvider); + var publisher = new InMemoryPublisher(); + var relay = new OutboxRelay(dbContext, publisher, timeProvider); + + await orderService.CreateAsync( + customerId: Guid.NewGuid(), + total: 49.99m); + + int published = await relay.DispatchPendingAsync(); + + Assert.Equal(1, published); + Assert.Equal(1, publisher.PublishedMessageIds.Count); + + int processedCount = await dbContext.OutboxMessages + .CountAsync(m => m.ProcessedOnUtc != null); + + Assert.Equal(1, processedCount); + + int pendingCount = await dbContext.OutboxMessages + .CountAsync(m => m.ProcessedOnUtc == null); + + Assert.Equal(0, pendingCount); + } + finally + { + if (File.Exists(dbPath)) File.Delete(dbPath); + } + } + + [Fact] + public async Task Second_relay_run_does_not_republish_processed_message() + { + string dbPath = Path.Combine( + Path.GetTempPath(), + $"test-{Guid.NewGuid():N}.db"); + try + { + var dbContext = CreateContext(dbPath); + var timeProvider = new FakeTimeProvider( + new DateTimeOffset(2026, 7, 28, 12, 0, 0, TimeSpan.Zero)); + var orderService = new OrderService(dbContext, timeProvider); + var publisher = new InMemoryPublisher(); + var relay = new OutboxRelay(dbContext, publisher, timeProvider); + + await orderService.CreateAsync( + customerId: Guid.NewGuid(), + total: 49.99m); + + await relay.DispatchPendingAsync(); + + int secondRun = await relay.DispatchPendingAsync(); + + Assert.Equal(0, secondRun); + Assert.Equal(1, publisher.PublishedMessageIds.Count); + } + finally + { + if (File.Exists(dbPath)) File.Delete(dbPath); + } + } +} \ No newline at end of file diff --git a/distributed-systems/transactional-outbox-ef-core/tests/TransactionalOutboxMinimal.Tests/TransactionalOutboxMinimal.Tests.csproj b/distributed-systems/transactional-outbox-ef-core/tests/TransactionalOutboxMinimal.Tests/TransactionalOutboxMinimal.Tests.csproj new file mode 100644 index 0000000..f23ceda --- /dev/null +++ b/distributed-systems/transactional-outbox-ef-core/tests/TransactionalOutboxMinimal.Tests/TransactionalOutboxMinimal.Tests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + \ No newline at end of file From 2700a340ab98a6bb5de85b1dec9ff06efb10406e Mon Sep 17 00:00:00 2001 From: BALAJI BASHYAM Date: Tue, 28 Jul 2026 15:01:10 +0000 Subject: [PATCH 2/3] Add outbox sample to repository README --- README.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ab708e9..08f108e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ Each sample folder contains a focused implementation of one tutorial topic. The | [`dotnet-ai/hybrid-search-ef-core-pgvector`](dotnet-ai/hybrid-search-ef-core-pgvector/) | Minimal Reciprocal Rank Fusion demo combining pre-ranked keyword and vector results | [Hybrid Search in .NET with EF Core 10 and pgvector](https://www.dotnet-guide.com/tutorials/dotnet-ai/hybrid-search-ef-core-pgvector/) | | [`dotnet-aspire/orchestrate-distributed-system`](dotnet-aspire/orchestrate-distributed-system/) | Minimal Aspire AppHost coordinating a web project and API with service discovery and startup ordering | [Aspire in .NET: Orchestrate, Run, and Deploy a Distributed System from One App Host](https://www.dotnet-guide.com/tutorials/dotnet-aspire/orchestrate-distributed-system/) | | [`software-architecture/architecture-testing-dotnet`](software-architecture/architecture-testing-dotnet/) | Minimal NetArchTest.eNhancedEdition rule that prevents Domain from depending on outer layers | [Architecture Testing in .NET: Enforce Layer and Module Boundaries with NetArchTest and ArchUnitNET](https://www.dotnet-guide.com/tutorials/software-architecture/architecture-testing-dotnet/) | - +| [`distributed-systems/transactional-outbox-ef-core`](distributed-systems/transactional-outbox-ef-core/) | Minimal EF Core and SQLite demonstration that saves business state and an outbox message atomically, then publishes it through a one-shot relay | [Transactional Outbox Pattern in .NET with EF Core (.NET 10): Fix the Dual-Write Problem](https://www.dotnet-guide.com/tutorials/distributed-systems/transactional-outbox-ef-core/) | +| ## Companion articles - [Common Microsoft.Extensions.AI mistakes](https://www.dotnet-guide.com/articles/dotnet-ai/microsoft-extensions-ai-common-mistakes/) @@ -90,6 +91,30 @@ tutorials/ | `-- ArchitectureGuard.ArchitectureTests/ | |-- ArchitectureGuard.ArchitectureTests.csproj | `-- LayerRules.cs +|-- distributed-systems/ +| `-- transactional-outbox-ef-core/ +| |-- TransactionalOutboxMinimal.slnx +| |-- README.md +| |-- src/ +| | `-- TransactionalOutboxMinimal/ +| | |-- TransactionalOutboxMinimal.csproj +| | |-- Program.cs +| | |-- Data/ +| | | `-- OrdersDbContext.cs +| | |-- Domain/ +| | | `-- Order.cs +| | |-- Messaging/ +| | | |-- OrderPlaced.cs +| | | |-- OutboxMessage.cs +| | | |-- IPublisher.cs +| | | |-- InMemoryPublisher.cs +| | | `-- OutboxRelay.cs +| | `-- Services/ +| | `-- OrderService.cs +| `-- tests/ +| `-- TransactionalOutboxMinimal.Tests/ +| |-- TransactionalOutboxMinimal.Tests.csproj +| `-- OutboxFlowTests.cs |-- .github/ | `-- workflows/ | `-- build-samples.yml From 3a38e3a12e50966f05705466a1efd6ef34712178 Mon Sep 17 00:00:00 2001 From: BALAJI BASHYAM Date: Tue, 28 Jul 2026 15:01:16 +0000 Subject: [PATCH 3/3] Test transactional outbox sample in GitHub Actions --- .github/workflows/build-samples.yml | 41 +++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/.github/workflows/build-samples.yml b/.github/workflows/build-samples.yml index 43e3fd8..b687eda 100644 --- a/.github/workflows/build-samples.yml +++ b/.github/workflows/build-samples.yml @@ -8,6 +8,7 @@ on: - "dotnet-ai/hybrid-search-ef-core-pgvector/**" - "dotnet-aspire/orchestrate-distributed-system/**" - "software-architecture/architecture-testing-dotnet/**" + - "distributed-systems/transactional-outbox-ef-core/**" - ".github/workflows/build-samples.yml" pull_request: @@ -16,6 +17,7 @@ on: - "dotnet-ai/hybrid-search-ef-core-pgvector/**" - "dotnet-aspire/orchestrate-distributed-system/**" - "software-architecture/architecture-testing-dotnet/**" + - "distributed-systems/transactional-outbox-ef-core/**" - ".github/workflows/build-samples.yml" workflow_dispatch: @@ -137,3 +139,42 @@ jobs: software-architecture/architecture-testing-dotnet/tests/ArchitectureGuard.ArchitectureTests/ArchitectureGuard.ArchitectureTests.csproj \ --configuration Release \ --no-build + + test-transactional-outbox: + name: Test transactional outbox sample + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Install .NET 10 SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - name: Restore + run: > + dotnet restore + distributed-systems/transactional-outbox-ef-core/TransactionalOutboxMinimal.slnx + + - name: Build + run: > + dotnet build + distributed-systems/transactional-outbox-ef-core/TransactionalOutboxMinimal.slnx + --configuration Release + --no-restore + + - name: Test + run: > + dotnet test + distributed-systems/transactional-outbox-ef-core/tests/TransactionalOutboxMinimal.Tests/TransactionalOutboxMinimal.Tests.csproj + --configuration Release + --no-build + + - name: Run demonstration + run: > + dotnet run + --project distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/TransactionalOutboxMinimal.csproj + --configuration Release + --no-build