Skip to content

DynamoDB Single-Table Pattern: Inventory Management

Inventory management has one non-negotiable constraint: stock updates must be atomic. If 10 customers try to buy the last item simultaneously, exactly one should succeed. The other 9 should see “out of stock.”

In a relational database, you’d use SELECT FOR UPDATE or optimistic locking. In DynamoDB, you use atomic updates with condition expressions - the same technique the Booking/Scheduling pattern uses to prevent double-bookings - and they’re actually simpler and more reliable for this use case.

This pattern handles products, warehouse stock levels, and a movement audit trail with atomic stock operations and oversell prevention.

Access patterns

#Access PatternOperationNotes
AP1Get product detailsGetItemProduct info page
AP2List products by categoryQuery (GSI1)Category browsing
AP3Get stock level for a product at a warehouseGetItemStock check
AP4List all stock at a warehouseQueryWarehouse dashboard
AP5List all warehouses stocking a productQuery (GSI1)Fulfillment routing
AP6Receive stock (increment)UpdateItemInbound shipment
AP7Reserve stock for an order (atomic, prevent oversell)UpdateItem with conditionOrder placement
AP8Get movement history for a warehouseQueryAudit trail

Eight access patterns. Three entity types. One table, one GSI.

Entities

Three entity types:

  • Product: the catalog entry — name, SKU, category
  • Stock: the quantity of a product at a specific warehouse, with reserved and available levels tracked separately
  • Movement: an append-only audit trail of every stock change — inbound, outbound, reservation, release
Inventory Management entity diagram showing Product, Stock, Movement relationships
3 entities · 1 GSI · stock at the intersection of warehouse and product partitions

Table design

Primary key structure

EntityPKSKPurpose
ProductPRODUCT#<productId>#METADATAProduct catalog info
StockWAREHOUSE#<warehouseId>PRODUCT#<productId>Stock level per warehouse-product pair
MovementWAREHOUSE#<warehouseId>MOVEMENT#<movementId>Audit trail of stock changes

Stock is keyed by warehouse × product. Each Stock item represents one product at one warehouse. The warehouse partition groups all stock at a location together (AP4), and the GSI inverts it to find all warehouses for a product (AP5).

Movements use ULIDs for chronological ordering, so all movements at a warehouse sort by time and the audit trail is a single Query.

Inventory Management DynamoDB schema: Entity, PK, SK, GSI columns for Product, Stock, Movement
PK/SK structure for Product, Stock, and Movement — warehouse-partition stock with inverted product GSI

Atomic stock operations

Receiving stock (AP6):

await StockEntity.update({ warehouseId, productId })
  .add({ quantity: inboundQty })
  .go();

DynamoDB’s ADD operation is atomic - no read-modify-write cycle.

Reserving stock with oversell prevention (AP7):

async function reserveStock(warehouseId: string, productId: string, qty: number) {
  await client.update({
    TableName: TABLE_NAME,
    Key: { pk: `WAREHOUSE#${warehouseId}`, sk: `PRODUCT#${productId}` },
    UpdateExpression: "ADD reservedQty :qty",
    // Only reserve if enough unreserved stock exists
    ConditionExpression: "(quantity - reservedQty) >= :qty",
    ExpressionAttributeValues: { ":qty": qty },
  });
}

The condition expression (quantity - reservedQty) >= :qty is evaluated atomically. If two orders try to reserve the last 5 units simultaneously, only one succeeds. The other gets a ConditionalCheckFailedException.

Fulfilling a reservation (shipping):

await client.update({
  TableName: TABLE_NAME,
  Key: { pk: `WAREHOUSE#${warehouseId}`, sk: `PRODUCT#${productId}` },
  UpdateExpression: "ADD quantity :negQty, reservedQty :negQty",
  ExpressionAttributeValues: { ":negQty": -qty },
});

Both quantity and reservedQty decrease - the item has left the warehouse.

Recording movements

Every stock change should also write a Movement record. Use TransactWriteItems to atomically update stock and create the movement:

await client.transactWrite({
  TransactItems: [
    { Update: { /* stock update with condition */ } },
    { Put: { /* new Movement record */ } },
  ],
});

This gives you a complete audit trail - every stock change is traceable to a movement with a type, reason, and reference ID.

Sample data

pkskgsi1pkgsi1skEntity Data
PRODUCT#prod_01#METADATACATEGORY#electronicsPRODUCT#prod_01{ name: "Wireless Mouse", sku: "WM-100" }
WAREHOUSE#wh_nycPRODUCT#prod_01PRODUCT#prod_01WAREHOUSE#wh_nyc{ quantity: 150, reservedQty: 12, reorderPoint: 20 }
WAREHOUSE#wh_nycPRODUCT#prod_02PRODUCT#prod_02WAREHOUSE#wh_nyc{ quantity: 45, reservedQty: 0, reorderPoint: 10 }
WAREHOUSE#wh_nycMOVEMENT#01HW...--{ productId: "prod_01", type: "reserve", quantity: 3, referenceId: "order_01" }
WAREHOUSE#wh_laPRODUCT#prod_01PRODUCT#prod_01WAREHOUSE#wh_la{ quantity: 80, reservedQty: 5, reorderPoint: 15 }

ElectroDB entity definitions

export const ProductEntity = new Entity({
  model: { entity: "product", version: "1", service: "inventory" },
  attributes: {
    productId: { type: "string", required: true },
    name:      { type: "string", required: true },
    sku:       { type: "string", required: true },
    category:  { type: "string" },
    active:    { type: "boolean", required: true, default: true },
    createdAt: {
      type: "string", required: true,
      default: () => new Date().toISOString(), readOnly: true,
    },
    updatedAt: {
      type: "string", required: true,
      default: () => new Date().toISOString(),
      set: () => new Date().toISOString(), watch: "*",
    },
  },
  indexes: {
    primary: {
      pk: { field: "pk", composite: ["productId"], template: "PRODUCT#${productId}" },
      sk: { field: "sk", composite: [],            template: "#METADATA" },
    },
    byCategory: {
      index: "GSI1",
      pk: { field: "gsi1pk", composite: ["category"],  template: "CATEGORY#${category}" },
      sk: { field: "gsi1sk", composite: ["productId"], template: "PRODUCT#${productId}" },
    },
  },
}, { client, table });

Why this design

Available quantity is computed at read time (availableQty = quantity - reservedQty) rather than stored. This avoids another field to keep in sync, and the two source fields are always updated atomically.

The reservation pattern separates intent from fulfillment. Reserving stock (order placed) and decrementing stock (order shipped) are separate operations. This handles the gap between order creation and shipment: reserved stock is still in the warehouse but not available for other orders.

Movements live in the warehouse partition, not their own partition. “Show me all activity at warehouse NYC” is a single Query. If you also need “all movements for product X across all warehouses,” add a GSI on the Movement entity with PK = PRODUCT#<productId>.

Low-stock alerts can be triggered via DynamoDB Streams. When a stock update causes quantity - reservedQty to drop below reorderPoint, a stream-triggered Lambda fires a notification.

Design this visually → coming soon

The Stock entity sitting at the intersection of Warehouse and Product partitions - with atomic operations flowing in from orders and movements flowing out - is exactly the kind of thing that’s easier to understand on a canvas than in prose. That’s what I’m building at singletable.dev.

Join the waitlist →


Pattern #8 of 10 in the SingleTable pattern library. The atomic update with condition expression technique is the same mechanism used in the Booking/Scheduling pattern for preventing double-booking. I’m building singletable.dev to make these patterns visual.

Join the waitlist →

Tejovanth N

These patterns come from real apps - rasika.life, rekha.app, rrmstays - all running single-table DynamoDB with ElectroDB.

LinkedIn codeculturecob.com

Related

Schema review

Want a second pair of eyes before you ship?

Async DynamoDB schema review. PK/SK design, GSI strategy, ElectroDB entity code. Fixed price, 5 business days.