The Shipment Pattern
Based on "Enterprise Patterns and MDA" by Jim Arlow & Ila Neustadt
Problem
Orders track what was ordered. Shipments track how it gets to the customer:
- Partial shipments — Large order split across multiple deliveries
- Multiple carriers — DHL, UPS, InPost, Poczta Polska
- Tracking integration — External tracking numbers, status updates
- Shipment events — Picked up, in transit, out for delivery, delivered
- Proof of delivery — Signatures, photos, timestamps
Concept
Shipment represents the physical movement of goods from origin to destination. It has its own lifecycle, separate from Order.
Key insight: One Order can have multiple Shipments (partial fulfillment), and one Shipment can consolidate multiple Orders.
Structure
Shipment Lifecycle
Examples
Fulfilling an order
order = findOrder("ORD-2026-000042")
warehouseAddress = findAddress("WH-01")
// Create shipment from order
shipment = Shipment.fromOrder(order, "SHP-2026-000018", warehouseAddress)
// Add items from order lines
shipment.addItemFromOrderLine(order.lines[0], 1) // ThinkPad x1
shipment.addItemFromOrderLine(order.lines[1], 2) // Mouse x2
// Warehouse flow
shipment.startPicking()
shipment.markPacked()
// Assign carrier
dhl = findCarrier("DHL")
shipment.assignCarrier(dhl, "1234567890")
shipment.markLabelPrinted()
shipment.handOverToCarrier()
// emits ShipmentShipped(shipment)
// shipment.shippedAt = now()
shipment.getTrackingUrl()
// "https://www.dhl.com/track?num=1234567890"Carrier delivers the package
// Carrier tracking updates (via webhook)
shipment.markInTransit("Warszawa Hub")
shipment.markOutForDelivery()
shipment.markDelivered({ signature: "J. Kowalski", photo: "proof_123.jpg" })
// emits ShipmentDelivered(shipment)
shipment.getTransitDays() // 2Status Meanings
| Status | Phase | Description |
|---|---|---|
| Created | Internal | Shipment record created |
| Picking | Internal | Warehouse staff collecting items |
| Packed | Internal | Items packed into boxes/parcels |
| LabelPrinted | Internal | Shipping label generated |
| HandedOver | Internal | Given to carrier |
| InTransit | External | Carrier has it, moving |
| OutForDelivery | External | On delivery vehicle |
| Delivered | External | Successfully delivered |
| DeliveryFailed | External | Delivery attempt failed |
| Returned | External | Returned to sender |
| Cancelled | Any | Shipment cancelled |
Attributes
Shipment
| Attribute | Type | Required | Description |
|---|---|---|---|
| id | UUID | Yes | Unique identifier |
| shipmentNumber | String | Yes | Human-readable reference |
| order | Order | No | Related order (null for consolidated) |
| carrier | Carrier | No | Shipping carrier |
| trackingNumber | String | No | Carrier tracking number |
| status | ShipmentStatus | Yes | Current status |
| shipFromAddress | Address | Yes | Origin (warehouse) |
| shipToAddress | Address | Yes | Destination (customer) |
| weightGrams | Integer | No | Total weight |
| dimensions | Dimensions | No | L × W × H in cm |
| shippingCost | Money | No | Cost of shipping |
| estimatedDelivery | Date | No | Expected delivery date |
| deliveryInstructions | String | No | Special instructions |
| proofOfDelivery | JSON | No | Signature, photo, etc. |
| createdAt | Timestamp | Yes | When created |
| shippedAt | Timestamp | No | When handed to carrier |
| deliveredAt | Timestamp | No | When delivered |
ShipmentItem
| Attribute | Type | Required | Description |
|---|---|---|---|
| id | UUID | Yes | Unique identifier |
| shipment | Shipment | Yes | Parent shipment |
| orderLine | OrderLine | No | Related order line |
| product | Product | Yes | What is being shipped |
| quantity | Integer | Yes | How many |
| serialNumber | String | No | For serialized items |
| batchNumber | String | No | For batch-tracked items |
ShipmentEvent
| Attribute | Type | Required | Description |
|---|---|---|---|
| id | UUID | Yes | Unique identifier |
| shipment | Shipment | Yes | Parent shipment |
| status | ShipmentStatus | Yes | Status at this event |
| description | String | Yes | Event description |
| location | String | No | Where it happened |
| carrierEventCode | String | No | Carrier's event code |
| occurredAt | Timestamp | Yes | When event occurred |
Carrier
| Attribute | Type | Required | Description |
|---|---|---|---|
| id | UUID | Yes | Unique identifier |
| code | String | Yes | Short code (DHL, UPS) |
| name | String | Yes | Full name |
| trackingUrlTemplate | String | No | URL with {tracking} placeholder |
| apiConfig | JSON | No | API credentials/settings |
| isActive | Boolean | Yes | Is carrier available? |
Behaviors
Entity Carrier:
function getTrackingUrl(trackingNumber: String): String or None
if trackingUrlTemplate is None:
return None
return trackingUrlTemplate.replace("{tracking}", trackingNumber)
// "https://www.dhl.com/track?num=1234567890"
Entity Shipment:
// === Creation ===
static function fromOrder(order: Order, shipmentNumber: String, fromAddress: Address): Shipment
shipment = new Shipment(
shipmentNumber: shipmentNumber,
order: order,
shipFromAddress: fromAddress,
shipToAddress: order.shippingAddress,
status: ShipmentStatus.Created
)
shipment.addEvent(ShipmentStatus.Created, "Shipment created")
return shipment
function addItem(product: Product, quantity: Integer): ShipmentItem
item = new ShipmentItem(
shipment: this,
product: product,
quantity: quantity
)
items.add(item)
return item
function addItemFromOrderLine(orderLine: OrderLine, quantity: Integer): ShipmentItem
item = addItem(orderLine.product, quantity)
item.orderLine = orderLine
return item
// === Status transitions ===
function startPicking():
require status == ShipmentStatus.Created
updateStatus(ShipmentStatus.Picking, "Picking started")
function markPacked():
require status == ShipmentStatus.Picking
updateStatus(ShipmentStatus.Packed, "Packed")
function assignCarrier(carrier: Carrier, trackingNumber: String):
this.carrier = carrier
this.trackingNumber = trackingNumber
function markLabelPrinted():
require status == ShipmentStatus.Packed
require carrier is not None
updateStatus(ShipmentStatus.LabelPrinted, "Label printed")
function handOverToCarrier():
require status == ShipmentStatus.LabelPrinted
shippedAt = now()
updateStatus(ShipmentStatus.HandedOver, "Handed to " + carrier.name)
emit ShipmentShipped(this)
function markInTransit(location: String):
require status in [ShipmentStatus.HandedOver, ShipmentStatus.InTransit]
updateStatus(ShipmentStatus.InTransit, "In transit", location)
function markOutForDelivery():
require status == ShipmentStatus.InTransit
updateStatus(ShipmentStatus.OutForDelivery, "Out for delivery")
function markDelivered(proof: JSON):
require status == ShipmentStatus.OutForDelivery
deliveredAt = now()
proofOfDelivery = proof
updateStatus(ShipmentStatus.Delivered, "Delivered")
emit ShipmentDelivered(this)
function markDeliveryFailed(reason: String):
require status == ShipmentStatus.OutForDelivery
updateStatus(ShipmentStatus.DeliveryFailed, "Failed: " + reason)
function markReturned(reason: String):
require status == ShipmentStatus.DeliveryFailed
updateStatus(ShipmentStatus.Returned, "Returned: " + reason)
emit ShipmentReturned(this)
function cancel(reason: String):
require not status.isFinal()
updateStatus(ShipmentStatus.Cancelled, "Cancelled: " + reason)
emit ShipmentCancelled(this)
// === Helpers ===
private function updateStatus(newStatus: ShipmentStatus, description: String, location: String = None):
status = newStatus
addEvent(newStatus, description, location)
function addEvent(status: ShipmentStatus, description: String, location: String = None): ShipmentEvent
event = new ShipmentEvent(
shipment: this,
status: status,
description: description,
location: location,
occurredAt: now()
)
events.add(event)
return event
function getTrackingUrl(): String or None
if carrier is None or trackingNumber is None:
return None
return carrier.getTrackingUrl(trackingNumber)
function getTransitDays(): Integer or None
if shippedAt is None or deliveredAt is None:
return None
return daysBetween(shippedAt, deliveredAt)
function isFinal(): Boolean
return status in [ShipmentStatus.Delivered, ShipmentStatus.Returned, ShipmentStatus.Cancelled]
Entity ShipmentItem:
function setSerialNumber(sn: String):
serialNumber = sn
function setBatchNumber(batch: String):
batchNumber = batchCarrier Webhook Integration
// Receiving tracking updates from carrier
function handleCarrierWebhook(carrierCode: String, payload: JSON): Response
// Find shipment by tracking number
shipment = findShipmentByTracking(payload.trackingNumber)
if shipment is None:
return Response(404, "Shipment not found")
// Map carrier status to our status
ourStatus = mapCarrierStatus(carrierCode, payload.status)
// Create tracking event
event = shipment.addEvent(
status: ourStatus,
description: payload.description,
location: payload.location
)
event.carrierEventCode = payload.carrierCode
// Update shipment status
match ourStatus:
case ShipmentStatus.InTransit:
shipment.markInTransit(payload.location)
case ShipmentStatus.OutForDelivery:
shipment.markOutForDelivery()
case ShipmentStatus.Delivered:
proof = { signature: payload.signature, photo: payload.photoUrl }
shipment.markDelivered(proof)
case ShipmentStatus.DeliveryFailed:
shipment.markDeliveryFailed(payload.reason)
save(shipment)
return Response(200, "OK")
function mapCarrierStatus(carrierCode: String, carrierStatus: String): ShipmentStatus
// Each carrier has different status codes
match carrierCode:
case "DHL":
match carrierStatus:
case "PU": return ShipmentStatus.HandedOver // Picked up
case "IT": return ShipmentStatus.InTransit
case "WC": return ShipmentStatus.OutForDelivery
case "DL": return ShipmentStatus.Delivered
// ... etc
case "UPS":
// ... different mappingsInvariants
- Shipment number unique — No two shipments share the same number
- At least one item — Cannot ship empty shipment
- Carrier required for handover — Must have carrier before marking HandedOver
- Valid state transitions — Only allowed transitions per state machine
- Items from same order — If order-linked, items should match order lines
- Weight matches items — If specified, weight should be reasonable for items
Error Handling
| Operation | Precondition Violated | Error |
|---|---|---|
startPicking() |
Status not Created | PreconditionError: can only start picking from Created status |
markPacked() |
Status not Picking | PreconditionError: must be in Picking status to pack |
markLabelPrinted() |
No carrier assigned | PreconditionError: carrier must be assigned before printing label |
handOverToCarrier() |
Status not LabelPrinted | PreconditionError: label must be printed before handover |
markDelivered(proof) |
Status not OutForDelivery | PreconditionError: must be out for delivery to mark delivered |
cancel(reason) |
Already in final status | PreconditionError: cannot cancel delivered shipment |
markInTransit(loc) |
Not HandedOver/InTransit | PreconditionError: shipment not yet handed to carrier |
Carrier webhook resilience: If a webhook arrives with a status transition that doesn't match the current state (e.g., "Delivered" while still "HandedOver"), log the event but skip the status update. Carrier events may arrive out of order.
SQL Schema
sql-- Carriers
CREATE TABLE carrier (
id UUID PRIMARY KEY,
code VARCHAR(20) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
tracking_url_template VARCHAR(255),
api_config JSONB,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Shipments
CREATE TABLE shipment (
id UUID PRIMARY KEY,
shipment_number VARCHAR(50) NOT NULL UNIQUE,
order_id UUID REFERENCES orders(id),
carrier_id UUID REFERENCES carrier(id),
tracking_number VARCHAR(100),
status VARCHAR(20) NOT NULL DEFAULT 'Created',
-- Addresses (JSON)
ship_from_address JSONB NOT NULL,
ship_to_address JSONB NOT NULL,
-- Physical
weight_grams INTEGER,
length_cm DECIMAL(10,2),
width_cm DECIMAL(10,2),
height_cm DECIMAL(10,2),
-- Cost
shipping_cost_cents INTEGER,
shipping_cost_currency CHAR(3),
-- Delivery
estimated_delivery DATE,
delivery_instructions TEXT,
proof_of_delivery JSONB,
-- Timestamps
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
shipped_at TIMESTAMP,
delivered_at TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_shipment_status CHECK (
status IN ('Created', 'Picking', 'Packed', 'LabelPrinted', 'HandedOver',
'InTransit', 'OutForDelivery', 'Delivered', 'DeliveryFailed',
'Returned', 'Cancelled')
)
);
-- Shipment items
CREATE TABLE shipment_item (
id UUID PRIMARY KEY,
shipment_id UUID NOT NULL REFERENCES shipment(id) ON DELETE CASCADE,
order_line_id UUID REFERENCES order_line(id),
product_id UUID NOT NULL REFERENCES product(id),
quantity INTEGER NOT NULL,
serial_number VARCHAR(100),
batch_number VARCHAR(50),
CONSTRAINT chk_positive_quantity CHECK (quantity > 0)
);
-- Shipment events (tracking history)
CREATE TABLE shipment_event (
id UUID PRIMARY KEY,
shipment_id UUID NOT NULL REFERENCES shipment(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL,
description VARCHAR(255) NOT NULL,
location VARCHAR(100),
carrier_event_code VARCHAR(50),
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Indexes
CREATE INDEX idx_shipment_order ON shipment(order_id);
CREATE INDEX idx_shipment_carrier ON shipment(carrier_id);
CREATE INDEX idx_shipment_tracking ON shipment(tracking_number) WHERE tracking_number IS NOT NULL;
CREATE INDEX idx_shipment_status ON shipment(status);
CREATE INDEX idx_shipment_item_shipment ON shipment_item(shipment_id);
CREATE INDEX idx_shipment_event_shipment ON shipment_event(shipment_id);Common Queries
sql-- Get shipment with items and events
SELECT
s.*,
c.name as carrier_name,
json_agg(DISTINCT jsonb_build_object(
'product_id', si.product_id,
'quantity', si.quantity,
'serial_number', si.serial_number
)) as items,
json_agg(DISTINCT jsonb_build_object(
'status', se.status,
'description', se.description,
'location', se.location,
'occurred_at', se.occurred_at
) ORDER BY se.occurred_at DESC) as events
FROM shipment s
LEFT JOIN carrier c ON c.id = s.carrier_id
LEFT JOIN shipment_item si ON si.shipment_id = s.id
LEFT JOIN shipment_event se ON se.shipment_id = s.id
WHERE s.id = :shipmentId
GROUP BY s.id, c.name;
-- Find shipment by tracking number
SELECT s.*, c.name as carrier_name
FROM shipment s
LEFT JOIN carrier c ON c.id = s.carrier_id
WHERE s.tracking_number = :trackingNumber;
-- Shipments pending pickup (ready but not shipped)
SELECT * FROM shipment
WHERE status IN ('Packed', 'LabelPrinted')
AND created_at < CURRENT_TIMESTAMP - INTERVAL '4 hours';
-- Delivery performance by carrier
SELECT
c.name,
COUNT(*) as total_shipments,
AVG(EXTRACT(EPOCH FROM (delivered_at - shipped_at)) / 86400) as avg_transit_days,
COUNT(*) FILTER (WHERE status = 'Delivered') as delivered,
COUNT(*) FILTER (WHERE status = 'DeliveryFailed') as failed
FROM shipment s
JOIN carrier c ON c.id = s.carrier_id
WHERE s.shipped_at >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY c.id;Design Considerations
Shipment vs Order
| Aspect | Order | Shipment |
|---|---|---|
| Purpose | What customer wants | How to get it there |
| Lifecycle | Purchase flow | Fulfillment/delivery flow |
| Relationship | 1 Order → N Shipments | Split/partial fulfillment |
| Key fields | Products, prices | Carrier, tracking, events |
Event Sourcing for Tracking
Every status change creates a ShipmentEvent:
- Complete history preserved
- Can replay timeline
- Carrier events stored with original codes
- Enables delivery analytics
Partial Shipments
When order can't be fully shipped at once:
- Create multiple Shipments from same Order
- Each ShipmentItem references its OrderLine
- Track fulfillment status per line
Return Shipments (RMA)
For returns, consider:
- Separate ReturnShipment entity
- Or use Shipment with
isReturnflag - Link to RMA (Return Merchandise Authorization) record