← Back to list

Cracking the LLD Interview: Designing an Inventory Management System with Domain-Driven Design

Why “just track a count” is the wrong mental model — and how aggregate boundaries protect you from negative stock.

Shubham Patel · 2026-07-26 17:11 · 0 claps · 23.4 min read
#system-design-interview #low-level-design #backend-development #software-engineering #object-oriented-design
Open on Medium ↗
Wiki topics: BIZ · Business Strategy 🌐 · Web Development 🧠 · Mental Wellness

Cracking the LLD Interview: Designing an Inventory Management System with Domain-Driven Design

Why “just track a count” is the wrong mental model — and how aggregate boundaries protect you from negative stock.

Introduction

Inventory Management is one of the most underestimated LLD interview problems. On the surface, it sounds like a counter: add stock, subtract stock, show the number. But the moment an interviewer asks “What happens when two orders try to reserve the last item simultaneously?” or “How do you undo a reservation without going negative?” — the counter model collapses.

The real complexity is in concurrent reservations against a shared quantity, multi-warehouse transfers that must atomically debit and credit, and audit trails that reconstruct how stock reached its current level. These are consistency problems, not counting problems.

DDD is uniquely suited here because inventory management is dominated by invariants — stock can never go negative, reservations must reduce available quantity atomically, transfers must debit and credit in a single transaction. These invariants define aggregate boundaries naturally.

By the end of this article you will have a complete, interview-ready DDD design in Go: aggregates for inventory items and stock movements, a reservation model that prevents overselling, and a transfer domain service for cross-warehouse operations.

Problem Statement

Design an Inventory Management System that:

  • Tracks stock levels for products across multiple warehouses
  • Supports receiving new stock (inbound from suppliers)
  • Supports reserving stock for customer orders (without immediately reducing physical count)
  • Supports confirming or cancelling reservations
  • Supports stock adjustments (damage, shrinkage, corrections)
  • Supports transferring stock between warehouses
  • Provides real-time visibility into available, reserved, and total quantities
  • Maintains a full audit trail of every stock change

Real-world expectation: An e-commerce platform with multiple warehouses. When a customer places an order, stock is reserved (not immediately deducted). When the order ships, the reservation is confirmed and physical stock decreases. If the order is cancelled, the reservation is released and stock becomes available again.

Interview framing: The interviewer will probe the difference between physical stock, reserved stock, and available stock. They will ask about concurrent reservations, about what happens when a transfer fails halfway, and about how you audit every change. The 45-minute goal is a clean domain model with clearly enforced invariants.

Phase 1: Domain Discovery

Step 1 — Clarifying Questions

Q1: How many warehouses? (bounds for the model)
Q2: Is stock tracked per warehouse or globally?
Q3: Do we need lot/batch tracking (expiry dates, FIFO/LIFO)?
Q4: Is there a concept of "safety stock" (minimum threshold)?
Q5: Can a single order reserve stock from multiple warehouses?
Q6: Do we track unit cost for inventory valuation?
Q7: Is stock transfer between warehouses synchronous or async?
Q8: Do we need to support returns (inbound from customers)?

For a typical 60-minute LLD round, assume:

  • Multiple warehouses, stock tracked per product per warehouse
  • No lot/batch tracking (out of scope)
  • No safety stock thresholds
  • Single-warehouse reservations only (cross-warehouse reservation is out of scope)
  • No unit-cost tracking (out of scope for core design)
  • Transfers are synchronous within a single transaction

Step 2 — Requirements

Functional Requirements:

FR-1.  Receive stock into a warehouse for a given product (inbound)
FR-2.  Reserve a quantity of a product in a warehouse for an order
FR-3.  Confirm a reservation — reduces physical stock, removes reservation
FR-4.  Cancel a reservation — releases reserved quantity back to available
FR-5.  Adjust stock (correct errors, record damage/shrinkage)
FR-6.  Transfer stock from one warehouse to another
FR-7.  Query available quantity (= total on hand − reserved)
FR-8.  Query stock ledger — full history of all movements for audit

Non-Functional Requirements:

NFR-1. Two concurrent reservations must not oversell the last item
NFR-2. A transfer must debit source and credit destination atomically
NFR-3. Every stock change must be recorded as an immutable ledger entry
NFR-4. Adding new movement types (e.g., returns) must not require modifying existing aggregates

Out of Scope:

OOS-1. Lot/batch tracking and expiry management
OOS-2. Inventory valuation (FIFO/LIFO costing)
OOS-3. Purchase order management
OOS-4. Physical warehouse layout (zones, bins, aisles)
OOS-5. Barcode/RFID scanning integration

Step 3 — Business Rules (Invariants)

INV-1.  Total on-hand quantity can never be negative
INV-2.  Reserved quantity can never exceed on-hand quantity
INV-3.  Available quantity = on-hand − reserved  (always ≥ 0)
INV-4.  A reservation can only be created if available quantity ≥ requested quantity
INV-5.  A reservation can only be confirmed once
INV-6.  A reservation can only be cancelled if it is in PENDING status
INV-7.  A confirmed reservation reduces on-hand and removes reserved
INV-8.  A cancelled reservation reduces reserved and increases available
INV-9.  An adjustment can increase or decrease on-hand but not below zero
INV-10. Every mutation to stock must produce a StockLedgerEntry (immutable audit)
INV-11. A transfer must debit source on-hand and credit destination on-hand atomically

Step 4 — User Stories and Workflows

Step 5 — Edge Cases and Failure Scenarios

Step 6 — Ubiquitous Language

Phase 2: Domain Modeling

Step 7 — Nouns and Verbs Extraction

Candidate Nouns:

Candidate Verbs:

Step 8 — Entity vs Value Object Classification

Value Objects with behavior (not anemic):

// Quantity — Value Object
// Immutable, validated, carries arithmetic
type Quantity struct {
    value int
}

func NewQuantity(v int) (Quantity, error) {
    if v < 0 {
        return Quantity{}, ErrNegativeQuantity
    }
    return Quantity{value: v}, nil
}

func MustQuantity(v int) Quantity {
    q, err := NewQuantity(v)
    if err != nil { panic(err) }
    return q
}

func (q Quantity) Value() int                 { return q.value }
func (q Quantity) IsZero() bool               { return q.value == 0 }
func (q Quantity) Equal(other Quantity) bool   { return q.value == other.value }
func (q Quantity) GreaterOrEqual(other Quantity) bool { return q.value >= other.value }

func (q Quantity) Add(other Quantity) Quantity {
    return Quantity{value: q.value + other.value}
}

func (q Quantity) Subtract(other Quantity) (Quantity, error) {
    result := q.value - other.value
    if result < 0 {
        return Quantity{}, ErrInsufficientStock
    }
    return Quantity{value: result}, nil
}
// SKU - Value Object
type SKU struct {
    value string
}

func NewSKU(raw string) (SKU, error) {
    cleaned := strings.ToUpper(strings.TrimSpace(raw))
    if len(cleaned) < 2 || len(cleaned) > 50 {
        return SKU{}, ErrInvalidSKU
    }
    return SKU{value: cleaned}, nil
}

func (s SKU) Value() string              { return s.value }
func (s SKU) Equal(other SKU) bool       { return s.value == other.value }

Step 9 — Avoiding Anemic Models

// ❌ Anemic — InventoryItem is just a data bag
type InventoryItem struct {
    OnHand   int
    Reserved int
}

// All logic in a service class — fragile, untestable, invariant-blind
func reserveStock(item *InventoryItem, qty int) {
    item.Reserved += qty  // no check! INV-4 violated
}
// ✅ Rich model — InventoryItem enforces all quantity invariants internally
func (ii *InventoryItem) Reserve(
    resID ReservationID,
    orderRef string,
    qty Quantity,
) error {
    if qty.IsZero() {
        return ErrInvalidQuantity
    }
    available := ii.Available()
    if !available.GreaterOrEqual(qty) {
        return ErrInsufficientStock  // INV-4 enforced inside the entity
    }
    // ...reservation created, onHand unchanged, reserved increases
}

The rule: if a method needs to read on-hand and reserved to make a decision, it belongs on the entity that owns both fields. No service class should ever compute available = onHand - reserved externally.

Phase 3: Aggregate Design

Step 10 — The Core Insight: What Is an InventoryItem?

An InventoryItem is the combination of one product in one warehouse. It tracks three quantities — onHand, reserved, and the derived available — and owns the Reservation entities and StockLedgerEntry records for that product-warehouse pair.

This is the most important DDD decision in the entire system: InventoryItem is the aggregate root, and its scope is (product × warehouse). Not product-only (because the same product has different stock in different warehouses). Not warehouse-only (because warehouse-level locking would serialise all products).

Step 11 — Consistency Boundary Analysis

Step 12 — The Four Aggregate Rules

Rule 1: One root — InventoryItem is the only entry point for reservations and ledger entries
Rule 2: As small as possible — scoped to (product × warehouse), not entire warehouse
Rule 3: Cross-aggregate references by ID only — holds productID and warehouseID, never pointers
Rule 4: Optimistic concurrency — version field on InventoryItem prevents concurrent reservation races

Step 13 — Aggregate Implementations

Reservation Entity (Inside InventoryItem)

type ReservationID string
type ReservationStatus string

const (
    ReservationStatusPending   ReservationStatus = "PENDING"
    ReservationStatusConfirmed ReservationStatus = "CONFIRMED"
    ReservationStatusCancelled ReservationStatus = "CANCELLED"
)

type Reservation struct {
    id        ReservationID
    orderRef  string
    quantity  Quantity
    status    ReservationStatus
    createdAt time.Time
}

func newReservation(id ReservationID, orderRef string, qty Quantity) *Reservation {
    return &Reservation{
        id:        id,
        orderRef:  orderRef,
        quantity:  qty,
        status:    ReservationStatusPending,
        createdAt: time.Now(),
    }
}

func (r *Reservation) ID() ReservationID        { return r.id }
func (r *Reservation) OrderRef() string         { return r.orderRef }
func (r *Reservation) Quantity() Quantity        { return r.quantity }
func (r *Reservation) Status() ReservationStatus { return r.status }
func (r *Reservation) IsPending() bool           { return r.status == ReservationStatusPending }

StockLedgerEntry Entity (Inside InventoryItem — Immutable Audit Record)

type LedgerEntryID string

type MovementType string

const (
    MovementTypeInbound    MovementType = "INBOUND"
    MovementTypeOutbound   MovementType = "OUTBOUND"
    MovementTypeAdjustment MovementType = "ADJUSTMENT"
    MovementTypeTransferIn  MovementType = "TRANSFER_IN"
    MovementTypeTransferOut MovementType = "TRANSFER_OUT"
)

type StockLedgerEntry struct {
    id              LedgerEntryID
    movementType    MovementType
    quantityChange  int        // positive for inbound, negative for outbound/adjustment
    onHandAfter     int        // snapshot of on-hand after this change
    reservedAfter   int        // snapshot of reserved after this change
    reference       string     // order ID, PO number, adjustment reason
    occurredAt      time.Time
}

func newLedgerEntry(
    id LedgerEntryID,
    movType MovementType,
    qtyChange int,
    onHandAfter, reservedAfter int,
    reference string,
) *StockLedgerEntry {
    return &StockLedgerEntry{
        id:             id,
        movementType:   movType,
        quantityChange: qtyChange,
        onHandAfter:    onHandAfter,
        reservedAfter:  reservedAfter,
        reference:      reference,
        occurredAt:     time.Now(),
    }
}

InventoryItem Aggregate Root

type InventoryItemID string

type InventoryItem struct {
    id           InventoryItemID
    productID    ProductID      // cross-aggregate ref: ID only
    warehouseID  WarehouseID    // cross-aggregate ref: ID only
    sku          SKU
    onHand       Quantity       // total physical stock
    reserved     Quantity       // allocated to pending orders
    reservations []*Reservation
    ledger       []*StockLedgerEntry
    version      int            // optimistic concurrency
    events       []DomainEvent
}

func NewInventoryItem(
    id InventoryItemID,
    productID ProductID,
    warehouseID WarehouseID,
    sku SKU,
) *InventoryItem {
    return &InventoryItem{
        id:          id,
        productID:   productID,
        warehouseID: warehouseID,
        sku:         sku,
        onHand:      MustQuantity(0),
        reserved:    MustQuantity(0),
        version:     0,
    }
}

// Available — derived, never stored. INV-3: always ≥ 0
func (ii *InventoryItem) Available() Quantity {
    avail, _ := ii.onHand.Subtract(ii.reserved)
    return avail
}

// ReceiveStock — inbound: adds to on-hand (INV-1: quantity must be positive)
func (ii *InventoryItem) ReceiveStock(qty Quantity, reference string) error {
    if qty.IsZero() {
        return ErrInvalidQuantity
    }
    ii.onHand = ii.onHand.Add(qty)
    ii.recordLedger(MovementTypeInbound, qty.Value(), reference)
    ii.addEvent(StockReceived{
        InventoryItemID: ii.id,
        ProductID:       ii.productID,
        WarehouseID:     ii.warehouseID,
        Quantity:        qty,
        Reference:       reference,
        At:              time.Now(),
    })
    return nil
}

// Reserve — creates a reservation if sufficient available stock exists (INV-4)
func (ii *InventoryItem) Reserve(
    resID ReservationID,
    orderRef string,
    qty Quantity,
) error {
    if qty.IsZero() {
        return ErrInvalidQuantity
    }
    // INV-4: available must be ≥ requested quantity
    if !ii.Available().GreaterOrEqual(qty) {
        return fmt.Errorf(
            "%w: requested %d, available %d",
            ErrInsufficientStock, qty.Value(), ii.Available().Value(),
        )
    }
    // Check duplicate reservation ID
    if ii.findReservation(resID) != nil {
        return ErrDuplicateReservation
    }
    ii.reserved = ii.reserved.Add(qty)
    ii.reservations = append(ii.reservations, newReservation(resID, orderRef, qty))
    ii.recordLedger(MovementTypeOutbound, 0, orderRef) // reserved only, on-hand unchanged
    ii.addEvent(StockReserved{
        InventoryItemID: ii.id,
        ReservationID:   resID,
        OrderRef:        orderRef,
        Quantity:        qty,
        At:              time.Now(),
    })
    return nil
}

// ConfirmReservation — ships the order; reduces on-hand AND reserved (INV-5, INV-7)
func (ii *InventoryItem) ConfirmReservation(resID ReservationID) error {
    res := ii.findReservation(resID)
    if res == nil {
        return ErrReservationNotFound
    }
    if res.status == ReservationStatusConfirmed {
        return ErrReservationAlreadyConfirmed  // INV-5: idempotency guard
    }
    if res.status != ReservationStatusPending {
        return ErrReservationNotPending
    }
    qty := res.quantity
    var err error
    ii.onHand, err = ii.onHand.Subtract(qty)    // INV-1: enforced by Quantity.Subtract
    if err != nil {
        return err  // should not happen if reservations are consistent
    }
    ii.reserved, err = ii.reserved.Subtract(qty) // INV-2
    if err != nil {
        return err
    }
    res.status = ReservationStatusConfirmed
    ii.recordLedger(MovementTypeOutbound, -qty.Value(), res.orderRef)
    ii.addEvent(ReservationConfirmed{
        InventoryItemID: ii.id,
        ReservationID:   resID,
        Quantity:        qty,
        At:              time.Now(),
    })
    return nil
}

// CancelReservation — releases reserved stock back to available (INV-6, INV-8)
func (ii *InventoryItem) CancelReservation(resID ReservationID) error {
    res := ii.findReservation(resID)
    if res == nil {
        return ErrReservationNotFound
    }
    if !res.IsPending() {
        return ErrReservationNotPending  // INV-6
    }
    qty := res.quantity
    var err error
    ii.reserved, err = ii.reserved.Subtract(qty) // INV-8: decreases reserved
    if err != nil {
        return err
    }
    res.status = ReservationStatusCancelled
    ii.recordLedger(MovementTypeAdjustment, 0, "reservation_cancelled:"+res.orderRef)
    ii.addEvent(ReservationCancelled{
        InventoryItemID: ii.id,
        ReservationID:   resID,
        Quantity:        qty,
        At:              time.Now(),
    })
    return nil
}

// AdjustStock — corrections for damage, shrinkage, or physical count (INV-9)
func (ii *InventoryItem) AdjustStock(delta int, reason string) error {
    if delta == 0 {
        return ErrInvalidQuantity
    }
    if reason == "" {
        return ErrAdjustmentReasonRequired
    }
    newOnHand := ii.onHand.Value() + delta
    if newOnHand < 0 {
        return fmt.Errorf("%w: adjustment of %d would make on-hand %d",
            ErrInsufficientStock, delta, newOnHand)
    }
    // INV-2: after adjustment, reserved must not exceed new on-hand
    if ii.reserved.Value() > newOnHand {
        return fmt.Errorf("%w: on-hand after adjustment (%d) would be less than reserved (%d)",
            ErrInsufficientStock, newOnHand, ii.reserved.Value())
    }
    ii.onHand = MustQuantity(newOnHand)
    ii.recordLedger(MovementTypeAdjustment, delta, reason)
    ii.addEvent(StockAdjusted{
        InventoryItemID: ii.id,
        Delta:           delta,
        Reason:          reason,
        At:              time.Now(),
    })
    return nil
}

// DebitForTransfer — called by TransferService; reduces on-hand for outbound transfer
func (ii *InventoryItem) DebitForTransfer(qty Quantity, transferRef string) error {
    if qty.IsZero() {
        return ErrInvalidQuantity
    }
    if !ii.Available().GreaterOrEqual(qty) {
        return ErrInsufficientStock
    }
    var err error
    ii.onHand, err = ii.onHand.Subtract(qty)
    if err != nil {
        return err
    }
    ii.recordLedger(MovementTypeTransferOut, -qty.Value(), transferRef)
    ii.addEvent(StockTransferredOut{
        InventoryItemID: ii.id,
        Quantity:        qty,
        TransferRef:     transferRef,
        At:              time.Now(),
    })
    return nil
}

// CreditFromTransfer — called by TransferService; adds on-hand for inbound transfer
func (ii *InventoryItem) CreditFromTransfer(qty Quantity, transferRef string) error {
    if qty.IsZero() {
        return ErrInvalidQuantity
    }
    ii.onHand = ii.onHand.Add(qty)
    ii.recordLedger(MovementTypeTransferIn, qty.Value(), transferRef)
    ii.addEvent(StockTransferredIn{
        InventoryItemID: ii.id,
        Quantity:        qty,
        TransferRef:     transferRef,
        At:              time.Now(),
    })
    return nil
}

// ── Accessors ─────────────────────────────────────────────────────────────

func (ii *InventoryItem) ID() InventoryItemID    { return ii.id }
func (ii *InventoryItem) ProductID() ProductID   { return ii.productID }
func (ii *InventoryItem) WarehouseID() WarehouseID { return ii.warehouseID }
func (ii *InventoryItem) SKU() SKU               { return ii.sku }
func (ii *InventoryItem) OnHand() Quantity        { return ii.onHand }
func (ii *InventoryItem) Reserved() Quantity      { return ii.reserved }
func (ii *InventoryItem) Version() int            { return ii.version }
func (ii *InventoryItem) Ledger() []*StockLedgerEntry { return ii.ledger }
func (ii *InventoryItem) Reservations() []*Reservation { return ii.reservations }

func (ii *InventoryItem) DomainEvents() []DomainEvent {
    events := ii.events; ii.events = nil; return events
}

// ── Internal helpers ──────────────────────────────────────────────────────

func (ii *InventoryItem) findReservation(id ReservationID) *Reservation {
    for _, r := range ii.reservations {
        if r.id == id { return r }
    }
    return nil
}

func (ii *InventoryItem) recordLedger(movType MovementType, qtyChange int, ref string) {
    entry := newLedgerEntry(
        LedgerEntryID(uuid.New().String()),
        movType, qtyChange,
        ii.onHand.Value(), ii.reserved.Value(),
        ref,
    )
    ii.ledger = append(ii.ledger, entry)
}

func (ii *InventoryItem) addEvent(e DomainEvent) { ii.events = append(ii.events, e) }

Step 14 — Domain Events

type DomainEvent interface {
    EventName() string
    OccurredAt() time.Time
}
type StockReceived struct {
    InventoryItemID InventoryItemID; ProductID ProductID
    WarehouseID WarehouseID; Quantity Quantity
    Reference string; At time.Time
}
func (e StockReceived) EventName() string    { return "inventory.stock_received" }
func (e StockReceived) OccurredAt() time.Time { return e.At }
type StockReserved struct {
    InventoryItemID InventoryItemID; ReservationID ReservationID
    OrderRef string; Quantity Quantity; At time.Time
}
func (e StockReserved) EventName() string    { return "inventory.stock_reserved" }
func (e StockReserved) OccurredAt() time.Time { return e.At }
type ReservationConfirmed struct {
    InventoryItemID InventoryItemID; ReservationID ReservationID
    Quantity Quantity; At time.Time
}
func (e ReservationConfirmed) EventName() string    { return "inventory.reservation_confirmed" }
func (e ReservationConfirmed) OccurredAt() time.Time { return e.At }
type ReservationCancelled struct {
    InventoryItemID InventoryItemID; ReservationID ReservationID
    Quantity Quantity; At time.Time
}
func (e ReservationCancelled) EventName() string    { return "inventory.reservation_cancelled" }
func (e ReservationCancelled) OccurredAt() time.Time { return e.At }
type StockAdjusted struct {
    InventoryItemID InventoryItemID; Delta int
    Reason string; At time.Time
}
func (e StockAdjusted) EventName() string    { return "inventory.stock_adjusted" }
func (e StockAdjusted) OccurredAt() time.Time { return e.At }
type StockTransferredOut struct {
    InventoryItemID InventoryItemID; Quantity Quantity
    TransferRef string; At time.Time
}
func (e StockTransferredOut) EventName() string    { return "inventory.stock_transferred_out" }
func (e StockTransferredOut) OccurredAt() time.Time { return e.At }
type StockTransferredIn struct {
    InventoryItemID InventoryItemID; Quantity Quantity
    TransferRef string; At time.Time
}
func (e StockTransferredIn) EventName() string    { return "inventory.stock_transferred_in" }
func (e StockTransferredIn) OccurredAt() time.Time { return e.At }

Step 15 — Domain Service: Stock Transfer

Transfers operate across two InventoryItem aggregates (source and destination). No single aggregate can enforce INV-11 alone. This is the textbook case for a Domain Service.

// StockTransferService — Domain Service
// Cross-aggregate, stateless; debits source and credits destination
type StockTransferService struct{}

func NewStockTransferService() *StockTransferService {
    return &StockTransferService{}
}

// Transfer — debits source, credits destination within a single call
// The caller (application service) must ensure both are saved in one transaction
func (s *StockTransferService) Transfer(
    source *InventoryItem,
    destination *InventoryItem,
    qty Quantity,
    transferRef string,
) error {
    if source.ID() == destination.ID() {
        return ErrCannotTransferToSelf
    }
    if !source.ProductID().Equal(destination.ProductID()) {
        return ErrProductMismatch
    }
    // Debit source (checks available ≥ qty, reduces on-hand)
    if err := source.DebitForTransfer(qty, transferRef); err != nil {
        return fmt.Errorf("source debit failed: %w", err)
    }
    // Credit destination (increases on-hand)
    if err := destination.CreditFromTransfer(qty, transferRef); err != nil {
        return fmt.Errorf("destination credit failed: %w", err)
    }
    return nil
}

Phase 4: Bounded Contexts

Step 16 — Context Map

Integration Patterns:

Phase 5: Application Service Design

Step 17 — Repository Interface

// domain/inventory/repository.go
type InventoryItemRepository interface {
    FindByID(ctx context.Context, id InventoryItemID) (*InventoryItem, error)
    FindByProductAndWarehouse(ctx context.Context, productID ProductID, warehouseID WarehouseID) (*InventoryItem, error)
    Save(ctx context.Context, item *InventoryItem) error
}

Step 18 — DTOs

// application/dto/inventory_dto.go

type ReceiveStockCommand struct {
    ProductID   string `json:"productId"`
    WarehouseID string `json:"warehouseId"`
    SKU         string `json:"sku"`
    Quantity    int    `json:"quantity"`
    Reference   string `json:"reference"` // PO number, supplier ref
}

type ReserveStockCommand struct {
    ProductID     string `json:"productId"`
    WarehouseID   string `json:"warehouseId"`
    ReservationID string `json:"reservationId"`
    OrderRef      string `json:"orderRef"`
    Quantity      int    `json:"quantity"`
}

type ConfirmReservationCommand struct {
    ProductID     string `json:"productId"`
    WarehouseID   string `json:"warehouseId"`
    ReservationID string `json:"reservationId"`
}

type CancelReservationCommand struct {
    ProductID     string `json:"productId"`
    WarehouseID   string `json:"warehouseId"`
    ReservationID string `json:"reservationId"`
}

type AdjustStockCommand struct {
    ProductID   string `json:"productId"`
    WarehouseID string `json:"warehouseId"`
    Delta       int    `json:"delta"`    // positive or negative
    Reason      string `json:"reason"`
}

type TransferStockCommand struct {
    ProductID       string `json:"productId"`
    SourceWarehouse string `json:"sourceWarehouseId"`
    DestWarehouse   string `json:"destWarehouseId"`
    Quantity        int    `json:"quantity"`
    TransferRef     string `json:"transferRef"`
}

type StockLevelResponse struct {
    ProductID   string `json:"productId"`
    WarehouseID string `json:"warehouseId"`
    SKU         string `json:"sku"`
    OnHand      int    `json:"onHand"`
    Reserved    int    `json:"reserved"`
    Available   int    `json:"available"`
}

Step 19 — Application Service

type InventoryApplicationService struct {
    repo            InventoryItemRepository
    transferService *StockTransferService
    eventBus        EventBus
    idGen           IDGenerator
}

// ReceiveStock — use case: warehouse receives a shipment
func (s *InventoryApplicationService) ReceiveStock(
    ctx context.Context, cmd ReceiveStockCommand,
) error {
    qty, err := NewQuantity(cmd.Quantity)
    if err != nil { return err }
    sku, err := NewSKU(cmd.SKU)
    if err != nil { return err }

    item, err := s.findOrCreateItem(ctx, cmd.ProductID, cmd.WarehouseID, sku)
    if err != nil { return err }

    if err := item.ReceiveStock(qty, cmd.Reference); err != nil { return err }

    if err := s.repo.Save(ctx, item); err != nil { return err }

    s.publishEvents(ctx, item)
    return nil
}

// ReserveStock — use case: order service reserves inventory
func (s *InventoryApplicationService) ReserveStock(
    ctx context.Context, cmd ReserveStockCommand,
) error {
    qty, err := NewQuantity(cmd.Quantity)
    if err != nil { return err }

    item, err := s.repo.FindByProductAndWarehouse(
        ctx, ProductID(cmd.ProductID), WarehouseID(cmd.WarehouseID),
    )
    if err != nil { return err }

    if err := item.Reserve(
        ReservationID(cmd.ReservationID), cmd.OrderRef, qty,
    ); err != nil {
        return err
    }

    if err := s.repo.Save(ctx, item); err != nil { return err }

    s.publishEvents(ctx, item)
    return nil
}

// ConfirmReservation — use case: order ships; physical stock decreases
func (s *InventoryApplicationService) ConfirmReservation(
    ctx context.Context, cmd ConfirmReservationCommand,
) error {
    item, err := s.repo.FindByProductAndWarehouse(
        ctx, ProductID(cmd.ProductID), WarehouseID(cmd.WarehouseID),
    )
    if err != nil { return err }

    if err := item.ConfirmReservation(ReservationID(cmd.ReservationID)); err != nil {
        return err
    }

    if err := s.repo.Save(ctx, item); err != nil { return err }

    s.publishEvents(ctx, item)
    return nil
}

// CancelReservation — use case: order cancelled; stock released
func (s *InventoryApplicationService) CancelReservation(
    ctx context.Context, cmd CancelReservationCommand,
) error {
    item, err := s.repo.FindByProductAndWarehouse(
        ctx, ProductID(cmd.ProductID), WarehouseID(cmd.WarehouseID),
    )
    if err != nil { return err }

    if err := item.CancelReservation(ReservationID(cmd.ReservationID)); err != nil {
        return err
    }

    if err := s.repo.Save(ctx, item); err != nil { return err }

    s.publishEvents(ctx, item)
    return nil
}

// AdjustStock — use case: physical count correction
func (s *InventoryApplicationService) AdjustStock(
    ctx context.Context, cmd AdjustStockCommand,
) error {
    item, err := s.repo.FindByProductAndWarehouse(
        ctx, ProductID(cmd.ProductID), WarehouseID(cmd.WarehouseID),
    )
    if err != nil { return err }

    if err := item.AdjustStock(cmd.Delta, cmd.Reason); err != nil { return err }

    if err := s.repo.Save(ctx, item); err != nil { return err }

    s.publishEvents(ctx, item)
    return nil
}

// TransferStock — use case: move stock between warehouses (INV-11)
func (s *InventoryApplicationService) TransferStock(
    ctx context.Context, cmd TransferStockCommand,
) error {
    qty, err := NewQuantity(cmd.Quantity)
    if err != nil { return err }

    source, err := s.repo.FindByProductAndWarehouse(
        ctx, ProductID(cmd.ProductID), WarehouseID(cmd.SourceWarehouse),
    )
    if err != nil { return err }

    dest, err := s.repo.FindByProductAndWarehouse(
        ctx, ProductID(cmd.ProductID), WarehouseID(cmd.DestWarehouse),
    )
    if err != nil { return err }

    // Domain service coordinates two aggregates
    if err := s.transferService.Transfer(source, dest, qty, cmd.TransferRef); err != nil {
        return err
    }

    // Both must be saved in the same DB transaction (unit of work)
    if err := s.repo.Save(ctx, source); err != nil { return err }
    if err := s.repo.Save(ctx, dest); err != nil { return err }

    s.publishEvents(ctx, source)
    s.publishEvents(ctx, dest)
    return nil
}

// GetStockLevel — use case: query current availability
func (s *InventoryApplicationService) GetStockLevel(
    ctx context.Context, productID, warehouseID string,
) (*StockLevelResponse, error) {
    item, err := s.repo.FindByProductAndWarehouse(
        ctx, ProductID(productID), WarehouseID(warehouseID),
    )
    if err != nil { return nil, err }

    return &StockLevelResponse{
        ProductID:   string(item.ProductID()),
        WarehouseID: string(item.WarehouseID()),
        SKU:         item.SKU().Value(),
        OnHand:      item.OnHand().Value(),
        Reserved:    item.Reserved().Value(),
        Available:   item.Available().Value(),
    }, nil
}

// ── Helpers ──────────────────────────────────────────────────────────────

func (s *InventoryApplicationService) findOrCreateItem(
    ctx context.Context, productID, warehouseID string, sku SKU,
) (*InventoryItem, error) {
    item, err := s.repo.FindByProductAndWarehouse(
        ctx, ProductID(productID), WarehouseID(warehouseID),
    )
    if err == nil {
        return item, nil
    }
    // Create new InventoryItem if none exists for this product × warehouse
    newItem := NewInventoryItem(
        InventoryItemID(s.idGen.NewID()),
        ProductID(productID),
        WarehouseID(warehouseID),
        sku,
    )
    return newItem, nil
}

func (s *InventoryApplicationService) publishEvents(ctx context.Context, item *InventoryItem) {
    for _, ev := range item.DomainEvents() {
        s.eventBus.Publish(ctx, ev)
    }
}

Step 20 — API Endpoints

POST   /inventory/receive                             → ReceiveStock
POST   /inventory/reserve                             → ReserveStock
POST   /inventory/reservations/{id}/confirm           → ConfirmReservation
POST   /inventory/reservations/{id}/cancel            → CancelReservation
POST   /inventory/adjust                              → AdjustStock
POST   /inventory/transfer                            → TransferStock
GET    /inventory/stock?productId=X&warehouseId=Y     → GetStockLevel
GET    /inventory/ledger?productId=X&warehouseId=Y    → GetLedger (audit trail)

Phase 6: LLD Conversion

Step 21 — Class Diagram

Step 22 — Database Schema

CREATE TABLE inventory_items (
    id           UUID PRIMARY KEY,
    product_id   UUID NOT NULL,
    warehouse_id UUID NOT NULL,
    sku          VARCHAR(50) NOT NULL,
    on_hand      INT NOT NULL DEFAULT 0 CHECK (on_hand >= 0),
    reserved     INT NOT NULL DEFAULT 0 CHECK (reserved >= 0),
    version      INT NOT NULL DEFAULT 0,       -- optimistic concurrency
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE(product_id, warehouse_id),          -- one InventoryItem per product per warehouse
    CONSTRAINT chk_reserved_le_onhand CHECK (reserved <= on_hand)  -- INV-2
);

CREATE TABLE reservations (
    id                UUID PRIMARY KEY,
    inventory_item_id UUID NOT NULL REFERENCES inventory_items(id),
    order_ref         VARCHAR(100) NOT NULL,
    quantity          INT NOT NULL CHECK (quantity > 0),
    status            VARCHAR(15) NOT NULL DEFAULT 'PENDING',
    created_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT chk_status CHECK (status IN ('PENDING','CONFIRMED','CANCELLED'))
);

CREATE TABLE stock_ledger (
    id                UUID PRIMARY KEY,
    inventory_item_id UUID NOT NULL REFERENCES inventory_items(id),
    movement_type     VARCHAR(15) NOT NULL,
    quantity_change   INT NOT NULL,
    on_hand_after     INT NOT NULL,
    reserved_after    INT NOT NULL,
    reference         VARCHAR(255),
    occurred_at       TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_inventory_product_warehouse ON inventory_items(product_id, warehouse_id);
CREATE INDEX idx_reservations_item ON reservations(inventory_item_id) WHERE status = 'PENDING';
CREATE INDEX idx_ledger_item ON stock_ledger(inventory_item_id, occurred_at);

Schema decisions:

  • reserved <= on_hand enforced at DB level (INV-2: second safety net)
  • version for optimistic locking — critical for concurrent reservations
  • stock_ledger is append-only and never updated (immutable audit trail)
  • UNIQUE(product_id, warehouse_id) enforces the aggregate identity constraint

Step 23 — Sequence Diagrams

Reserving Stock for an Order

Stock Transfer Between Warehouses

Step 24 — Error Catalog

var (
    // INV-1, INV-4, INV-9
    ErrInsufficientStock          = errors.New("insufficient stock for this operation")

    // INV-5
    ErrReservationAlreadyConfirmed = errors.New("reservation has already been confirmed")

    // INV-6
    ErrReservationNotPending       = errors.New("reservation is not in PENDING status")

    // Reservation lifecycle
    ErrReservationNotFound         = errors.New("reservation not found")
    ErrDuplicateReservation        = errors.New("a reservation with this ID already exists")

    // Input validation
    ErrInvalidQuantity             = errors.New("quantity must be positive")
    ErrNegativeQuantity            = errors.New("quantity cannot be negative")
    ErrInvalidSKU                  = errors.New("invalid SKU format")
    ErrAdjustmentReasonRequired    = errors.New("adjustment reason is required for audit")

    // Transfer
    ErrCannotTransferToSelf        = errors.New("source and destination must differ")
    ErrProductMismatch             = errors.New("source and destination must be the same product")

    // Concurrency
    ErrConcurrentModification      = errors.New("inventory item was modified concurrently — retry")
)

Step 25 — Folder Structure

inventory-system/
├── domain/
│   ├── inventory/
│   │   ├── inventory_item.go        # InventoryItem aggregate root
│   │   ├── reservation.go           # Reservation entity (inside aggregate)
│   │   ├── stock_ledger_entry.go    # StockLedgerEntry entity (inside aggregate)
│   │   ├── quantity.go              # Quantity value object
│   │   ├── sku.go                   # SKU value object
│   │   ├── events.go               # All domain events
│   │   └── repository.go           # InventoryItemRepository interface
│   ├── transfer/
│   │   └── transfer_service.go     # StockTransferService domain service
│   └── errors.go                   # Domain error catalog
│
├── application/
│   ├── inventory_service.go        # ReceiveStock, ReserveStock, Confirm, Cancel, Adjust, Transfer
│   ├── ports/
│   │   └── event_bus.go            # EventBus interface
│   └── dto/
│       └── inventory_dto.go        # Commands and Responses
│
├── infrastructure/
│   ├── postgres/
│   │   ├── inventory_repo.go       # InventoryItemRepository → SQL
│   │   └── migrations/
│   │       └── 001_initial.sql
│   ├── memory/
│   │   └── inventory_repo.go       # In-memory for tests
│   └── events/
│       └── in_memory_bus.go
│
└── presentation/
    └── http/
        ├── inventory_handler.go
        └── router.go

Design Patterns Used

1. Repository Pattern

InventoryItemRepository is defined in the domain and implemented in infrastructure. The domain never knows about PostgreSQL. Unit tests use InMemoryInventoryItemRepository.

2. Factory Pattern

NewInventoryItem(...) and newReservation(...) are factory functions. Invalid InventoryItems and Reservations cannot exist — the factory validates on construction. No public constructor bypasses validation.

3. Strategy Pattern — Transfer Coordination

While not a formal Strategy interface, StockTransferService acts as the coordination strategy for cross-aggregate operations. If the system later needs different transfer modes (immediate vs. batched, intra-company vs. inter-company), a TransferStrategy interface can be extracted.

4. Event Sourcing (Partial) — Ledger as Append-Only Log

The StockLedgerEntry list inside InventoryItem is a write-ahead audit log. Every mutation appends a new entry. The current onHand and reserved could technically be reconstructed from the ledger alone (event sourcing). In this design we maintain both: the materialised state (onHand, reserved) for fast queries and the ledger for auditability.

5. Observer / Event Pattern

Domain events (StockReceived, StockReserved, ReservationConfirmed) are published after persistence. The Order Fulfillment context subscribes to StockReserved to update order status. The Reporting context subscribes to all events to build dashboards.

Important Flows

Flow 1: Receive → Reserve → Confirm (Full Lifecycle)

1. Warehouse receives 100 units of LAPTOP-PRO-15
   → ReceiveStock: onHand=100, reserved=0, available=100
   → Ledger entry: INBOUND +100

2. Customer orders 2 units
   → ReserveStock: onHand=100, reserved=2, available=98
   → Reservation(ID=R1, PENDING, qty=2)
   → Ledger entry: reserved updated

3. Order ships
   → ConfirmReservation(R1): onHand=98, reserved=0, available=98
   → Reservation(R1, CONFIRMED)
   → Ledger entry: OUTBOUND -2

Flow 2: Reserve → Cancel

1. Customer orders 5 units → reserved=5
2. Customer cancels order → CancelReservation
   → onHand unchanged, reserved=0, available restores
   → Reservation(R2, CANCELLED)

Flow 3: Stock Transfer

1. Manager transfers 50 units from WH-1 to WH-2
2. TransferService.Transfer():
   → Source:  DebitForTransfer(50)  → onHand 200→150, ledger: TRANSFER_OUT -50
   → Dest:   CreditFromTransfer(50) → onHand 100→150, ledger: TRANSFER_IN +50
3. Both saved in single transaction

Flow 4: Adjustment for Damage

1. Physical count reveals 3 items damaged in WH-1
2. AdjustStock(delta=-3, reason="damage inspection 2024-01-15")
   → onHand 100→97, reserved unchanged (if reserved ≤ 97)
   → Ledger entry: ADJUSTMENT -3, reason captured

Handling Edge Cases

Concurrency and Consistency

The Critical Race Condition

Two order services simultaneously call ReserveStock for the same product in the same warehouse. Only 5 units available. Both read available=5. Both try to reserve 3. Without protection, both succeed, reserving 6 out of 5 — overselling.

Solution: Optimistic Locking on InventoryItem

UPDATE inventory_items
SET    on_hand = $1, reserved = $2, version = version + 1
WHERE  id = $3 AND version = $4;
-- rows_affected = 0 → concurrent modification → retry

The application service retries with a fresh load:

const maxRetries = 3

func (s *InventoryApplicationService) ReserveStock(ctx context.Context, cmd ReserveStockCommand) error {
    for attempt := 0; attempt < maxRetries; attempt++ {
        if err := s.tryReserveStock(ctx, cmd); err != nil {
            if errors.Is(err, ErrConcurrentModification) {
                time.Sleep(time.Duration(attempt*10) * time.Millisecond)
                continue
            }
            return err  // non-retriable error
        }
        return nil  // success
    }
    return ErrConcurrentModification
}

Transfer Atomicity (INV-11)

The TransferStock application service loads both InventoryItems, calls StockTransferService.Transfer() to mutate both in memory, then saves both within a single database transaction. If either save fails, the entire transaction rolls back — no money-in-transit problem.

Scalability Considerations

Aggregate granularity advantage: Because InventoryItem is scoped to (product × warehouse), two concurrent operations on different products in the same warehouse never contend. Only the same product in the same warehouse requires optimistic lock contention.

Read-heavy availability queries: The GET /inventory/stock query reads onHand, reserved, and computes available in-memory. At scale, maintain a denormalized read model (product_stock_view) updated via StockReceived, StockReserved, and ReservationConfirmed events.

Ledger growth: The stock_ledger table grows with every operation. Partition by occurred_at month. Old partitions can be archived to cold storage. The current onHand/reserved on inventory_items is always correct without replaying the ledger.

Multi-warehouse reservation: Currently out of scope. At scale, implement a CrossWarehouseReservationSaga that reserves across warehouses sequentially, with compensation (cancel) on partial failure.

Common Interview Follow-up Questions

Q: Why not track available as a stored field alongside onHand and reserved? Because available = onHand − reserved is always derivable. Storing it means three fields must stay in sync — a triple-write problem. Deriving it is O(1) subtraction. Fewer fields to persist means fewer consistency bugs.

Q: What if we need partial reservations (reserve 3 out of requested 5)? Add a PartialReservationPolicy that creates a reservation for min(requested, available). The aggregate method returns the actual reserved quantity rather than an error. The order service handles the shortfall.

Q: How would you support lot/batch tracking (FIFO expiry)? Add a Lot entity inside InventoryItem with lotNumber, expiryDate, and quantity. Reserve selects lots FIFO by expiry. ConfirmReservation decrements the specific lot. The aggregate root still owns all lots — the invariant boundary doesn't change.

Q: How do you handle returns (customer ships item back)? Add a ReceiveReturn(qty, orderRef, reason) method on InventoryItem. It increases onHand and records a RETURN movement type in the ledger. Functionally identical to ReceiveStock but with a different movement type for audit clarity.

Q: What if the order service sends a duplicate ReserveStock command? The application service passes reservationIDfrom the order service. InventoryItem.Reserve() checks for existing reservations with that ID and returns ErrDuplicateReservation. This makes the operation idempotent at the domain level.

Q: Should StockLedgerEntry be its own aggregate? No. A ledger entry has no independent lifecycle — it is always created as part of a stock mutation on InventoryItem. It cannot be updated or deleted independently. It exists only inside the aggregate.

Mistakes to Avoid

1. Making “Stock” a global singleton A global Stock object that tracks all products across all warehouses is a concurrency bottleneck. Every reservation locks the entire inventory. Scope the aggregate to (product × warehouse) for maximum parallelism.

2. Confusing on-hand with available On-hand is the physical count. Available is on-hand minus reserved. Reserving stock does not change on-hand — the item is still physically in the warehouse. Confusing these leads to phantom stock (showing 0 available while items are physically present).

3. Storing available as a separate field Three fields that must always satisfy available = onHand - reserved is a consistency trap. Derive available instead.

4. Skipping the audit trail An inventory system without a ledger is like accounting without a general journal. Every change must be recorded with timestamp, type, quantity delta, and reference. Make StockLedgerEntry part of every mutation.

5. Putting transfer logic in a single InventoryItem An InventoryItem cannot debit itself and credit another InventoryItem — it doesn’t have a reference to the other aggregate. Cross-aggregate coordination belongs in a Domain Service.

6. Not handling the reservation lifecycle (PENDING → CONFIRMED / CANCELLED) Reserving stock without a confirm/cancel path means stock is locked forever. The reservation must have a clear lifecycle with explicit transitions.

Final Design Summary

The Inventory Management System design delivers one aggregate root, two internal entities, a domain service for cross-aggregate transfers, and a full audit trail:

Inventory Management (Core Domain):

  • InventoryItem aggregate root — scoped to (product × warehouse); owns onHand, reserved, and the derived available
  • Reservation entity — PENDING → CONFIRMED / CANCELLED lifecycle; embedded inside InventoryItem
  • StockLedgerEntry entity — immutable, append-only audit trail; embedded inside InventoryItem
  • StockTransferService — domain service for cross-warehouse transfers

The design is clean because:

  • The three-quantity model (onHand, reserved, available) is fully encapsulated inside the aggregate — no external code can compute or set available incorrectly
  • Every mutation atomically produces a ledger entry (INV-10), giving complete audit traceability
  • Reservations prevent overselling while keeping physical stock unchanged until shipment — the distinction between “promised” and “shipped” is explicitly modeled
  • Cross-warehouse transfers are coordinated by a stateless domain service, not by one aggregate reaching into another
  • The aggregate’s (product × warehouse) granularity maximises concurrency — operations on different products in the same warehouse never contend

Conclusion

Inventory management is not a counting problem — it’s a consistency problem. The moment you separate on-hand from reserved from available, you create a three-variable invariant that must hold through concurrent reservations, transfers, adjustments, and confirmations. That invariant is the reason InventoryItem exists as an aggregate root.

DDD forces you to ask the question that most candidates skip: “What must be consistent together in a single transaction?” For inventory, the answer is: on-hand, reserved, and every reservation against them. That cluster — one product, one warehouse, all its reservations, and its ledger — is the aggregate. Everything outside (other products, other warehouses, product metadata) is a separate concern.

Practical takeaway for your next interview:

  1. Draw the three-quantity model first (onHand, reserved, available = onHand − reserved) — it is the domain
  2. Name every invariant explicitly — the interviewer will push on edge cases, and your invariants are the answer
  3. Scope the aggregate to (product × warehouse) — not smaller (loses invariant protection), not larger (kills concurrency)
  4. Show the reservation lifecycle: PENDING → CONFIRMED / CANCELLED — without it, stock gets locked forever
  5. Use a Domain Service for transfers — one aggregate can’t reach into another

Tags: #SystemDesign #LLD #DomainDrivenDesign #Golang #InventoryManagement #SoftwareArchitecture #InterviewPrep#BackendEngineering


메타데이터
post_id
aa8f030a018d
slug
cracking-the-lld-interview-designing-an-inventory-management-system-with-domain-driven-design-aa8f030a018d
url
https://medium.com/@shubham.patel191295/cracking-the-lld-interview-designing-an-inventory-management-system-with-domain-driven-design-aa8f030a018d
canonical_url
https://medium.com/@shubham.patel191295/cracking-the-lld-interview-designing-an-inventory-management-system-with-domain-driven-design-aa8f030a018d
author_url
https://medium.com/@shubham.patel191295
status
ok
fetched_at
2026-08-11 18:06:04