diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
new file mode 100644
index 0000000..7dcefc3
--- /dev/null
+++ b/.config/dotnet-tools.json
@@ -0,0 +1,13 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "dotnet-ef": {
+ "version": "10.0.8",
+ "commands": [
+ "dotnet-ef"
+ ],
+ "rollForward": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/.editorconfig b/.editorconfig
index bceadf0..4b91992 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -29,3 +29,8 @@ indent_size = 2
[*.md]
trim_trailing_whitespace = false
+
+# EF Core migrations are generated code — don't hold them to our style rules.
+[**/Migrations/*.cs]
+generated_code = true
+dotnet_diagnostic.IDE0161.severity = none
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 0ca608e..2a6ff7d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -11,10 +11,12 @@
+
-
-
-
+
+
+
diff --git a/src/BuildingBlocks/Persistence/BuildingBlocks.Persistence.csproj b/src/BuildingBlocks/Persistence/BuildingBlocks.Persistence.csproj
index fe06f3e..b6decf4 100644
--- a/src/BuildingBlocks/Persistence/BuildingBlocks.Persistence.csproj
+++ b/src/BuildingBlocks/Persistence/BuildingBlocks.Persistence.csproj
@@ -5,6 +5,7 @@
+
diff --git a/src/Services/Orders/Domain/Order.cs b/src/Services/Orders/Domain/Order.cs
new file mode 100644
index 0000000..7361fda
--- /dev/null
+++ b/src/Services/Orders/Domain/Order.cs
@@ -0,0 +1,55 @@
+namespace Services.Orders.Domain;
+
+public enum OrderStatus
+{
+ Pending,
+ Confirmed,
+ Cancelled,
+}
+
+///
+/// Order aggregate. Created in ; later moved to
+/// Confirmed/Cancelled by the saga (phases 4-5). Business rules live here, not in handlers.
+///
+public sealed class Order
+{
+ public Guid Id { get; private set; }
+
+ public string Sku { get; private set; } = null!;
+
+ public int Quantity { get; private set; }
+
+ public decimal Amount { get; private set; }
+
+ public OrderStatus Status { get; private set; }
+
+ public DateTimeOffset CreatedAt { get; private set; }
+
+ private Order()
+ {
+ // EF Core materialization.
+ }
+
+ private Order(Guid id, string sku, int quantity, decimal amount, DateTimeOffset createdAt)
+ {
+ Id = id;
+ Sku = sku;
+ Quantity = quantity;
+ Amount = amount;
+ Status = OrderStatus.Pending;
+ CreatedAt = createdAt;
+ }
+
+ public static Order Place(string sku, int quantity, decimal amount, DateTimeOffset createdAt)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(sku);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
+ ArgumentOutOfRangeException.ThrowIfNegative(amount);
+
+ return new Order(Guid.CreateVersion7(), sku, quantity, amount, createdAt);
+ }
+
+ public void Confirm() => Status = OrderStatus.Confirmed;
+
+ public void Cancel() => Status = OrderStatus.Cancelled;
+}
diff --git a/src/Services/Orders/Features/PlaceOrder.cs b/src/Services/Orders/Features/PlaceOrder.cs
new file mode 100644
index 0000000..489d54e
--- /dev/null
+++ b/src/Services/Orders/Features/PlaceOrder.cs
@@ -0,0 +1,32 @@
+using BuildingBlocks.Messaging;
+using Contracts;
+using Services.Orders.Domain;
+using Services.Orders.Infrastructure;
+
+namespace Services.Orders.Features;
+
+public sealed record PlaceOrderRequest(string Sku, int Quantity, decimal Amount);
+
+public sealed record PlaceOrderResponse(Guid OrderId);
+
+///
+/// Creates a Pending order and enqueues into the outbox in the
+/// SAME unit of work, then commits once. This is the first real use of the phase-1 outbox:
+/// state change and integration event are atomic — no dual-write (ADR-001).
+///
+public sealed class PlaceOrderHandler(OrdersDbContext db, IOutbox outbox, TimeProvider clock)
+{
+ public async Task HandleAsync(PlaceOrderRequest request, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var order = Order.Place(request.Sku, request.Quantity, request.Amount, clock.GetUtcNow());
+
+ db.Orders.Add(order);
+ outbox.Add(new OrderPlaced(order.Id, order.Sku, order.Quantity, order.Amount));
+
+ await db.SaveChangesAsync(ct);
+
+ return new PlaceOrderResponse(order.Id);
+ }
+}
diff --git a/src/Services/Orders/Infrastructure/OrderConfiguration.cs b/src/Services/Orders/Infrastructure/OrderConfiguration.cs
new file mode 100644
index 0000000..27c3db5
--- /dev/null
+++ b/src/Services/Orders/Infrastructure/OrderConfiguration.cs
@@ -0,0 +1,21 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using Services.Orders.Domain;
+
+namespace Services.Orders.Infrastructure;
+
+internal sealed class OrderConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("orders");
+
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.Id).HasColumnName("id");
+ builder.Property(x => x.Sku).HasColumnName("sku").HasMaxLength(100).IsRequired();
+ builder.Property(x => x.Quantity).HasColumnName("quantity");
+ builder.Property(x => x.Amount).HasColumnName("amount").HasPrecision(18, 2);
+ builder.Property(x => x.Status).HasColumnName("status").HasConversion().HasMaxLength(20);
+ builder.Property(x => x.CreatedAt).HasColumnName("created_at");
+ }
+}
diff --git a/src/Services/Orders/Infrastructure/OrdersDbContext.cs b/src/Services/Orders/Infrastructure/OrdersDbContext.cs
new file mode 100644
index 0000000..da90405
--- /dev/null
+++ b/src/Services/Orders/Infrastructure/OrdersDbContext.cs
@@ -0,0 +1,24 @@
+using BuildingBlocks.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Services.Orders.Domain;
+
+namespace Services.Orders.Infrastructure;
+
+///
+/// The Orders service context. Derives from so the
+/// orders table and the outbox/inbox tables share one transaction —
+/// the order and its OrderPlaced event commit atomically (ADR-001). Everything lives in
+/// the orders schema (ADR-005).
+///
+public sealed class OrdersDbContext(DbContextOptions options) : MessagingDbContext(options)
+{
+ public DbSet Orders => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.HasDefaultSchema("orders");
+ modelBuilder.ApplyConfiguration(new OrderConfiguration());
+
+ base.OnModelCreating(modelBuilder);
+ }
+}
diff --git a/src/Services/Orders/Infrastructure/OrdersDbContextFactory.cs b/src/Services/Orders/Infrastructure/OrdersDbContextFactory.cs
new file mode 100644
index 0000000..4ac7ccd
--- /dev/null
+++ b/src/Services/Orders/Infrastructure/OrdersDbContextFactory.cs
@@ -0,0 +1,20 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace Services.Orders.Infrastructure;
+
+///
+/// Design-time factory so dotnet ef migrations can build the context without
+/// spinning the web host. The connection string here is never used at runtime.
+///
+public sealed class OrdersDbContextFactory : IDesignTimeDbContextFactory
+{
+ public OrdersDbContext CreateDbContext(string[] args)
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseNpgsql("Host=localhost;Database=dcl;Username=dcl;Password=dcl;SearchPath=orders")
+ .Options;
+
+ return new OrdersDbContext(options);
+ }
+}
diff --git a/src/Services/Orders/Migrations/20260608160223_InitialCreate.Designer.cs b/src/Services/Orders/Migrations/20260608160223_InitialCreate.Designer.cs
new file mode 100644
index 0000000..fac5153
--- /dev/null
+++ b/src/Services/Orders/Migrations/20260608160223_InitialCreate.Designer.cs
@@ -0,0 +1,123 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Services.Orders.Infrastructure;
+
+#nullable disable
+
+namespace Services.Orders.Migrations
+{
+ [DbContext(typeof(OrdersDbContext))]
+ [Migration("20260608160223_InitialCreate")]
+ partial class InitialCreate
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("orders")
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("BuildingBlocks.Persistence.InboxMessage", b =>
+ {
+ b.Property("MessageId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("message_id");
+
+ b.Property("ProcessedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("processed_at");
+
+ b.HasKey("MessageId");
+
+ b.ToTable("inbox", "orders");
+ });
+
+ modelBuilder.Entity("BuildingBlocks.Persistence.OutboxMessage", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Attempts")
+ .HasColumnType("integer")
+ .HasColumnName("attempts");
+
+ b.Property("OccurredAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("occurred_at");
+
+ b.Property("Payload")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("payload");
+
+ b.Property("ProcessedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("processed_at");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("type");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OccurredAt")
+ .HasDatabaseName("ix_outbox_pending")
+ .HasFilter("processed_at IS NULL");
+
+ b.ToTable("outbox", "orders");
+ });
+
+ modelBuilder.Entity("Services.Orders.Domain.Order", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Amount")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("amount");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Quantity")
+ .HasColumnType("integer")
+ .HasColumnName("quantity");
+
+ b.Property("Sku")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("sku");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("status");
+
+ b.HasKey("Id");
+
+ b.ToTable("orders", "orders");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Services/Orders/Migrations/20260608160223_InitialCreate.cs b/src/Services/Orders/Migrations/20260608160223_InitialCreate.cs
new file mode 100644
index 0000000..a195c17
--- /dev/null
+++ b/src/Services/Orders/Migrations/20260608160223_InitialCreate.cs
@@ -0,0 +1,88 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Services.Orders.Migrations
+{
+ ///
+ public partial class InitialCreate : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.EnsureSchema(
+ name: "orders");
+
+ migrationBuilder.CreateTable(
+ name: "inbox",
+ schema: "orders",
+ columns: table => new
+ {
+ message_id = table.Column(type: "uuid", nullable: false),
+ processed_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_inbox", x => x.message_id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "orders",
+ schema: "orders",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ sku = table.Column(type: "character varying(100)", maxLength: 100, nullable: false),
+ quantity = table.Column(type: "integer", nullable: false),
+ amount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
+ status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_orders", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "outbox",
+ schema: "orders",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ type = table.Column(type: "character varying(200)", maxLength: 200, nullable: false),
+ payload = table.Column(type: "jsonb", nullable: false),
+ occurred_at = table.Column(type: "timestamp with time zone", nullable: false),
+ processed_at = table.Column(type: "timestamp with time zone", nullable: true),
+ attempts = table.Column(type: "integer", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_outbox", x => x.id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_outbox_pending",
+ schema: "orders",
+ table: "outbox",
+ column: "occurred_at",
+ filter: "processed_at IS NULL");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "inbox",
+ schema: "orders");
+
+ migrationBuilder.DropTable(
+ name: "orders",
+ schema: "orders");
+
+ migrationBuilder.DropTable(
+ name: "outbox",
+ schema: "orders");
+ }
+ }
+}
diff --git a/src/Services/Orders/Migrations/OrdersDbContextModelSnapshot.cs b/src/Services/Orders/Migrations/OrdersDbContextModelSnapshot.cs
new file mode 100644
index 0000000..9ac53cc
--- /dev/null
+++ b/src/Services/Orders/Migrations/OrdersDbContextModelSnapshot.cs
@@ -0,0 +1,120 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Services.Orders.Infrastructure;
+
+#nullable disable
+
+namespace Services.Orders.Migrations
+{
+ [DbContext(typeof(OrdersDbContext))]
+ partial class OrdersDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("orders")
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("BuildingBlocks.Persistence.InboxMessage", b =>
+ {
+ b.Property("MessageId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("message_id");
+
+ b.Property("ProcessedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("processed_at");
+
+ b.HasKey("MessageId");
+
+ b.ToTable("inbox", "orders");
+ });
+
+ modelBuilder.Entity("BuildingBlocks.Persistence.OutboxMessage", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Attempts")
+ .HasColumnType("integer")
+ .HasColumnName("attempts");
+
+ b.Property("OccurredAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("occurred_at");
+
+ b.Property("Payload")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("payload");
+
+ b.Property("ProcessedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("processed_at");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("type");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OccurredAt")
+ .HasDatabaseName("ix_outbox_pending")
+ .HasFilter("processed_at IS NULL");
+
+ b.ToTable("outbox", "orders");
+ });
+
+ modelBuilder.Entity("Services.Orders.Domain.Order", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Amount")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("amount");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Quantity")
+ .HasColumnType("integer")
+ .HasColumnName("quantity");
+
+ b.Property("Sku")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("sku");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("status");
+
+ b.HasKey("Id");
+
+ b.ToTable("orders", "orders");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Services/Orders/Program.cs b/src/Services/Orders/Program.cs
index 1b2cd1e..90b5d39 100644
--- a/src/Services/Orders/Program.cs
+++ b/src/Services/Orders/Program.cs
@@ -1,13 +1,42 @@
+using BuildingBlocks.Messaging;
+using BuildingBlocks.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Services.Orders.Features;
+using Services.Orders.Infrastructure;
+
var builder = WebApplication.CreateBuilder(args);
+var connectionString = builder.Configuration.GetConnectionString("Postgres")
+ ?? "Host=localhost;Database=dcl;Username=dcl;Password=dcl;SearchPath=orders";
+
+// One scoped context instance serves both the domain (OrdersDbContext) and the outbox
+// (MessagingDbContext) so they share a unit of work.
+builder.Services.AddDbContext(o => o.UseNpgsql(connectionString));
+builder.Services.AddScoped(sp => sp.GetRequiredService());
+
+builder.Services.AddOutboxInbox();
+builder.Services.AddRabbitMqPublisher(builder.Configuration.GetSection("RabbitMq").Bind);
+builder.Services.AddOutboxDispatcher();
+
+builder.Services.AddSingleton(TimeProvider.System);
+builder.Services.AddScoped();
+
var app = builder.Build();
+// Apply migrations on startup (lab convenience; a real deploy would run them separately).
+using (var scope = app.Services.CreateScope())
+{
+ var db = scope.ServiceProvider.GetRequiredService();
+ await db.Database.MigrateAsync();
+}
+
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
-// Entry point of the checkout saga. Implementation (persist Order + write OrderPlaced
-// to the outbox in one transaction) lands with dotnet-dev — see spec §10, phase 2.
-app.MapPost("/orders", () =>
- Results.StatusCode(StatusCodes.Status501NotImplemented));
+app.MapPost("/orders", async (PlaceOrderRequest request, PlaceOrderHandler handler, CancellationToken ct) =>
+{
+ var response = await handler.HandleAsync(request, ct);
+ return Results.Created($"/orders/{response.OrderId}", response);
+});
app.Run();
diff --git a/src/Services/Orders/Services.Orders.csproj b/src/Services/Orders/Services.Orders.csproj
index 724fae3..2348bc6 100644
--- a/src/Services/Orders/Services.Orders.csproj
+++ b/src/Services/Orders/Services.Orders.csproj
@@ -6,6 +6,13 @@
+
+
+
+ all
+
+
+
net10.0
enable
diff --git a/src/Services/Orders/appsettings.json b/src/Services/Orders/appsettings.json
index 10f68b8..503210d 100644
--- a/src/Services/Orders/appsettings.json
+++ b/src/Services/Orders/appsettings.json
@@ -5,5 +5,15 @@
"Microsoft.AspNetCore": "Warning"
}
},
- "AllowedHosts": "*"
+ "AllowedHosts": "*",
+ "ConnectionStrings": {
+ "Postgres": "Host=localhost;Database=dcl;Username=dcl;Password=dcl;SearchPath=orders"
+ },
+ "RabbitMq": {
+ "Host": "localhost",
+ "Port": 5672,
+ "Username": "guest",
+ "Password": "guest",
+ "Exchange": "dcl.events"
+ }
}
diff --git a/tests/Integration/PlaceOrderTests.cs b/tests/Integration/PlaceOrderTests.cs
new file mode 100644
index 0000000..be20a45
--- /dev/null
+++ b/tests/Integration/PlaceOrderTests.cs
@@ -0,0 +1,70 @@
+using BuildingBlocks.Messaging;
+using BuildingBlocks.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Services.Orders.Domain;
+using Services.Orders.Features;
+using Services.Orders.Infrastructure;
+using Shouldly;
+using Testcontainers.PostgreSql;
+
+namespace Tests.Integration;
+
+///
+/// Proves phase 2 (spec §10.2): placing an order persists the Order AND writes the
+/// OrderPlaced event to the outbox in a single transaction — no dual-write.
+///
+public sealed class PlaceOrderTests : IAsyncLifetime
+{
+ private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:17-alpine").Build();
+
+ public async Task InitializeAsync()
+ {
+ await _postgres.StartAsync();
+
+ await using var db = CreateDbContext();
+ await db.Database.MigrateAsync();
+ }
+
+ public Task DisposeAsync() => _postgres.DisposeAsync().AsTask();
+
+ [Fact]
+ public async Task Placing_an_order_persists_it_and_writes_OrderPlaced_to_the_outbox_atomically()
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(1));
+ var ct = cts.Token;
+
+ // Act: place an order through the real handler (EF outbox + single SaveChanges).
+ Guid orderId;
+ await using (var db = CreateDbContext())
+ {
+ var handler = new PlaceOrderHandler(db, new EfOutbox(db, new JsonEventSerializer()), TimeProvider.System);
+ var response = await handler.HandleAsync(new PlaceOrderRequest("SKU-1", 2, 49.90m), ct);
+ orderId = response.OrderId;
+ }
+
+ orderId.ShouldNotBe(Guid.Empty);
+
+ // Assert against a fresh context: the order is Pending...
+ await using (var db = CreateDbContext())
+ {
+ var order = await db.Orders.SingleAsync(ct);
+ order.Id.ShouldBe(orderId);
+ order.Status.ShouldBe(OrderStatus.Pending);
+ order.Sku.ShouldBe("SKU-1");
+
+ // ...and exactly one pending outbox row of type OrderPlaced referencing the order.
+ var outbox = await db.Outbox.SingleAsync(ct);
+ outbox.Type.ShouldBe(nameof(Contracts.OrderPlaced));
+ outbox.ProcessedAt.ShouldBeNull();
+ outbox.Payload.ShouldContain(orderId.ToString());
+ }
+ }
+
+ private OrdersDbContext CreateDbContext()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseNpgsql(_postgres.GetConnectionString())
+ .Options;
+ return new OrdersDbContext(options);
+ }
+}
diff --git a/tests/Integration/Tests.Integration.csproj b/tests/Integration/Tests.Integration.csproj
index efcd553..2a4b7cd 100644
--- a/tests/Integration/Tests.Integration.csproj
+++ b/tests/Integration/Tests.Integration.csproj
@@ -15,6 +15,7 @@
+