DEV Community

Cover image for Six Requests a Pick-Pack-Ship API Should Refuse (and the Status Code for Each)
MICHAEL-MAURICE
MICHAEL-MAURICE

Posted on

Six Requests a Pick-Pack-Ship API Should Refuse (and the Status Code for Each)

Most of the code in a warehouse API isn't about doing things. It's about refusing them. A packer taps "Pack" on an order nobody has picked. A scanner on bad Wi-Fi sends the same "ship" twice. Two orders want the last five lamps at the same moment. A buggy handheld sends {} where a quantity should be.

Each of those requests needs a clear "no", with the right status code and enough detail that the client knows what to do next. Get the codes wrong and your support team spends the afternoon decoding "500 Internal Server Error" from a warehouse floor.

I built a small pick-pack-ship order API on .NET 10 Minimal APIs and EF Core 10 (SQLite) to work through this. It has 55 passing tests (43 unit, 12 integration) and I poked it by hand with curl. This post is about one slice of it: the six requests it refuses, what it answers with, and the one request it deliberately does not refuse. Code is trimmed to the shape; omitted parts are marked.

Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. Complete, working, tested .NET projects with full source code and PDF guides: Tech Skill Builder.

If you'd rather start from a full warehouse system than from a demo, the Warehouse Management System for .NET 10 is a starter kit with a Minimal API backend, EF Core and CQRS/MediatR. More on it at the end.

The cheat sheet first

The request The answer
Pack an order that was never picked 409, with the actions that are allowed
Reserve stock that isn't there 409, with sku, requested and available
Write with a stale version 412 Precondition Failed
Ship again with a different tracking number 409
Confirm a pick with no quantity 400 validation problem
Send broken JSON 400 (it was 500 until I fixed it)
Ship again with the same tracking number 200, nothing changes

Every error is an RFC 9457 problem details document, built in one static class, so clients parse one shape. Now the reasons.

1. "Pack this order" when nobody picked it: 409 with a way out

The order's lifecycle is a small state machine: Released, Picking, Picked (or ShortPicked), Packed, Shipped, plus Cancelled. I keep the legal moves in a single lookup of (status, action) -> next status. Anything not in it is illegal, and the exception carries the actions that are legal from the current state:

public static OrderStatus Next(OrderStatus from, OrderAction action) =>
    Transitions.TryGetValue((from, action), out var to)
        ? to
        : throw new IllegalTransitionException(from, action, AllowedFrom(from));

// Transitions: a FrozenDictionary of the 10 legal edges (full table in the complete project)
Enter fullscreen mode Exit fullscreen mode

Why 409 and not 400? The request is well formed. It conflicts with the current state of the resource. The useful part is the extension data:

{"title":"Illegal state transition","status":409,
 "detail":"Cannot Pack an order that is Released.",
 "currentStatus":"Released","attemptedAction":"Pack",
 "allowedActions":["StartPicking","Cancel"],"version":0}
Enter fullscreen mode Exit fullscreen mode

A handheld can turn allowedActions straight into the buttons it shows. That's much better than a red toast saying "error".

2. "Reserve 4 mugs" when 1 is free: 409 with numbers

Releasing an order reserves stock bin by bin. Two rules matter:

  • Check every line before reserving any. Otherwise a shortage on line 3 leaves lines 1 and 2 half reserved, and you're writing cleanup code.
  • Say exactly what was short. The problem carries sku, requested and available, so the caller can offer a partial order instead of guessing.
foreach (var (sku, quantity) in merged)
{
    var available = bySku[sku].Sum(b => b.Available);
    if (available < quantity) throw new InsufficientStockException(sku, quantity, available);
}
// second pass: reserve bin by bin in walk order (omitted here)
Enter fullscreen mode Exit fullscreen mode

Under a race this check isn't enough on its own, because two requests can both read "5 available". The bins carry a version column used as an EF Core concurrency token, and release re-reads and retries up to three times on a conflict. When I fired 10 parallel requests for 1 unit each at a bin holding 5, I got 5 x 201 and 5 x 409 "Insufficient stock", twice in a row. Nothing oversold. (I wrote about the oversell race itself in an earlier post, so I won't repeat it here.)

3. A write based on an old read: 412

Every response carries an ETag with the order's version. If the client sends it back as If-Match and the order has moved on, the request is refused before anything runs:

public static bool Matches(HttpContext http, uint version, out string ifMatch)
{
    ifMatch = http.Request.Headers[HeaderNames.IfMatch].ToString();
    if (string.IsNullOrWhiteSpace(ifMatch) || ifMatch.Trim() == "*") return true;
    // compare against the quoted current version (omitted here)
}
Enter fullscreen mode Exit fullscreen mode

412 says "your picture of this order is out of date, reload it". That's a different instruction from 409, and clients should treat it differently.

One design choice to be aware of: no If-Match header means the check is skipped. The row is still protected by the concurrency token, so two writes can't silently overwrite each other, but a client that never sends If-Match gets "last valid write wins". If you need stricter rules, require the header and answer 428 when it's missing. I didn't.

4. The request I don't refuse: the same "ship" twice

This is the one people get wrong in the other direction. A scanner times out, retries, and the second "ship" arrives after the first one succeeded. If you answer 409 "already shipped", the device shows an error for something that worked.

So each domain method returns false when the exact same command was already applied, and the endpoint skips saving:

public bool Ship(string carrier, string trackingNumber, DateTimeOffset now)
{
    if (Status == OrderStatus.Shipped && TrackingNumber == trackingNumber.Trim()) return false; // replay
    // Move(OrderAction.Ship), set carrier, tracking and time (omitted here)
}
Enter fullscreen mode Exit fullscreen mode

Same tracking number: 200, same ETag, nothing written. A different tracking number is not a retry. It goes to the state machine, which has no edge out of Shipped, so it's a 409. Pick confirmations follow the same rule: the same quantity twice is a no-op, a different quantity on a confirmed stop is a conflict.

5. A pick confirmation with no quantity: 400, not "zero"

This one is subtle. The request body is a record with a quantity. With a plain int, a body of {} binds Quantity = 0. In a pick-pack-ship flow, zero is a perfectly valid short pick: "I went to the bin and found nothing". The order would release its reservation and flag the bin for a count, all because a device forgot a field.

public sealed record ConfirmPickRequest([Required, Range(0, 10_000)] int? Quantity);
Enter fullscreen mode Exit fullscreen mode

int? plus [Required] turns a missing field into a 400. Validation here is the built-in .NET 10 Minimal API validation (builder.Services.AddValidation() with DataAnnotations), no extra package. One detail if you build clients against it: the error keys come back PascalCase, like Lines[0].Sku.

6. Broken JSON: should be 400, was 500

All integration tests were green. Then I sent a truncated body with curl and got a 500.

The cause: in the Development environment, Minimal APIs throw a BadHttpRequestException for bad request bodies instead of writing the 400 themselves, and my UseExceptionHandler turned every exception into a 500. The tests ran in a different environment, where the framework writes the 400 directly, so they never saw it.

app.UseExceptionHandler(new ExceptionHandlerOptions
{
    StatusCodeSelector = ex => ex is BadHttpRequestException bad
        ? bad.StatusCode
        : StatusCodes.Status500InternalServerError,
});
Enter fullscreen mode Exit fullscreen mode

The regression test runs the WebApplicationFactory in Development on purpose. I checked that it fails without the fix and passes with it. Lesson: at least one integration test should run in the environment your developers actually use.

One helper keeps the answers consistent

All the workflow endpoints (start picking, confirm pick, complete picking, accept short, pack, ship, cancel) go through one method: load the order, check If-Match, apply one domain method, save, map exceptions to problems. Its signature does a lot of documentation work:

private static async Task<Results<Ok<OrderResponse>, ProblemHttpResult>> TransitionAsync(
    Guid id, HttpContext http, WarehouseDbContext db, CancellationToken ct,
    Func<FulfillmentOrder, IReadOnlyCollection<BinStock>, bool> apply)
{
    // 404 if missing, 412 if stale, apply, SaveChanges only when apply returned true,
    // then map IllegalTransition / DomainException / concurrency conflict to 409s
    // (full implementation in the complete project)
}
Enter fullscreen mode Exit fullscreen mode

Because the endpoints can't invent their own error handling, a new action gets all six behaviours for free.

A checklist for your own workflow API

  • [ ] Is "illegal in this state" a 409 that lists the allowed actions?
  • [ ] Is stock checked for every line before any line is reserved?
  • [ ] Do stock rows have a concurrency token, with a bounded retry?
  • [ ] Do responses carry an ETag, and does a stale If-Match get 412?
  • [ ] Is an exact retry a 200 no-op, and a different command a 409?
  • [ ] Can a missing number ever bind as 0?
  • [ ] Does at least one test run in the Development environment?

What I ran, and what I didn't

55 tests (43 unit, 12 integration with WebApplicationFactory on a temp SQLite file) pass (six consecutive runs), and every status code above came from a real curl session. Not verified: SQL Server or PostgreSQL (SQLite has no rowversion, so the entity manages its own version), load beyond a 10-request race, and EF Core migrations (the demo uses EnsureCreated).

Building a real warehouse system?

A demo shows the refusals. A warehouse needs everything around them. The Warehouse Management System (.NET 10 + Razor Pages) is a starter kit with a Minimal API backend, EF Core, CQRS/MediatR and JWT security, covering sales, purchasing with goods receipt and returns, and inventory with warehouses, transfers, adjustments and stock counts. It ships with SQL Server scripts and seed data under the MIT license, for $42. The pick-pack-ship flow in this post is my own demo, not part of the kit, but these status-code rules drop straight into its order and inventory endpoints.

And if you learn best from complete code you can run, Tech Skill Builder gives you complete, working, tested .NET projects with full source code and step-by-step PDF guides, with new ones added regularly. Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. Join Tech Skill Builder

Which of these does your API answer with a 500 today?

Top comments (0)