Software Archetypes

The Order Archetype

Based on "Enterprise Patterns and MDA" by Jim Arlow & Ila Neustadt

Problem

Orders are central to commerce but have complex requirements:

  • Header + lines structure: Order metadata vs individual items
  • Lifecycle management: Draft → Confirmed → Paid → Shipped → Completed
  • Price calculations: Line totals, discounts, tax, shipping, grand total
  • Modifications: Adding/removing items, quantity changes, cancellations
  • History: Who changed what and when

Concept

The Order pattern models a commercial transaction between a buyer and seller. An Order has:

  • Header — Overall order information (customer, dates, totals, status)
  • Lines — Individual items being ordered (product, quantity, price)
  • Lifecycle — States the order passes through

Orders are typically immutable once confirmed — changes create new versions or adjustment records.

Structure

1

1..*

Order

+id: UUID

+orderNumber: String

+customer: Customer

+status: OrderStatus

+lines: OrderLine[]

+billingAddress: Address

+shippingAddress: Address

+calculateTotal() : : Money

+confirm() : : void

OrderLine

+id: UUID

+order: Order

+lineNumber: Integer

+product: Product

+quantity: Integer

+unitPrice: Money

+discount: Money

+getLineTotal() : : Money

«enumeration»

OrderStatus

Draft

Confirmed

Paid

Processing

Shipped

Delivered

Completed

Cancelled

Customer

Product

Attributes

Order

Attribute Type Required Description
id UUID Yes Unique identifier
orderNumber String Yes Human-readable order number
customer Customer Yes Who placed the order
status OrderStatus Yes Current order state
orderDate Timestamp Yes When order was placed
confirmedAt Timestamp No When order was confirmed
currency String Yes ISO currency code (PLN, EUR)
billingAddress Address Yes Invoice address
shippingAddress Address No Delivery address (if different)
subtotal Money Yes Sum of line totals
discountTotal Money No Order-level discounts
taxTotal Money Yes Total tax amount
shippingCost Money No Delivery cost
grandTotal Money Yes Final amount to pay
notes String No Customer notes
internalNotes String No Staff notes

OrderLine

Attribute Type Required Description
id UUID Yes Unique identifier
order Order Yes Parent order
lineNumber Integer Yes Line sequence (1, 2, 3...)
product Product Yes What is being ordered
productName String Yes Product name (snapshot)
productSku String Yes SKU at time of order
quantity Integer Yes How many
unitPrice Money Yes Price per unit
discount Money No Line-level discount
taxRate Decimal Yes Tax percentage (e.g., 23.00)
taxAmount Money Yes Calculated tax
lineTotal Money Yes (unitPrice × quantity) - discount

Behaviors

Entity Order:
    
    function addLine(product: Product, quantity: Integer, unitPrice: Money): OrderLine
        require status == OrderStatus.Draft
        require quantity > 0
        
        line = new OrderLine(
            order: this,
            lineNumber: lines.length + 1,
            product: product,
            productName: product.name,  // Snapshot
            productSku: product.sku,
            quantity: quantity,
            unitPrice: unitPrice,
            taxRate: product.taxRate
        )
        
        lines.add(line)
        recalculateTotals()
        return line
    
    function removeLine(line: OrderLine):
        require status == OrderStatus.Draft
        require line in lines
        
        lines.remove(line)
        renumberLines()
        recalculateTotals()
    
    function updateQuantity(line: OrderLine, newQuantity: Integer):
        require status == OrderStatus.Draft
        require newQuantity > 0
        
        line.quantity = newQuantity
        line.recalculate()
        recalculateTotals()
    
    function recalculateTotals():
        subtotal = lines.sum(line => line.lineTotal)
        taxTotal = lines.sum(line => line.taxAmount)
        grandTotal = subtotal + taxTotal + shippingCost - discountTotal
    
    function confirm():
        require status == OrderStatus.Draft
        require lines.isNotEmpty()
        require validate().isValid
        
        status = OrderStatus.Confirmed
        confirmedAt = now()
        
        emit OrderConfirmed(this)
    
    function markPaid(payment: Payment):
        require status == OrderStatus.Confirmed
        require payment.amount >= grandTotal
        
        status = OrderStatus.Paid
        paidAt = now()
        
        emit OrderPaid(this, payment)
    
    function cancel(reason: String):
        require status in [OrderStatus.Draft, OrderStatus.Confirmed]
        
        previousStatus = status
        status = OrderStatus.Cancelled
        cancelledAt = now()
        cancellationReason = reason
        
        emit OrderCancelled(this, reason)
    
    function validate(): ValidationResult
        errors = []
        
        if lines.isEmpty():
            errors.add("Order must have at least one line")
        
        if billingAddress is None:
            errors.add("Billing address is required")
        
        for line in lines:
            if line.quantity <= 0:
                errors.add("Line " + line.lineNumber + ": quantity must be positive")
            if line.unitPrice < 0:
                errors.add("Line " + line.lineNumber + ": price cannot be negative")
        
        return ValidationResult(isValid: errors.isEmpty(), errors: errors)
    
    function canBeModified(): Boolean
        return status == OrderStatus.Draft
    
    function canBeCancelled(): Boolean
        return status in [OrderStatus.Draft, OrderStatus.Confirmed]


Entity OrderLine:
    
    function getLineTotal(): Money
        return (unitPrice * quantity) - discount
    
    function getTaxAmount(): Money
        return getLineTotal() * (taxRate / 100)
    
    function getGrossTotal(): Money
        return getLineTotal() + getTaxAmount()
    
    function recalculate():
        lineTotal = getLineTotal()
        taxAmount = getTaxAmount()

State Machine

create

confirm()

cancel()

markPaid()

cancel()

startProcessing()

ship()

markDelivered()

complete()

Draft

Confirmed

Cancelled

Paid

Processing

Shipped

Delivered

Completed

Can modify lines
Can be cancelled

Lines locked
Awaiting payment

Examples

Creating and confirming an order

jan = findCustomer("CUST-00001234")
thinkpad = findProduct("LEN-X1C-G11-16-512")
mouse = findProduct("LOG-MX-MASTER-3S")

order = new Order(
    orderNumber: "ORD-2026-000042",
    customer: jan,
    currency: "PLN",
    billingAddress: jan.party.getPrimaryAddress()
)

// Add lines (order is in Draft status)
order.addLine(thinkpad, quantity: 1, unitPrice: Money(649900, "PLN"))
order.addLine(mouse, quantity: 2, unitPrice: Money(44900, "PLN"))

order.subtotal     // 739700 (6499.00 + 2 x 449.00)
order.grandTotal   // 909633 (with 23% VAT)

// Confirm locks the lines
order.confirm()
// emits OrderConfirmed(order)
// order.status == Confirmed
// order.addLine(...) would now fail — require status == Draft

Cancelling an order

order.cancel("Customer changed their mind")
// emits OrderCancelled(order, "Customer changed their mind")
// order.status == Cancelled

Order Number Generation

// Strategies for generating order numbers

// 1. Sequential with prefix
function generateOrderNumber(): String
    year = currentYear()
    sequence = getNextSequence("order", year)
    return "ORD-" + year + "-" + padLeft(sequence, 6, '0')
    // Example: ORD-2024-000123

// 2. Date-based
function generateOrderNumber(): String
    date = today().format("YYYYMMDD")
    sequence = getNextSequence("order", date)
    return date + "-" + padLeft(sequence, 4, '0')
    // Example: 20241215-0042

// 3. Random (for hiding volume)
function generateOrderNumber(): String
    return "ORD-" + randomAlphanumeric(10).toUpperCase()
    // Example: ORD-X7K9M2P4Q1

Invariants

  1. Order number unique — No two orders share the same order number
  2. At least one line — Confirmed orders must have at least one line
  3. Positive quantities — Line quantities must be > 0
  4. Non-negative prices — Prices and totals cannot be negative
  5. Consistent currency — All monetary values in same currency
  6. Lines locked after confirm — Cannot modify lines after confirmation
  7. Status transitions valid — Only allowed state transitions

Error Handling

Operation Precondition Violated Error
addLine(product, qty, price) Order not in Draft PreconditionError: cannot modify confirmed order
addLine(product, qty, price) quantity <= 0 PreconditionError: quantity must be positive
removeLine(line) Order not in Draft PreconditionError: cannot modify confirmed order
updateQuantity(line, qty) Order not in Draft PreconditionError: cannot modify confirmed order
confirm() No lines PreconditionError: order must have at least one line
confirm() Validation fails PreconditionError: billing address is required
markPaid(payment) Not Confirmed PreconditionError: order must be confirmed before payment
markPaid(payment) Payment < total PreconditionError: payment amount insufficient
cancel(reason) Already shipped/completed PreconditionError: cannot cancel order in status Shipped

Note: After confirmation, all modification attempts return a clear error directing the caller to cancel-and-reorder instead.

SQL Schema

sqlCREATE TABLE orders (  -- "order" is reserved word
    id                  UUID PRIMARY KEY,
    order_number        VARCHAR(30) NOT NULL UNIQUE,
    customer_id         UUID NOT NULL REFERENCES party_role(id),
    status              VARCHAR(20) NOT NULL DEFAULT 'Draft',
    order_date          TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    confirmed_at        TIMESTAMP,
    currency            CHAR(3) NOT NULL DEFAULT 'PLN',
    
    -- Addresses (stored as JSON or separate table)
    billing_address     JSONB NOT NULL,
    shipping_address    JSONB,
    
    -- Totals (stored in cents)
    subtotal_cents      INTEGER NOT NULL DEFAULT 0,
    discount_total_cents INTEGER NOT NULL DEFAULT 0,
    tax_total_cents     INTEGER NOT NULL DEFAULT 0,
    shipping_cost_cents INTEGER NOT NULL DEFAULT 0,
    grand_total_cents   INTEGER NOT NULL DEFAULT 0,
    
    -- Notes
    notes               TEXT,
    internal_notes      TEXT,
    
    -- Cancellation
    cancelled_at        TIMESTAMP,
    cancellation_reason TEXT,
    
    -- Audit
    created_at          TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at          TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    
    CONSTRAINT chk_order_status CHECK (
        status IN ('Draft', 'Confirmed', 'Paid', 'Processing', 'Shipped', 'Delivered', 'Completed', 'Cancelled')
    )
);

CREATE TABLE order_line (
    id                  UUID PRIMARY KEY,
    order_id            UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    line_number         INTEGER NOT NULL,
    product_id          UUID REFERENCES product(id),  -- Nullable if product deleted
    product_name        VARCHAR(255) NOT NULL,  -- Snapshot
    product_sku         VARCHAR(50) NOT NULL,   -- Snapshot
    quantity            INTEGER NOT NULL,
    unit_price_cents    INTEGER NOT NULL,
    discount_cents      INTEGER NOT NULL DEFAULT 0,
    tax_rate            DECIMAL(5,2) NOT NULL,
    tax_amount_cents    INTEGER NOT NULL,
    line_total_cents    INTEGER NOT NULL,
    
    CONSTRAINT chk_positive_quantity CHECK (quantity > 0),
    CONSTRAINT chk_non_negative_price CHECK (unit_price_cents >= 0),
    UNIQUE(order_id, line_number)
);

-- Order status history for audit
CREATE TABLE order_status_history (
    id              UUID PRIMARY KEY,
    order_id        UUID NOT NULL REFERENCES orders(id),
    from_status     VARCHAR(20),
    to_status       VARCHAR(20) NOT NULL,
    changed_at      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    changed_by      UUID,  -- User who made change
    reason          TEXT
);

-- Indexes
CREATE INDEX idx_order_customer ON orders(customer_id);
CREATE INDEX idx_order_status ON orders(status);
CREATE INDEX idx_order_date ON orders(order_date);
CREATE INDEX idx_order_line_order ON order_line(order_id);
CREATE INDEX idx_order_line_product ON order_line(product_id);

Common Queries

sql-- Get order with lines
SELECT 
    o.*,
    json_agg(
        json_build_object(
            'lineNumber', ol.line_number,
            'productName', ol.product_name,
            'quantity', ol.quantity,
            'unitPrice', ol.unit_price_cents / 100.0,
            'lineTotal', ol.line_total_cents / 100.0
        ) ORDER BY ol.line_number
    ) as lines
FROM orders o
LEFT JOIN order_line ol ON ol.order_id = o.id
WHERE o.id = :orderId
GROUP BY o.id;

-- Customer order history
SELECT o.order_number, o.order_date, o.status, o.grand_total_cents
FROM orders o
WHERE o.customer_id = :customerId
ORDER BY o.order_date DESC;

-- Orders pending payment
SELECT * FROM orders
WHERE status = 'Confirmed'
  AND confirmed_at < CURRENT_TIMESTAMP - INTERVAL '24 hours';

-- Sales by product
SELECT 
    ol.product_sku,
    ol.product_name,
    SUM(ol.quantity) as total_quantity,
    SUM(ol.line_total_cents) as total_revenue
FROM order_line ol
JOIN orders o ON o.id = ol.order_id
WHERE o.status NOT IN ('Draft', 'Cancelled')
  AND o.order_date BETWEEN :startDate AND :endDate
GROUP BY ol.product_sku, ol.product_name
ORDER BY total_revenue DESC;

Design Considerations

Snapshot vs Reference

Approach Pros Cons
Reference Always current data Historical orders show wrong data
Snapshot Historical accuracy Data duplication

Recommendation: Snapshot critical fields (name, SKU, price) at order time. Keep reference for navigation.

Monetary Values

// NEVER use floating point for money!
// Bad:  price = 19.99 (float)
// Good: priceInCents = 1999 (integer)

// Or use a Money type:
Money {
    amount: Integer  // In smallest unit (cents, grosze)
    currency: String // ISO code
}

Immutability After Confirmation

Once an order is confirmed:

  • Lines cannot be added/removed
  • Quantities cannot change
  • Prices are locked

For changes after confirmation:

  • Cancel and create new order
  • Or create adjustment/amendment record

Order vs Quote vs Invoice

Document Purpose Status Flow
Quote Price estimate Draft → Sent → Accepted/Expired
Order Customer purchase intent Draft → Confirmed → Paid...
Invoice Payment request Issued → Paid → Overdue

These can share structure but have different lifecycles.