The Project
Turn the requirements for an orders API into a resource model, an idempotent POST contract, an event plan and a solution layout.
POST /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 bodyThe 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,productsandorderswith lines. An order movesPendingtoPaidtoShipped, or toCancelledfromPendingorPaid; no other transition is legal, and the rule lives on theOrdertype, not in handlers. - Auth. Access tokens come from an external identity provider. The
subclaim identifies the customer; scopesorders.readandorders.writegate the endpoints through policies namedorders:readandorders:write; astaffrole sees all orders. A customer asking for someone else's order gets 404, not 403, so ids cannot be probed. - Idempotent POST.
POST /api/ordersrequires anIdempotency-Keyheader. 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,OrderPaidandOrderCancelledare 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 isdecimalwith two places, times are UTCDateTimeOffset, 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
| Layout | Shape | Fits when |
|---|---|---|
| One API project, feature folders | Orders.Api with Features/Orders, Features/Products, Data | One team, one deployable: the default here |
| API plus domain library | Orders.Api and Orders.Domain with no ASP.NET Core reference | Domain rules are shared with a worker process |
| Four-layer clean architecture | Api, Application, Domain, Infrastructure | Large 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
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).
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.ymlThe 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.
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.
@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