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
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
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