Most 3PLs we sit with run three systems for three stages of the same physical box: a WMS for the warehouse, a TMS for dispatch, and a last-mile app for the driver. Each owns a record. Each assigns its own ID. Each has its own exception queue.
The operational cost shows up quietly: a customer calls asking where their package is, and the ops lead has to pull up three screens, cross-reference three IDs, and piece together a timeline. Half the time nobody is sure which system holds the truth when the timestamps disagree.
The claim of this post: a shipment is one object, not three records. The physical box has one identity; the data model should reflect that.
Why three records is the wrong default
The tempting reason to use three records is that each system has a different view. WMS cares about bin location and pick status. TMS cares about driver assignment and route sequence. Last-mile cares about POD and exception reason. So the early engineering call is: let each system own its own object, and stitch them with a shared order_id.
This works until it doesn’t. The failure modes are familiar:
- Hand-off drift. Pick completes at 10:42; dispatch doesn’t see it until 10:47 because the sync job runs on a 5-minute interval. A driver leaves without the package that was ready 5 minutes ago.
- Exception scatter. An address error noticed by the driver at 2pm doesn’t reach the warehouse team, who are the only ones who can rebook the outbound SKU to a different customer.
- Timeline reconstruction. When a customer escalates, building the truth from three systems’ logs eats a support engineer’s afternoon.
A minimal shipment data contract
Instead of three records, we model one shipment with five load-bearing fields and one status enum:
type Shipment = {
shipment_id: string; // opaque, assigned at receiving
stage: 'received' | 'picked' | 'dispatched' | 'in_transit' | 'delivered' | 'exception';
handoff_at: Record<Stage, Date>; // when each stage closed
exception_ref: string | null; // points to the shared exception queue
sla_deadline: Date; // the one number everyone argues about
};
WMS, TMS, and last-mile all read and write this object. Each system still owns its domain-specific data — bin, route sequence, POD image — but those live as foreign references, not as duplicated copies of stage or deadline.
The data contract also forbids one thing: no stage can advance without a handoff timestamp. If dispatch wants to mark a shipment as dispatched, handoff_at.picked must already be set. This constraint alone removes most of the drift we used to chase.
Hand-off mechanics
Hand-offs are where unified models earn their keep. A few patterns we land on:
- Pick complete → dispatch ready. When
handoff_at.pickedwrites, the TMS sees a row appear in itsreadyqueue on the next event loop — not on the next 5-minute sync. We use a simple webhook fan-out with at-least-once delivery and idempotent consumers. - POD → last-mile closed. The driver’s POD capture writes
handoff_at.deliveredand attaches the POD image. The warehouse team stops seeing the shipment in their open-case list. - Exception queue is shared. There is exactly one
exceptionstable. A stuck pick, a failed dispatch, and a refused delivery all land here, with the same schema. Ops triages from one screen.
Trade-off: when not to unify
Unification is an investment. You pay for it in schema design, migration, and the cross-team conversations about who owns which field. There are cases where three records is the right call:
- Contract 3PL for a single stage. If you only run the warehouse and the customer owns dispatch, unifying beyond your boundary is over-engineering. You’ll be pushing data into a model you can’t keep in sync.
- Heterogeneous regulation. If warehouse data must stay on-prem for customs reasons and dispatch runs in the cloud, the unified model becomes a bridge, not a single store — adding latency and a failure mode.
- Sub-20 shipments per day. A spreadsheet and a group chat still work. Engineering effort is better spent elsewhere.
A number we watch
After rolling this out with one of our 3PL customers, the handoff latency we care about most — p95(handoff_at.picked → handoff_at.dispatched) — dropped from the 5-minute sync ceiling to a little under 15 seconds. Not because anything got faster operationally, but because the sync job stopped being in the critical path. Driver departure times followed.
We didn’t publish a splashy 10× number. The realistic win is that exception resolution moved from “three-system detective work” to “open the queue, find the row.” That is worth the engineering cost every time.