← Build logs

Rebuilding an order management system in eight weeks

backenddevlog

The vendor shut down, inventory stopped syncing to three marketplaces, and overselling became the number one business risk. Here is how we rebuilt the OMS from scratch in Go.

▶ Video walkthrough coming soon

In May 2026 the platform a Southeast Asian logistics group used to run a multi-brand retailer’s marketplace operations was shut down by its vendor. Overnight there was no order flow from Shopee, Lazada and TikTok to the warehouse, and stock stopped syncing. For 100+ brands, overselling was flagged as the top business risk.

We had an eight-week window before the August 1 cutover. This post is about the parts I owned: the stock engine, stock sync, and the order ingestion path.

The sellable stock formula

Everything starts from one number per SKU per warehouse:

sellable = (on_hand + fake) - (lock + reserve + bundle_reserve)
  • on_hand is physical stock confirmed by the warehouse.
  • fake is a manual buffer ops can set for pre-orders.
  • lock is stock held by orders already pushed to the warehouse.
  • reserve is stock held by orders accepted but not yet pushed.
  • bundle_reserve is component stock held because a bundle SKU was sold.

The last one is where most bugs hide. A bundle is a virtual SKU; selling one must deduct every component and keep the bundle’s own listing honest.

Concurrency is the real problem

Orders, inbound stock, and the sync job all mutate the same rows. Without discipline, counts drift and orders get stuck. Three rules fixed it:

  1. Idempotent updates. Every stock mutation carries an event id and a cycle id. Re-processing the same event is a no-op.
  2. Lock per SKU per cycle. Not a global lock. Throughput target was ~10k orders/hour.
  3. Diff before push. Only SKUs whose sellable value changed get pushed to the marketplace hub. Fewer calls, fewer races.
func (s *StockService) Apply(ctx context.Context, ev StockEvent) error {
	return s.tx(ctx, func(tx *sql.Tx) error {
		if applied, err := s.events.Seen(tx, ev.ID); err != nil || applied {
			return err // idempotent: already applied
		}
		if err := s.lock.Acquire(tx, ev.SKU, ev.Cycle); err != nil {
			return err
		}
		if err := s.stock.Mutate(tx, ev); err != nil {
			return err
		}
		return s.events.Mark(tx, ev.ID)
	})
}

Order ingestion

Orders arrive from the marketplace hub both by webhook and by polling, which means duplicates. The order id is the dedup key. Accepted orders go onto NSQ and a worker takes it from there: normalize, reserve stock, push to the 3PL, store the AWB label in S3. Failed messages retry with backoff and land in a dead-letter topic so nothing is silently lost.

What I would do differently

  • Build the simulator for the 3PL API on day one. Real-shaped data exposed an enum mismatch (RECEIVE/IN vs our GR/ADJ) that a simulator built from the contract would have caught earlier.
  • Push the single OpenAPI contract and generated frontend types even harder. It was the one thing that kept backend and admin UI from drifting during a very fast delivery.

The cutover happened on schedule. The system now handles roughly 2,500 orders a day and 6,800 SKUs across three marketplaces.