The Project

Turn the requirements for an orders API into a resource model, an idempotent POST contract, an event plan and a solution layout.

SyntaxPOST /api/orders Idempotency-Key: 6f1c2a90-3b7e-4d1a-9c55-2e8f0b7d4a13 201 first time; 201 with the same order on a retry; 422 when the key is reused with a different body

The project is the service most shops end up owning: an orders API. Customers place orders for products, staff see every order, payment and shipping move an order through its states, and other services react to what happened. Every later lesson in this stage adds one production concern to the same code base, so the decisions made here are the ones the rest of the stage relies on.

Requirements

  • Resources. customers, products and orders with lines. An order moves Pending to Paid to Shipped, or to Cancelled from Pending or Paid; no other transition is legal, and the rule lives on the Order type, not in handlers.
  • Auth. Access tokens come from an external identity provider. The sub claim identifies the customer; scopes orders.read and orders.write gate the endpoints through policies named orders:read and orders:write; a staff role sees all orders. A customer asking for someone else's order gets 404, not 403, so ids cannot be probed.
  • Idempotent POST. POST /api/orders requires an Idempotency-Key header. A retry with the same key and the same body returns the original 201 and creates nothing; the same key with a different body is 422; keys are scoped to the customer and kept for 24 hours.
  • Events. OrderPlaced, OrderPaid and OrderCancelled are published for billing and fulfilment, at least once, through an outbox written in the same transaction as the order.
  • Contract. Errors are application/problem+json, lists use keyset cursors, money is decimal with two places, times are UTC DateTimeOffset, and the OpenAPI document is generated from the code.

Why idempotency is a requirement, not a nicety

A mobile client sends the POST, the order commits, and the response is lost to a timeout. The client cannot tell "never arrived" from "arrived and the reply was lost", so it retries. Without a key the customer is charged twice. The key turns the retry into a lookup. Two details decide whether it works: the record must be stored in the same database transaction as the order, with a unique constraint on (customer_id, key), and the stored request fingerprint must be compared, or a client bug that reuses keys silently returns the wrong order.

Solution layout

LayoutShapeFits when
One API project, feature foldersOrders.Api with Features/Orders, Features/Products, DataOne team, one deployable: the default here
API plus domain libraryOrders.Api and Orders.Domain with no ASP.NET Core referenceDomain rules are shared with a worker process
Four-layer clean architectureApi, Application, Domain, InfrastructureLarge teams; for one service it mostly adds mapping code

The project uses one API project with feature folders, plus tests/Orders.UnitTests and tests/Orders.IntegrationTests, a .slnx solution and Directory.Packages.props for versions. Add a domain library only when a second process needs the same rules.

Where this goes wrong in production

The common failure is an idempotency store in process memory: it works on a laptop and fails as soon as the Deployment runs three replicas, because the retry lands on a pod that never saw the key. The second is a store written after the order commits: a crash between the two writes leaves an order with no key, and the retry creates another. The demo below uses an in-memory store only to show the contract; the data lesson moves it into PostgreSQL.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Reject a second POST /api/orders with the same Idempotency-Key and a different basket with 422 instead of returning the first order.
  • Return the original 201 and Location to a mobile client that retried after a timeout, without charging the customer twice.
  • Decide on one API project with feature folders for a single-team orders service instead of four clean-architecture projects.

More examples

The solution on disk

Feature folders keep everything about orders in one place, so a change to placing an order touches one folder instead of four projects. <code>Program.cs</code> stays short because each feature exposes one <code>MapX()</code> extension method. Infrastructure that is not a feature (the outbox relay, telemetry names) sits beside it. Tests are separate projects so the API image never ships test code, and <code>global.json</code> stops a newer SDK on a build agent from changing behaviour silently; its <code>"test": { "runner": "Microsoft.Testing.Platform" }</code> entry makes <code>dotnet test</code> run the xUnit v3 projects in MTP mode (see Tests with xUnit v3).

Example · text
orders/
  Orders.slnx
  Directory.Packages.props          # every NuGet version, one place
  Directory.Build.props             # Nullable, TreatWarningsAsErrors, lock files
  global.json                       # pins SDK 10.0.402; selects Microsoft.Testing.Platform
  src/Orders.Api/
    Program.cs                      # composition only: services, pipeline, MapOrders()
    Features/Orders/
      OrderEndpoints.cs             # MapGroup("/api/orders") and handlers
      PlaceOrderRequest.cs          # request records with DataAnnotations
      Order.cs                      # aggregate: status rules, totals
    Features/Products/
    Data/
      OrdersDbContext.cs
      Configurations/               # IEntityTypeConfiguration<T> per entity
      Migrations/
    Infrastructure/
      Outbox/OutboxRelay.cs         # BackgroundService publishing events
      Telemetry/OrdersTelemetry.cs  # ActivitySource and Meter
  tests/Orders.UnitTests/
  tests/Orders.IntegrationTests/    # WebApplicationFactory + Testcontainers
  deploy/k8s/
  .github/workflows/orders-api.yml

The order aggregate owns its state machine

The transition table is one switch over a tuple, so every legal move is visible in five lines and a unit test can enumerate all sixteen pairs. Handlers call <code>CanTransitionTo</code> to return a 409 problem for an expected conflict, and <code>MoveTo</code> throws only for a programming error. Putting the rule in a handler instead means the cancel endpoint and the payment webhook each grow their own, slightly different, copy.

Example · csharp
public enum OrderStatus { Pending, Paid, Shipped, Cancelled }

public sealed record OrderLine(string Sku, int Quantity, decimal UnitPrice)
{
    public decimal LineTotal => Quantity * UnitPrice;
}

public sealed class Order
{
    private readonly List<OrderLine> _lines = [];

    public Order(long customerId, IEnumerable<OrderLine> lines, DateTimeOffset createdAt)
    {
        CustomerId = customerId;
        _lines.AddRange(lines);
        if (_lines.Count == 0) throw new ArgumentException("An order needs at least one line.", nameof(lines));
        CreatedAt = createdAt;
    }

    public long Id { get; private set; }
    public long CustomerId { get; }
    public OrderStatus Status { get; private set; } = OrderStatus.Pending;
    public DateTimeOffset CreatedAt { get; }
    public IReadOnlyList<OrderLine> Lines => _lines;
    public decimal Total => _lines.Sum(l => l.LineTotal);

    public bool CanTransitionTo(OrderStatus next) => (Status, next) switch
    {
        (OrderStatus.Pending, OrderStatus.Paid) => true,
        (OrderStatus.Paid, OrderStatus.Shipped) => true,
        (OrderStatus.Pending or OrderStatus.Paid, OrderStatus.Cancelled) => true,
        _ => false,
    };

    public void MoveTo(OrderStatus next)
    {
        if (!CanTransitionTo(next))
            throw new InvalidOperationException($"Order {Id} cannot move from {Status} to {next}.");
        Status = next;
    }
}

The API contract as an .http file

Writing the contract down before the code settles the questions that are expensive to change later: the customer id comes from the token, never the body; lists take <code>limit</code> and an opaque <code>after</code> cursor; state changes are commands (<code>/cancel</code>) guarded by <code>If-Match</code>. Visual Studio, Rider and VS Code run <code>.http</code> files directly, and the token comes from <code>dotnet user-jwts create --scope orders.read --scope orders.write</code> locally.

Example · http
@api = https://localhost:7043
@token = <from-dotnet-user-jwts>

### Place an order (retry-safe)
POST {{api}}/api/orders
Authorization: Bearer {{token}}
Content-Type: application/json
Idempotency-Key: 6f1c2a90-3b7e-4d1a-9c55-2e8f0b7d4a13

{ "lines": [ { "sku": "KETTLE-BLUE", "quantity": 1 }, { "sku": "MUG-WHITE", "quantity": 4 } ] }

### My orders, newest first, 20 per page
GET {{api}}/api/orders?limit=20
Authorization: Bearer {{token}}

### Next page: the cursor comes from the previous response
GET {{api}}/api/orders?limit=20&after=MTc2MTEzNjAwMDAwMDAwMDA6MTA0Mg
Authorization: Bearer {{token}}

### Cancel, guarded by the version from the ETag
POST {{api}}/api/orders/1042/cancel
Authorization: Bearer {{token}}
If-Match: "1907"

Discussion

  • Be the first to comment on this lesson.