Back to BlogIndustry Guides

Building an Order Management System for a Growing Store

Rupak Amin

Founder & Lead Engineer, RAITHub

9 min read

An order management system (OMS) is the orchestration layer above your cart: it owns one order through many states, across split shipments, holds, edits and cancellations, and stays the source of truth whichever channel it came from. Model it as an explicit state machine with an append-only event log, not an editable status column, so every change is recorded and no order ships and refunds at once.

If you would rather have it built for you, see how RAITHub would build this below.

This post is the layer above stock. Which batch each unit ships from is in FEFO fulfilment; stopping two orders selling the last unit is in preventing overselling. The OMS decides what state the order is in and what may happen next.

When does a store need an OMS, not just an orders table?

When one order stops being one shipment. A small store ships each order once, so an orders table with a status column is enough. Growth breaks that: an order splits across warehouses, one line is out of stock and backordered, a customer edits the order after it is placed, part ships while part is on hold, and the order arrives from a second sales channel. Each of these needs the order to hold several fulfilment states at once, and needs a record of how it got there. That is the job an OMS does.

SituationAn orders table strugglesWhat the OMS adds
Split shipmentOne status can't describe "half shipped, half pending"Status per fulfilment, rolled up to an order status
HoldA flag that someone forgets to clearAn explicit held state with a reason and who set it
Edit after placingOverwriting lines loses what was promised and paidAn event that adjusts the order and the amount to capture
CancellationRace with a picker already packing itA transition only allowed from states where cancelling is safe
Multi-channelEach channel has its own truthOne order record the channels write to and read from

What does the order state machine look like?

Keep the order-level states few and clear, and track fulfilment states separately so an order can be partly shipped. The order status is derived from its fulfilments, not set by hand.

CREATE TYPE order_status AS ENUM (
  'pending',        -- placed, payment not captured
  'confirmed',      -- paid or authorised, ready to fulfil
  'on_hold',        -- stopped: stock, fraud review, address problem
  'partially_shipped',
  'shipped',
  'completed',
  'cancelled'
);

CREATE TABLE fulfilments (
  id        bigserial PRIMARY KEY,
  order_id  bigint NOT NULL REFERENCES orders(id),
  status    text   NOT NULL CHECK (status IN ('pending','picking','packed','shipped','delivered','cancelled')),
  location  text   NOT NULL,
  shipped_at timestamptz
);

Allowed transitions are a small table: confirmed may go to on_hold, partially_shipped or cancelled; shipped may go only to completed; nothing transitions out of cancelled or completed. Enforcing the transition list in code is what stops "cancel" from firing on an order a picker has already packed.

Why an event log instead of an editable status column?

Because the question "how did this order get here, and who did it" comes up on every disputed order, and an editable status column cannot answer it. Record every change as an immutable event, and derive the current state from the events. The orders row can still cache the current status for fast queries, but the events are the truth.

CREATE TABLE order_events (
  id         bigserial PRIMARY KEY,
  order_id   bigint      NOT NULL REFERENCES orders(id),
  type       text        NOT NULL,   -- placed, payment_captured, held, shipped, edited, cancelled, refunded
  payload    jsonb       NOT NULL DEFAULT '{}',
  actor      text        NOT NULL,   -- customer, a staff user, or a system job
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX order_events_by_order ON order_events (order_id, created_at);

Appending an event and updating the cached status happen in one transaction, so they never disagree. This is the same append-only discipline that keeps a store-credit balance honest and a refund defensible, and it pairs with idempotent handlers so a retried webhook does not double-apply an event, as in testing payments and webhooks.

How do edits, holds and cancellations stay safe?

  • Edits: an edit is an event that changes the lines and the amount still to capture or refund, not an in-place rewrite. If an item is removed before capture, reduce the authorisation; if after capture, trigger a partial refund through the returns and refund flow.
  • Holds: a hold is a state with a reason and an owner, so it cannot be silently forgotten, and a held order is excluded from the pick queue.
  • Cancellations: allowed only from states where no money or stock has moved irreversibly. Cancelling a confirmed order releases its stock reservation and voids or refunds payment, in one transaction; cancelling a shipped order is a return, not a cancellation.

Each of these is a transition in the state machine, so the rule about what is allowed lives in one place and is tested once.

How do you test an OMS?

Drive orders through transitions and assert the illegal ones are refused. The transition table is a pure function, so it is cheap to test exhaustively; then integration tests cover the money and stock side effects.

import { describe, it, expect } from 'vitest'
import { canTransition, apply } from './order-machine'

describe('order state machine', () => {
  it('refuses to cancel an order that is already shipped', () => {
    expect(canTransition('shipped', 'cancelled')).toBe(false)
  })

  it('rolls up to partially_shipped when one of two fulfilments ships', () => {
    const order = apply({ status: 'confirmed', fulfilments: ['pending', 'pending'] },
      { type: 'shipped', fulfilmentIndex: 0 })
    expect(order.status).toBe('partially_shipped')
  })

  it('never both ships and refunds the same fulfilment', () => {
    const after = apply({ status: 'shipped', fulfilments: ['shipped'] }, { type: 'cancelled' })
    expect(after.status).toBe('shipped')   // cancel is refused; a return is a separate path
  })
})

End to end, a split order should ship one parcel, show partially shipped, then complete when the second ships; a cancellation after packing should be refused with a clear message, not a 500. The order's event log should read as a clean history afterwards.

Buy, build or hire?

OptionChoose this whenThe catch
Platform orders plus apps (Shopify and similar)Single-warehouse, single-channel, standard fulfilmentSplit shipments, holds and multi-channel truth often outgrow the platform; see Shopify limitations and when to go custom
A standalone OMS productYou run many channels and warehouses and a product fits your flowYour own edits, holds and payment rules may not map onto its model
Custom buildOrders split, edit, hold and arrive from several channels, and the order must be your source of truthYou own the state machine, the event log and the tests

How long does it take to build yourself, and what is the risk?

For an experienced backend developer building an OMS on top of an existing store, our estimate is 6 to 12 weeks: the state machine, fulfilments, the event log, edits, holds, cancellations and the test suite. The main risk is letting the order status become an editable field that each channel writes to independently; once two sources disagree, reconciliation is painful. Decide early that the OMS owns the order and the channels sync to it.

Why RAITHub for this

  • Order orchestration in production. Sundor Skin, a B2B wholesale platform RAITHub built, runs orders through credit checks, re-pricing, allocation and dispatch with a hash-chained audit log, across 146 PostgreSQL tables and 530+ tests. See the Sundor Skin case study.
  • State machines are tested. Transitions, split shipments and cancellations get unit and integration tests gated in CI, so an illegal transition cannot ship.
  • Events, not overwrites. On TheSkinProof, the founder's own venture built and run by RAITHub, order and money changes are recorded as events across 217 endpoints and 750+ tests.

When you don't need us

  • One warehouse, one channel. An orders table with a status column is fine; do not build an OMS yet.
  • A platform fits. If your fulfilment maps onto a hosted platform or an OMS product, configure it.
  • You only need the machine. The transition table above is a fair start for your own developer.

How RAITHub would build this

  • Order state machine: explicit order and fulfilment states, an enforced transition list, status rolled up from fulfilments.
  • Event log: append-only events as the truth, a cached status updated in the same transaction, idempotent handlers.
  • Operations: split shipments, holds with reasons, edits that adjust capture, safe cancellations that release stock and money.
  • Tests: exhaustive transition tests plus integration tests for the money and stock side effects, gated in CI.

Timeline: an OMS on top of an existing store is backend work, typically 6 to 12 weeks; as part of a new store MVP a focused version fits the 4 to 6 week fixed-scope range. See API and backend development, SaaS development and the ecommerce industry page.

You receive: automated tests and CI covering the state machine and side effects, handover docs and runbooks, and full IP in your name under NDA.

Next step: book the free 15-minute technical audit with your order flow and the states you need, and we will follow up with a written fixed quote.

Frequently asked questions

What is an order management system?

The layer that owns an order through its whole life: confirmation, holds, split shipments, edits, cancellations and completion, across channels. It is the single source of truth for order status, above the cart and the stock system.

When do I need an OMS instead of an orders table?

When one order stops being one shipment: split fulfilment, backorders, holds, post-placement edits, or orders from more than one channel. Until then, an orders table with a status column is enough.

Should order status be a column or derived from events?

Record every change as an append-only event and derive status from it, caching the current status for fast queries in the same transaction. An editable status column cannot answer how an order reached its state or who changed it.

How do I stop an order being cancelled after it ships?

Enforce an allowed-transition list. Cancellation is permitted only from states where no money or stock has moved irreversibly. From a shipped state, the path is a return, not a cancellation, and the machine refuses the illegal transition.

Can the OMS own orders from several sales channels?

Yes, and it should. Make the OMS the source of truth and have each channel write to and read from it, rather than each channel keeping its own order status. Two independent truths are what make reconciliation painful.

Can I use a platform or an OMS product instead of building one?

Often yes for a single-warehouse, single-channel store. Build custom when orders split, edit and hold in ways the platform cannot, or when the order must be your own source of truth across channels.

order management systemomsorder state machinesplit shipmentorder eventsecommerce backend

Ready to discuss your project?

Book a free 15-minute technical audit with our engineering team.