Create, correct, cancel and look up a customer’s purchase orders, with a safe-to-retry create call.
X-Idempotency-Key
never creates a second order (it is refused instead of silently re-run — see
End-to-end flow).404, not a permission error, and never sees it in their own list.This is the template’s one fully hand-written vertical slice — every layer from entity to endpoint is
written out, with no [RaisesEvent], [CrudCreate]/[CrudUpdate], or [GenerateDto] attribute
anywhere in it (contrast: Automated Products, the same shape of
feature built from declarative attributes and compile-time generators). It does not overlap
Products: a different aggregate, a different schema (manual_sample vs. sample), no shared table.
| Term | Meaning | In the code |
|---|---|---|
| Purchase order | One customer’s order, tracked through to cancellation | PurchaseOrder |
| Placed | The only status a new order is created in | PurchaseOrderStatus.Placed |
| Cancelled | Terminal status — no route reopens an order from here | PurchaseOrderStatus.Cancelled |
| Ownership key | The caller-scoped key that decides who can read or write an order; independent of “created by” | PurchaseOrder.OwnedBy |
| Rule | Enforced by | A caller who breaks it gets |
|---|---|---|
Create requires an X-Idempotency-Key header |
.RequiredIdempotentKey() on the create route |
400 Bad Request |
| A replayed create key is refused, not replayed | IdempotencyEndpointFilter (ConflictHandling = ConflictResponse) |
409 Conflict |
| Customer name and amount are required on create | CreatePurchaseOrderCommandValidator |
400 Bad Request with field errors |
| Amount must be positive on update | UpdatePurchaseOrderCommandValidator |
400 Bad Request |
| An already-cancelled order cannot be cancelled again | CancelPurchaseOrderCommandHandler’s status guard |
409 Conflict |
| Every write needs a resolvable acting user | each command handler’s string.IsNullOrEmpty(request.ByUser) guard |
400 Bad Request |
| A caller can only see and change its own orders | the row-level ownership filter plus DataOwnerHook |
404 Not Found (by-id routes), silently absent from the list |
| An authenticated caller with no ownership key cannot create one | ownership enforcement raised as OwnershipRequiredException |
403 Forbidden, no row persisted |
Start the API as the service README describes; it listens on
http://localhost:5000. No separate token step — the Development environment runs with FeatureManagement:RequireAuthorization
off and the built-in demonstration identity on, so every call below is already “authenticated” as that
identity. Create one order; X-Idempotency-Key is required even on this first call:
curl -i -X POST http://localhost:5000/v1/purchase-orders \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: 4581d150-d72f-4384-ae88-d6e0341b743d" \
-d '{"customerName":"Docs Test Co","amount":99.90}'
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
{"id":"8daca0f3-a90e-4501-91b5-21cbcb76c8bb","customerName":"Docs Test Co","amount":99.90,"status":"placed","createdBy":"demo-user@not-a-real-identity.invalid"}
Run against a local instance. createdBy resolves from the caller’s claims, never from the request
body — see Endpoints for every other route.
The create route is the one route in this sample guarded by .RequiredIdempotentKey(). A new key
runs the full pipeline — FluentValidation, the PurchaseOrder constructor (which raises
PurchaseOrderCreatedEvent itself, via AddEvent), SaveChanges, then the in-memory bus delivers the
event to PurchaseOrderCreatedEventHandler, which only logs it. A replayed key short-circuits at the
filter: this deployment configures ConflictHandling = ConflictResponse, so the second call never
reaches the validator or handler and answers 409 Conflict. The diagram cannot show the transaction
boundary (the constructor call, AddAsync and SaveChanges all run inside one DbContext save) or
what happens if the in-memory publish throws after the row has already committed — untested by this
sample.
The order’s status changes only once, on cancel:
PurchaseOrderStatus also declares Draft, but no constructor, handler or migration in this sample
ever assigns it — every order is Placed from the moment it exists. The diagram cannot show that
Delete works regardless of status; deletion is independent of the lifecycle above.
PurchaseOrders table, its columns and its status valuesPurchaseOrderCreatedEvent and its one consumerIPurchaseOrdersClient — a typed Refit client for this feature’s routes, registered by
AddApiClient, for a downstream service that wants to call it without hand-rolling HTTP.| Question | Why it matters | Checked | Who can answer |
|---|---|---|---|
| Who is this API meant to be called by in production — an internal back-office tool, or a partner integration? | Changes the Overview’s “who calls it” line and whether the deployment guide should treat it as externally exposed | README, docs/index.md, docs/template-features.md — none names an intended caller; the sample ships with the built-in demonstration identity only |
Template maintainer / product-owner |
Is PurchaseOrderStatus.Draft reserved for a planned future workflow, or dead code to remove? |
Affects whether a future feature should document a create-as-draft path | Searched every assignment to Status in src/ApiEndpoints/ — none sets Draft |
Template maintainer |
| What does this route return if the idempotency store itself is unavailable (Redis down, with no connection string fallback engaged)? | The deployment guide should say whether a write then fails closed or open | IdempotencyEndpointFilter’s own code path for store.IsKeyProcessedAsync failing was not exercised against this running instance |
DKNet.AspCore.Idempotency maintainer |