DEV Community

Cover image for I ran 50 orders at 1 unit of stock. Here’s what stopped the oversell.
Arnav Poswal
Arnav Poswal

Posted on

I ran 50 orders at 1 unit of stock. Here’s what stopped the oversell.

Last week I built a small grocery-style order system to learn how event-driven services handle the classic problem: two customers click “buy” on the last item at the same time.

A caveat first. I built this in about 24 hours using AI coding agents (Cursor), and I directed the design. I’m writing this after going back through the code to understand why it works, which is a different thing from writing every line myself. If you’re after a hand-coded reference, this isn’t one. If you want to see how these patterns fit together and how I tested them, read on.

What it is

Three Spring Boot services (order, inventory, payment), each with its own PostgreSQL database, talking only through Kafka. There’s no central coordinator. Each service reacts to events and publishes its own, which is called a choreographed saga. A Next.js storefront sits on top, and an optional ELK stack lets me trace any order by its ID.

Happy path: an order goes PENDING, then RESERVED, then CONFIRMED. If stock runs out or the payment is declined, the order goes to CANCELLED and any reserved stock is released through a compensation event.

The problem

Writing to your database and publishing to Kafka are two separate operations. If the service crashes between them, you either have an order nobody hears about or an event for an order that doesn’t exist.

The three things that handle it

Transactional outbox. The order service writes the order and the event into the same database transaction. A separate step then publishes the event to Kafka. Either both are saved or neither is.
Idempotent consumers. Kafka can deliver a message more than once. Each consumer records the events it has processed (a processed_events table), so a duplicate does nothing the second time.
Atomic stock updates plus per-order keys. Stock is decremented in a single atomic update, so two orders can’t both read “1 left.” Events for one order use that order’s ID as the Kafka key, which keeps them in order.

The test

I wrote a k6 script that resets one item to a stock of 1, then fires 50 concurrent orders at it, one per virtual user. Afterward it checks that available stock never went below zero and that confirmed orders didn’t exceed the starting stock.

On my MacBook Pro (M5 Pro, 16 GB), all 50 requests were accepted. After the saga settled, exactly one order was confirmed and stock sat at 0. The whole run, including setup and waiting for outcomes, took about 6.8 seconds. The other orders are designed to end up CANCELLED. [check your database and put the real count here]

What this does and doesn’t prove

It’s a correctness test, not a performance benchmark. It shows the system doesn’t oversell under a burst of 50 on a laptop. It says nothing about throughput at scale, and I haven’t run a long soak test. Payments are simulated too: a token of ok succeeds and fail declines.

What surprised me

[one or two honest sentences: something you learned or got wrong when you traced the code, e.g. what you expected the outbox to do versus what it actually does]

Try it

Everything runs with docker compose up --build. The repo has the architecture notes, the acceptance scenarios, and the benchmark results:

GitHub logo ArnavPoswal15 / orderflow-platform

Event-driven order platform: Spring Boot microservices + Kafka choreographed saga, transactional outbox, idempotent consumers, Redis, PostgreSQL, Next.js storefront, ELK, k6 load test

Orderflow Platform

CI

Event-driven order platform for grocery-style orders: order, inventory, and payment services each own a database and coordinate through Kafka using a choreographed saga (no central orchestrator).

Status: Phases 1–7 complete (saga, storefront, ops, ELK observability, k6 proof load test, GitHub Actions CI, demo docs).

Architecture

flowchart TB
  %% Improved contrast + spacing to avoid overlap
  %% Global class defs (explicit text color and font size for readability)
  classDef cluster fill:#f8fafc,stroke:#0f172a,stroke-width:2px,rx:8,ry:8,color:#0f172a,font-size:14px
  classDef service fill:#fff7ed,stroke:#b45309,stroke-width:1.5px,rx:6,ry:6,color:#0f172a,font-size:14px
  classDef data fill:#ecfeff,stroke:#075985,stroke-width:1.5px,rx:6,ry:6,color:#0f172a,font-size:14px;
  classDef edge fill:#eef2ff,stroke:#3730a3,stroke-width:1.5px,rx:6,ry:6,color:#0f172a,font-size:14px;
  classDef msg fill:#f0fdf4,stroke:#166534,stroke-width:1.5px,rx:6,ry:6,color:#0f172a,font-size:14px;
  classDef clients fill:#fff1f2,stroke:#b91c1c,stroke-width:1.5px,rx:6,ry:6,color:#0f172a,font-size:14px;
  linkStyle default stroke:#94a3b8,stroke-width:1.2px;

  %% Top: clients -> edge -> messaging -> services -> data (vertical flow to reduce overlaps)
  subgraph clients [Clients]
    direction TB
    WEB["Storefront\nNext.js :3000"]:::clients
    OPS["Ops dashboard\n/ops"]:::clients
    API["HTTP clients\n(API, k6, tests)"]:::clients
  end

  subgraph edge [Edge / API Gateway]
    direction TB
    OS["order-service\nREST API"]:::edge
  end

  subgraph backbone [Messaging / Kafka]
    direction TB
    K["Kafka\norders · inventory · payments\n+ DLT topics"]:::msg
  end

  subgraph services
…

Top comments (0)