Skip to content

DynamoDB Single-Table Pattern: Workflow/State Machine

Approval chains, onboarding flows, CI/CD pipelines, order fulfillment - any process where “what happens next” depends on “what just happened.”

The DynamoDB challenge: modeling ordered steps and tracking execution state across those steps, with a full audit trail of every transition.

This pattern separates the workflow definition (what steps exist) from workflow execution (what actually happened when someone ran it). The same workflow definition can have thousands of executions, each at a different step with different data.

Access patterns

#Access PatternOperationNotes
AP1Get workflow definition with all stepsQueryLoad the full workflow
AP2Get execution status and current stepGetItemDashboard / status check
AP3List executions for a workflow (newest first)Query (GSI1)Execution history
AP4Get full transition history for an executionQueryAudit trail / debugging
AP5Advance execution to next stepTransactWriteItemsState transition
AP6Get a specific step definitionGetItemStep detail view

Six access patterns. Four entity types. One table, one GSI.

Entities

Four entity types:

  • Workflow: the definition — name, version, and list of steps
  • Step: a single step in a workflow, ordered by an integer sort key
  • Execution: a single run of a workflow, with current step and status
  • Transition: an audit record of each step completion, with result and data
Workflow/State Machine entity diagram showing Workflow, Step, Execution, Transition relationships
4 entities · 1 GSI · workflow definition separate from execution state

Table design

Primary key structure

EntityPKSKPurpose
WorkflowWORKFLOW#<workflowId>#METADATAWorkflow name, version, status
StepWORKFLOW#<workflowId>STEP#<stepOrder>Step definitions, ordered
ExecutionEXECUTION#<executionId>#METADATACurrent execution state
TransitionEXECUTION#<executionId>TRANSITION#<transitionId>State change audit trail

Steps use sequential integer sort keys: STEP#1, STEP#2, STEP#3. Query the workflow partition to get all steps in order. The step order IS the sort key.

Executions are separate from workflow definitions. The workflow partition holds the blueprint. Each execution gets its own partition with its state and transition history, cleanly separating “what should happen” from “what did happen.”

Transitions use ULIDs. Each state transition is a chronologically-ordered record in the execution partition. The full transition history is a single Query.

Workflow/State Machine DynamoDB schema: Entity, PK, SK, GSI columns for all four entities
PK/SK structure for Workflow, Step, Execution, and Transition — definition and execution in separate partitions

The state transition: advancing an execution

async function advanceExecution(
  executionId: string, 
  fromStep: number, 
  toStep: number, 
  result: string, 
  data?: any
) {
  const transitionId = ulid();
  
  await client.transactWrite({
    TransactItems: [
      {
        // Update execution: advance currentStep
        Update: {
          TableName: TABLE_NAME,
          Key: { pk: `EXECUTION#${executionId}`, sk: "#METADATA" },
          UpdateExpression: "SET currentStep = :toStep, #status = :status",
          // Guard: only advance if we're at the expected step
          ConditionExpression: "currentStep = :fromStep AND #status = :running",
          ExpressionAttributeNames: { "#status": "status" },
          ExpressionAttributeValues: {
            ":fromStep": fromStep,
            ":toStep": toStep,
            ":status": toStep === -1 ? "completed" : "running",
            ":running": "running",
          },
        },
      },
      {
        // Record the transition
        Put: {
          TableName: TABLE_NAME,
          Item: {
            pk: `EXECUTION#${executionId}`,
            sk: `TRANSITION#${transitionId}`,
            fromStep, toStep, result,
            data: data || null,
            occurredAt: new Date().toISOString(),
          },
        },
      },
    ],
  });
}

The condition expression currentStep = :fromStep AND #status = :running prevents out-of-order transitions and ensures only running executions can be advanced. If two processes try to advance the same execution simultaneously, exactly one succeeds.

Sample data

pkskgsi1pkgsi1skEntity Data
WORKFLOW#wf_onboard#METADATA--{ name: "Employee Onboarding", version: 2, status: "active" }
WORKFLOW#wf_onboardSTEP#1--{ name: "Send welcome email", type: "notification" }
WORKFLOW#wf_onboardSTEP#2--{ name: "Manager approval", type: "approval", timeoutMin: 4320 }
WORKFLOW#wf_onboardSTEP#3--{ name: "Provision accounts", type: "action" }
EXECUTION#01HW...#METADATAWORKFLOW#wf_onboardEXECUTION#01HW...{ workflowId: "wf_onboard", status: "running", currentStep: 2, triggeredBy: "hr_admin" }
EXECUTION#01HW...TRANSITION#01HW1...--{ fromStep: 1, toStep: 2, result: "success", data: { emailSent: true } }

ElectroDB entity definitions

export const WorkflowEntity = new Entity({
  model: { entity: "workflow", version: "1", service: "wf" },
  attributes: {
    workflowId: { type: "string", required: true },
    name:       { type: "string", required: true },
    version:    { type: "number", required: true, default: 1 },
    status:     { type: "string", required: true, default: "active",
                  enum: ["active", "archived"] },
    description: { type: "string" },
    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: ["workflowId"], template: "WORKFLOW#${workflowId}" },
      sk: { field: "sk", composite: [],              template: "#METADATA" },
    },
  },
}, { client, table });

Why this design

Sequential step ordering via SK is simpler than linked-list approaches. STEP#1, STEP#2, STEP#3 sorts naturally. “What comes after step 2?” is just STEP#3. No pointer traversal.

Transitions are the audit trail. The execution’s currentStep tells you where it is now. The transition history tells you how it got there, including which steps failed, which were skipped, and how long each took (compare occurredAt timestamps).

This is not a replacement for AWS Step Functions. Step Functions handle complex branching, parallel execution, error retries with backoff, and long-running waits natively. This pattern is for simpler, linear or mostly-linear workflows where you want the state machine definition and execution history in your own database: approval chains, onboarding flows, document review pipelines.

Conditional branching lives in the execution engine, not the schema. The Step entity’s config attribute stores branching rules (e.g., “if approved, go to step 3; if rejected, go to step 5”). The engine reads the config and determines the next step. Keeping branching logic out of the key structure means you can change workflow rules without re-keying data.

Design this visually → coming soon

The definition/execution split is easy to state but hard to internalize until you see both partitions side by side - the workflow blueprint over here, the live execution with its transition log over there. That’s what I’m building at singletable.dev.

Join the waitlist →


Pattern #9 of 10 in the SingleTable pattern library. The condition-based state transition technique is the same pattern used in Booking/Scheduling (prevent double-booking) and Inventory Management (prevent overselling). I’m building singletable.dev to make these patterns visual.

New to single-table design? DynamoDB fundamentals is the right starting point - it covers the data model this pattern builds on. The 5 most common mistakes is worth reading before you commit to a key structure.

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.