Engineering

My Accidental Journey into Logistics: How DDD Saved Our OMS Project

When Your Spec is a Fantasy Novel

It started, as most technical disasters do, with a beautifully optimistic requirements document. Our PM had been tasked with building an Order Management System despite, by their own admission, knowing "nothing about logistics." The document was filled with lovely but terrifyingly vague terms like "order processing," "fulfillment," and "shipment tracking."

To an engineer, this is a minefield. What's the difference between "fulfilled" and "shipped"? Does "processing" happen before or after payment confirmation? Who is the source of truth for inventory? We did what any team under pressure does: we guessed.

We built an Order model that quickly became a god object. 2000 lines of TypeScript holding every possible state and property. Fragile, impossible to test. A new bug appeared for every one we fixed. Friday deployments were followed by Saturday morning hotfixes.

Our initial architecture looked like this, a pattern I'm sure many will recognize with a slight shudder:

// The "Before" — a data bag with no rules.
export class AnemicOrder {
  id: string;
  status: 'new' | 'paid' | 'fulfilled' | 'shipped' | 'cancelled';
  items: any[];
  shippingAddress: string;
  totalPrice: number;
  // ...20 other properties
}
 
class OrderService {
  public shipOrder(order: AnemicOrder) {
    // Can we ship a 'new' order? A 'cancelled' one? Who knows!
    if (order.status === 'paid') {
      order.status = 'shipped'; // Direct mutation. Scary.
    } else {
      // throw new Error('Maybe this is not allowed?');
    }
  }
}

This code isn't just bad; it's a liability. The Order object has the structural integrity of a wet paper bag. It can't protect itself.

Learning to speak "logistics"

We couldn't add new features without breaking three others. The turning point came during a painful retrospective. The problem wasn't our code. It was our understanding. We, the engineers, didn't understand logistics. The PM didn't understand the technical implications of their requirements. Different languages.

This is what Domain-Driven Design was built for. It's not about complex patterns or fancy frameworks. Two ideas:

  1. Ubiquitous Language: The team agrees on a single, precise vocabulary. "Shipped" means the same thing in the code, in the database, and in the PM's head.
  2. Bounded Contexts: Break the domain into smaller sub-domains. "Inventory Management" is a different world from "Billing" and should be treated that way in the code.

We threw out our assumptions and spent a week whiteboarding with the PM and a consultant from a 3PL warehouse. No code written. Just talking. Defining terms. Mapping the lifecycle of an order from the moment a customer clicks "buy" to the moment a package lands on their doorstep.

It was slow and sometimes frustrating. But it changed everything.

An aggregate that fights back

Armed with a shared language, we refactored. We burned the god object and introduced an Order Aggregate. In DDD, an Aggregate is a cluster of objects treated as a single unit. The Aggregate Root (the Order class) is the only entry point. Its job is to enforce invariants.

Our new Order wasn't a passive data bag. It was an active participant in its own lifecycle. It learned to say "no."

export class Order {
  private readonly id: OrderId;
  private status: OrderStatus;
  private items: OrderItem[];
 
  constructor(id: OrderId, items: OrderItem[]) {
    this.status = OrderStatus.AWAITING_PAYMENT;
  }
 
  public pay(): void {
    if (this.status !== OrderStatus.AWAITING_PAYMENT) {
      throw new BusinessRuleViolationError('Order cannot be paid for in its current state.');
    }
    this.status = OrderStatus.PAID;
  }
 
  public ship(): void {
    if (this.status !== OrderStatus.PAID) {
      throw new BusinessRuleViolationError('Cannot ship an unpaid order.');
    }
    this.status = OrderStatus.SHIPPED;
  }
 
  // No public setters. State changes only through explicit business methods.
}

The business logic now lives with the data it operates on. You can't accidentally put the Order into an invalid state. The OrderService got much thinner, reduced to orchestration: fetch the aggregate, call a method, save it back.

We went a step further with lightweight CQRS. The write side used rich DDD models. The read side used simple, denormalized DTOs for fast dashboard queries. Read models were fast and dumb. Write models were smart and deliberate.

Code is a conversation

Building that OMS taught me something that's stuck with me: the hardest part of software engineering isn't writing code. It's understanding the problem.

Before you write a single line of a complex system, lock your team in a room and don't let them out until everyone uses the same words for the same concepts. That project turned a PM who knew nothing about logistics into a domain expert. And it turned a team of code-writing engineers into developers who built a model of a business process.

Bug count plummeted. Feature development accelerated. My only regret is that we didn't do it sooner.