Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/build-samples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand Down Expand Up @@ -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
Expand Down
154 changes: 154 additions & 0 deletions distributed-systems/transactional-outbox-ef-core/README.md
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<Solution>
<Project Path="src/TransactionalOutboxMinimal/TransactionalOutboxMinimal.csproj" />
<Project Path="tests/TransactionalOutboxMinimal.Tests/TransactionalOutboxMinimal.Tests.csproj" />
</Solution>
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore;
using TransactionalOutboxMinimal.Domain;
using TransactionalOutboxMinimal.Messaging;

namespace TransactionalOutboxMinimal.Data;

public sealed class OrdersDbContext(
DbContextOptions<OrdersDbContext> options)
: DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();

public DbSet<OutboxMessage> OutboxMessages =>
Set<OutboxMessage>();

protected override void OnModelCreating(
ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>(entity =>
{
entity.HasKey(order => order.Id);
});

modelBuilder.Entity<OutboxMessage>(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
});
});
}
}
Original file line number Diff line number Diff line change
@@ -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; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace TransactionalOutboxMinimal.Messaging;

public interface IPublisher
{
Task PublishAsync(
Guid messageId,
string type,
string payload,
CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace TransactionalOutboxMinimal.Messaging;

public sealed class InMemoryPublisher : IPublisher
{
private readonly List<Guid> _publishedMessageIds = [];

public IReadOnlyCollection<Guid> PublishedMessageIds =>
_publishedMessageIds;

public Task PublishAsync(
Guid messageId,
string type,
string payload,
CancellationToken cancellationToken = default)
{
_publishedMessageIds.Add(messageId);
return Task.CompletedTask;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace TransactionalOutboxMinimal.Messaging;

public sealed record OrderPlaced(
Guid OrderId,
Guid CustomerId,
decimal Total,
DateTimeOffset OccurredOnUtc);
Loading