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
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageReference Include="AwesomeAssertions" Version="7.2.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Examples.EventHandling.Outbox.Modular\Examples.EventHandling.Outbox.Modular.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using Examples.EventHandling.Outbox.Modular.Billing;
using Examples.EventHandling.Outbox.Modular.Ordering;
using Examples.EventHandling.Outbox.Modular.Shipping;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using RCommon;
using RCommon.Entities;
using RCommon.Persistence.Crud;
using RCommon.Persistence.Outbox;
using RCommon.Persistence.Transactions;
using Xunit;

namespace Examples.EventHandling.Outbox.Modular.Tests;

/// <summary>
/// End-to-end proof of the modular multi-datastore outbox example, and the regression guard for the
/// customer's scenario (3 bounded-context modules, each its own datastore + native outbox, composed at
/// one root). Every datastore's durable event must persist to its OWN outbox and nowhere else. Before
/// 3.2.1 this composition silently dropped durable events for datastores whose module was registered
/// after a different module's WithPersistence call. Runs on the EF Core InMemory provider (fast lane).
/// </summary>
public class ModularMultiDataStoreOutboxTests
{
private static ServiceProvider BuildProvider()
{
var services = new ServiceCollection();
services.AddLogging();

services.AddRCommon()
.WithSimpleGuidGenerator()
.WithUnitOfWork<DefaultUnitOfWorkBuilder>(uow => { })
// Compose modules NOT-primary-first, to prove the fixed registration is order-independent.
.AddBillingModule(Guid.NewGuid().ToString())
.AddShippingModule(Guid.NewGuid().ToString())
.AddOrderingModule(Guid.NewGuid().ToString());

return services.BuildServiceProvider(validateScopes: true);
}

[Fact]
public void OutboxTracker_IsAuthoritative_UnderThreeModuleComposition()
{
using var provider = BuildProvider();
using var scope = provider.CreateScope();

scope.ServiceProvider.GetRequiredService<IEntityEventTracker>()
.Should().BeOfType<OutboxEntityEventTracker>(
"the outbox tracker must win across a three-module composition regardless of order");
}

[Fact]
public async Task EachModule_PersistsItsDurableEvent_ToItsOwnOutboxOnly()
{
using var provider = BuildProvider();

using (var scope = provider.CreateScope())
{
await scope.ServiceProvider.GetRequiredService<OrderingDbContext>().Database.EnsureCreatedAsync();
await scope.ServiceProvider.GetRequiredService<BillingDbContext>().Database.EnsureCreatedAsync();
await scope.ServiceProvider.GetRequiredService<ShippingDbContext>().Database.EnsureCreatedAsync();
}

using (var scope = provider.CreateScope())
{
var sp = scope.ServiceProvider;
var uowFactory = sp.GetRequiredService<IUnitOfWorkFactory>();

var orders = sp.GetRequiredService<IAggregateRepository<Order, Guid>>();
orders.DataStoreName = "Ordering";
var invoices = sp.GetRequiredService<IAggregateRepository<Invoice, Guid>>();
invoices.DataStoreName = "Billing";
var shipments = sp.GetRequiredService<IAggregateRepository<Shipment, Guid>>();
shipments.DataStoreName = "Shipping";

var order = new Order { CustomerName = "Ada Lovelace", Total = 249.99m };
order.Place();
var invoice = new Invoice { CustomerName = "Ada Lovelace", Amount = 249.99m };
invoice.Raise();
var shipment = new Shipment { Destination = "London", TrackingNumber = "TRK-1" };
shipment.Dispatch();

using var uow = uowFactory.Create();
await orders.AddAsync(order);
await invoices.AddAsync(invoice);
await shipments.AddAsync(shipment);
await uow.CommitAsync();
}

using (var scope = provider.CreateScope())
{
var sp = scope.ServiceProvider;
var orderingRows = await sp.GetRequiredService<OrderingDbContext>().Set<OutboxMessage>().AsNoTracking().CountAsync();
var billingRows = await sp.GetRequiredService<BillingDbContext>().Set<OutboxMessage>().AsNoTracking().CountAsync();
var shippingRows = await sp.GetRequiredService<ShippingDbContext>().Set<OutboxMessage>().AsNoTracking().CountAsync();

orderingRows.Should().Be(1, "the Ordering module's durable event must persist to the Ordering outbox");
billingRows.Should().Be(1, "the Billing module's durable event must persist to the Billing outbox");
shippingRows.Should().Be(1, "the Shipping module's durable event must persist to the Shipping outbox");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using Microsoft.EntityFrameworkCore;
using RCommon;
using RCommon.Entities;
using RCommon.EventHandling;
using RCommon.EventHandling.Subscribers;
using RCommon.Persistence.EFCore;
using RCommon.Persistence.EFCore.Outbox;
using RCommon.Persistence.Outbox;

namespace Examples.EventHandling.Outbox.Modular.Billing;

// ---------------------------------------------------------------------------------------------------
// Billing bounded context — its own datastore ("Billing") with its own transactional outbox.
// ---------------------------------------------------------------------------------------------------

public sealed class InvoiceRaisedEvent : IDomainEvent
{
public InvoiceRaisedEvent(Guid invoiceId, decimal amount)
{
InvoiceId = invoiceId;
Amount = amount;
}

public Guid EventId { get; } = Guid.NewGuid();
public DateTimeOffset OccurredOn { get; } = DateTimeOffset.UtcNow;
public Guid InvoiceId { get; }
public decimal Amount { get; }
}

public sealed class Invoice : AggregateRoot<Guid>
{
public Invoice() : base(Guid.NewGuid()) { }
public string CustomerName { get; set; } = string.Empty;
public decimal Amount { get; set; }

public void Raise() => AddDomainEvent(new InvoiceRaisedEvent(Id, Amount));
}

public sealed class InvoiceRaisedEventHandler : ISubscriber<InvoiceRaisedEvent>
{
public Task HandleAsync(InvoiceRaisedEvent @event, CancellationToken cancellationToken = default)
{
Console.WriteLine($" [billing] invoice {@event.InvoiceId} raised for {@event.Amount:C}");
return Task.CompletedTask;
}
}

public sealed class BillingDbContext : RCommonDbContext
{
public BillingDbContext(DbContextOptions<BillingDbContext> options) : base(options) { }
public DbSet<Invoice> Invoices => Set<Invoice>();

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.AddOutboxMessages();
}
}

public static class BillingModule
{
/// <summary>
/// Registers the Billing bounded context: its DbContext + datastore, its transactional outbox, and
/// its event route. Note there is no <c>SetDefaultDataStore</c> here — a non-primary module does not
/// touch the default; it just contributes its own datastore.
/// </summary>
public static IRCommonBuilder AddBillingModule(this IRCommonBuilder rcommon, string database)
{
return rcommon
.WithPersistence<EFCorePersistenceBuilder>(ef =>
{
ef.AddDbContext<BillingDbContext>("Billing", o => o.UseInMemoryDatabase(database));
ef.AddOutbox<EFCoreOutboxStore>(dataStoreName: "Billing");
})
.WithEventHandling<InMemoryEventBusBuilder>(events =>
{
events.AddSubscriber<InvoiceRaisedEvent, InvoiceRaisedEventHandler>();
events.Publish<InvoiceRaisedEvent>().UseOutbox("Billing");
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.8" />
<!-- InMemory rather than SQLite: RCommon.Persistence's UnitOfWork wraps a System.Transactions
TransactionScope, which Microsoft.Data.Sqlite's ADO provider cannot enlist in. -->
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\Src\RCommon.EfCore\RCommon.EFCore.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using Microsoft.EntityFrameworkCore;
using RCommon;
using RCommon.Entities;
using RCommon.EventHandling;
using RCommon.EventHandling.Subscribers;
using RCommon.Persistence.EFCore;
using RCommon.Persistence.EFCore.Outbox;
using RCommon.Persistence.Outbox;

namespace Examples.EventHandling.Outbox.Modular.Ordering;

// ---------------------------------------------------------------------------------------------------
// Ordering bounded context — its own datastore ("Ordering") with its own transactional outbox.
// Everything this context needs is self-contained in this file, including its RCommon registration.
// ---------------------------------------------------------------------------------------------------

public sealed class OrderPlacedEvent : IDomainEvent
{
public OrderPlacedEvent(Guid orderId, decimal total)
{
OrderId = orderId;
Total = total;
}

public Guid EventId { get; } = Guid.NewGuid();
public DateTimeOffset OccurredOn { get; } = DateTimeOffset.UtcNow;
public Guid OrderId { get; }
public decimal Total { get; }
}

public sealed class Order : AggregateRoot<Guid>
{
public Order() : base(Guid.NewGuid()) { }
public string CustomerName { get; set; } = string.Empty;
public decimal Total { get; set; }

public void Place() => AddDomainEvent(new OrderPlacedEvent(Id, Total));
}

public sealed class OrderPlacedEventHandler : ISubscriber<OrderPlacedEvent>
{
public Task HandleAsync(OrderPlacedEvent @event, CancellationToken cancellationToken = default)
{
Console.WriteLine($" [ordering] order {@event.OrderId} placed for {@event.Total:C}");
return Task.CompletedTask;
}
}

public sealed class OrderingDbContext : RCommonDbContext
{
public OrderingDbContext(DbContextOptions<OrderingDbContext> options) : base(options) { }
public DbSet<Order> Orders => Set<Order>();

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.AddOutboxMessages(); // maps this datastore's __OutboxMessages table
}
}

public static class OrderingModule
{
/// <summary>
/// Registers the Ordering bounded context: its DbContext + datastore, its transactional outbox, and
/// its event route. Each module owns exactly one datastore and calls <c>WithPersistence</c> once —
/// the composition root simply chains the modules together (see <c>Program</c>).
/// </summary>
public static IRCommonBuilder AddOrderingModule(this IRCommonBuilder rcommon, string database)
{
return rcommon
.WithPersistence<EFCorePersistenceBuilder>(ef =>
{
ef.AddDbContext<OrderingDbContext>("Ordering", o => o.UseInMemoryDatabase(database));

// Ordering is the application's primary context, so it designates the default datastore.
// With more than one datastore registered, exactly one module (or the root) must set this;
// it is not inferred. The outbox poller uses it as its fallback datastore name.
ef.SetDefaultDataStore(ds => ds.DefaultDataStoreName = "Ordering");

// Native RCommon outbox for this datastore (producer + processor).
ef.AddOutbox<EFCoreOutboxStore>(dataStoreName: "Ordering");
})
.WithEventHandling<InMemoryEventBusBuilder>(events =>
{
events.AddSubscriber<OrderPlacedEvent, OrderPlacedEventHandler>();
// Durability is opt-in per route: publish this context's event durably to its own outbox.
events.Publish<OrderPlacedEvent>().UseOutbox("Ordering");
});
}
}
Loading
Loading