Software Archetypes

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

1

1

1..*

0..*

Shipment

+id: UUID

+shipmentNumber: String

+order: Order

+carrier: Carrier

+trackingNumber: String

+status: ShipmentStatus

+items: ShipmentItem[]

+events: ShipmentEvent[]

+getTrackingUrl() : : String

ShipmentItem

+id: UUID

+shipment: Shipment

+product: Product

+quantity: Integer

+serialNumber: String

ShipmentEvent

+id: UUID

+shipment: Shipment

+status: ShipmentStatus

+description: String

+location: String

+occurredAt: Timestamp

Carrier

+id: UUID

+code: String

+name: String

+trackingUrlTemplate: String

+getTrackingUrl(tracking) : : String

Order

Product

Shipment Lifecycle

Internal (Warehouse)

External (Carrier)

startPicking()

markPacked()

printLabel()

handOver()

carrier scan

out for delivery

delivered

failed attempt

retry

returned to sender

Created

Picking

Packed

LabelPrinted

HandedOver

InTransit

OutForDelivery

Delivered

DeliveryFailed

Returned

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()  // 2

Status 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 = batch

Carrier 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 mappings

Invariants

  1. Shipment number unique — No two shipments share the same number
  2. At least one item — Cannot ship empty shipment
  3. Carrier required for handover — Must have carrier before marking HandedOver
  4. Valid state transitions — Only allowed transitions per state machine
  5. Items from same order — If order-linked, items should match order lines
  6. 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:

  1. Create multiple Shipments from same Order
  2. Each ShipmentItem references its OrderLine
  3. Track fulfillment status per line

Return Shipments (RMA)

For returns, consider:

  • Separate ReturnShipment entity
  • Or use Shipment with isReturn flag
  • Link to RMA (Return Merchandise Authorization) record
  • Order — What is being shipped
  • Inventory — Stock issued when shipped
  • Payment — May trigger on delivery (COD)
  • Product — What's in the shipment