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.
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_handenforced at DB level (INV-2: second safety net)versionfor optimistic locking — critical for concurrent reservationsstock_ledgeris 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):
InventoryItemaggregate root — scoped to (product × warehouse); ownsonHand,reserved, and the derivedavailableReservationentity — PENDING → CONFIRMED / CANCELLED lifecycle; embedded inside InventoryItemStockLedgerEntryentity — immutable, append-only audit trail; embedded inside InventoryItemStockTransferService— 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 setavailableincorrectly - 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:
- Draw the three-quantity model first (
onHand,reserved,available = onHand − reserved) — it is the domain - Name every invariant explicitly — the interviewer will push on edge cases, and your invariants are the answer
- Scope the aggregate to (product × warehouse) — not smaller (loses invariant protection), not larger (kills concurrency)
- Show the reservation lifecycle: PENDING → CONFIRMED / CANCELLED — without it, stock gets locked forever
- 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