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
13 changes: 13 additions & 0 deletions .config/dotnet-tools.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.8",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}
5 changes: 5 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 5 additions & 3 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
</ItemGroup>

<!-- EF Core pinned to 10.0.4 to match the version Npgsql.EntityFrameworkCore.PostgreSQL
10.0.2 was built against — fully unified graph, no MSB3277 version-skew warning. -->
<ItemGroup Label="Persistence (EF Core + PostgreSQL)">
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.4" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.4" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
</ItemGroup>
Expand Down
55 changes: 55 additions & 0 deletions src/Services/Orders/Domain/Order.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
namespace Services.Orders.Domain;

public enum OrderStatus
{
Pending,
Confirmed,
Cancelled,
}

/// <summary>
/// Order aggregate. Created in <see cref="OrderStatus.Pending"/>; later moved to
/// Confirmed/Cancelled by the saga (phases 4-5). Business rules live here, not in handlers.
/// </summary>
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;
}
32 changes: 32 additions & 0 deletions src/Services/Orders/Features/PlaceOrder.cs
Original file line number Diff line number Diff line change
@@ -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);

/// <summary>
/// Creates a Pending order and enqueues <see cref="OrderPlaced"/> 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).
/// </summary>
public sealed class PlaceOrderHandler(OrdersDbContext db, IOutbox outbox, TimeProvider clock)
{
public async Task<PlaceOrderResponse> 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);
}
}
21 changes: 21 additions & 0 deletions src/Services/Orders/Infrastructure/OrderConfiguration.cs
Original file line number Diff line number Diff line change
@@ -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<Order>
{
public void Configure(EntityTypeBuilder<Order> 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<string>().HasMaxLength(20);
builder.Property(x => x.CreatedAt).HasColumnName("created_at");
}
}
24 changes: 24 additions & 0 deletions src/Services/Orders/Infrastructure/OrdersDbContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using BuildingBlocks.Persistence;
using Microsoft.EntityFrameworkCore;
using Services.Orders.Domain;

namespace Services.Orders.Infrastructure;

/// <summary>
/// The Orders service context. Derives from <see cref="MessagingDbContext"/> so the
/// <c>orders</c> table and the <c>outbox</c>/<c>inbox</c> tables share one transaction —
/// the order and its OrderPlaced event commit atomically (ADR-001). Everything lives in
/// the <c>orders</c> schema (ADR-005).
/// </summary>
public sealed class OrdersDbContext(DbContextOptions<OrdersDbContext> options) : MessagingDbContext(options)
{
public DbSet<Order> Orders => Set<Order>();

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("orders");
modelBuilder.ApplyConfiguration(new OrderConfiguration());

base.OnModelCreating(modelBuilder);
}
}
20 changes: 20 additions & 0 deletions src/Services/Orders/Infrastructure/OrdersDbContextFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;

namespace Services.Orders.Infrastructure;

/// <summary>
/// Design-time factory so <c>dotnet ef migrations</c> can build the context without
/// spinning the web host. The connection string here is never used at runtime.
/// </summary>
public sealed class OrdersDbContextFactory : IDesignTimeDbContextFactory<OrdersDbContext>
{
public OrdersDbContext CreateDbContext(string[] args)
{
var options = new DbContextOptionsBuilder<OrdersDbContext>()
.UseNpgsql("Host=localhost;Database=dcl;Username=dcl;Password=dcl;SearchPath=orders")
.Options;

return new OrdersDbContext(options);
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading