Skip to content

HATEOAS State Transitions — Modeling Resource State Machines with Links

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about HATEOAS State Transitions. We cover key concepts, practical examples, and best practices to help you master this topic.

HATEOAS state transitions model resources as state machines where the available links and actions change based on the resource's current state, guiding clients through valid operations.

What You'll Learn

  • State machine modeling for REST resources
  • Showing state-dependent links
  • Preventing invalid state transitions
  • Client guidance through available actions
  • Workflow Orchestration with HATEOAS

Why It Matters

A threat in "analyzing" state can't be quarantined — only canceled. A device in "quarantined" state can't be quarantined again. HATEOAS naturally prevents invalid operations by only showing links for actions that are valid in the current state. DodaTech's Durga Antivirus Pro uses state-based links to guide the admin dashboard through the threat response workflow.

Real-World Use

A threat is detected and enters "new" state. The API shows links for "analyze" and "ignore". When the user clicks "analyze", the state changes to "analyzing" and the API now shows "cancel" and "quarantine" links. When analysis completes, state becomes "identified" with "remediate" and "report" links.

stateDiagram-v2
    [*] --> New: Threat Detected
    New --> Analyzing: dt:analyze
    New --> Ignored: dt:ignore
    Analyzing --> Identified: dt:classify
    Analyzing --> New: dt:cancel
    Identified --> Quarantined: dt:quarantine
    Identified --> Remediated: dt:remediate
    Quarantined --> Remediated: dt:clean
    Quarantined --> New: dt:false-positive
    Remediated --> [*]: Resolved
    Ignored --> [*]: Closed

Code Examples

Example 1: State Machine Configuration

// State machine definition for Threat resource
const threatStates = {
  new: {
    label: 'New',
    actions: [
      { rel: 'dt:analyze', method: 'POST', href: '/threats/{id}/analyze', title: 'Analyze Threat' },
      { rel: 'dt:ignore', method: 'POST', href: '/threats/{id}/ignore', title: 'Ignore Threat' },
    ],
    transitionsTo: ['analyzing', 'ignored'],
  },
  analyzing: {
    label: 'Analyzing',
    actions: [
      { rel: 'dt:cancel', method: 'POST', href: '/threats/{id}/cancel-analysis', title: 'Cancel Analysis' },
      { rel: 'dt:classify', method: 'POST', href: '/threats/{id}/classify', title: 'Classify Threat' },
    ],
    transitionsTo: ['new', 'identified'],
  },
  identified: {
    label: 'Identified',
    actions: [
      { rel: 'dt:quarantine', method: 'POST', href: '/threats/{id}/quarantine', title: 'Quarantine' },
      { rel: 'dt:remediate', method: 'POST', href: '/threats/{id}/remediate', title: 'Remediate' },
    ],
    transitionsTo: ['quarantined', 'remediated'],
  },
  quarantined: {
    label: 'Quarantined',
    actions: [
      { rel: 'dt:clean', method: 'POST', href: '/threats/{id}/clean', title: 'Clean Device' },
      { rel: 'dt:false-positive', method: 'POST', href: '/threats/{id}/false-positive', title: 'Mark False Positive' },
    ],
    transitionsTo: ['remediated', 'new'],
  },
  remediated: {
    label: 'Remediated',
    actions: [],
    transitionsTo: [],
  },
  ignored: {
    label: 'Ignored',
    actions: [],
    transitionsTo: [],
  },
};

function getActionsForState(threatId, state) {
  const stateDef = threatStates[state];
  if (!stateDef) return [];
  
  return stateDef.actions.map(action => ({
    ...action,
    href: action.href.replace('{id}', threatId),
  }));
}

// Validate transition
function canTransition(threat, targetState) {
  const stateDef = threatStates[threat.status];
  if (!stateDef) return false;
  return stateDef.transitionsTo.includes(targetState);
}

Example 2: State-Dependent Resource Response

const express = require('express');
const app = express();

// Middleware to add state-dependent links
function addStateLinks(req, res, next) {
  const originalJson = res.json.bind(res);
  
  res.json = function(body) {
    if (body && body._links) {
      // Get state-dependent actions
      const state = body.status || 'new';
      const stateActions = getActionsForState(body.id, state);
      
      // Add state-dependent links
      for (const action of stateActions) {
        body._links[action.rel] = {
          href: action.href,
          title: action.title,
          method: action.method,
        };
      }
    }
    
    return originalJson(body);
  };
  
  next();
}

app.use(addStateLinks);

app.get('/threats/:id', (req, res) => {
  const threat = getThreat(req.params.id);
  
  res.json({
    ...threat,
    status: threat.status,
    _links: {
      self: { href: `/threats/${threat.id}` },
      collection: { href: '/threats' },
    },
  });
});

// Execute state transition
app.post('/threats/:id/:action', (req, res) => {
  const threat = getThreat(req.params.id);
  const action = req.params.action;
  
  // Map action to target state
  const actionStateMap = {
    analyze: 'analyzing',
    ignore: 'ignored',
    cancel: 'new',
    classify: 'identified',
    quarantine: 'quarantined',
    remediate: 'remediated',
    clean: 'remediated',
    'false-positive': 'new',
  };
  
  const targetState = actionStateMap[action];
  if (!targetState || !canTransition(threat, targetState)) {
    return res.status(422).json({
      error: `Cannot transition from ${threat.status} to ${targetState}`,
      _links: {
        self: { href: `/threats/${threat.id}` },
      },
    });
  }
  
  // Update state
  const updated = updateThreatState(threat.id, targetState);
  
  res.json({
    ...updated,
    _links: {
      self: { href: `/threats/${updated.id}` },
    },
  });
});

Example 3: Client Navigating State Machines

class StateMachineClient {
  constructor(baseUrl) {
    this.baseUrl = baseUrl;
  }
  
  async fetch(url) {
    const res = await fetch(`${this.baseUrl}${url}`);
    return res.json();
  }
  
  async getAvailableActions(threatId) {
    const threat = await this.fetch(`/threats/${threatId}`);
    
    console.log(`Threat: ${threat.name}`);
    console.log(`Current state: ${threat.status}`);
    
    // Available actions are in _links, filtered by state
    const actions = Object.entries(threat._links || {})
      .filter(([rel]) => rel.startsWith('dt:') && rel !== 'dt:scan' && rel !== 'dt:deep-scan')
      .map(([rel, link]) => ({
        rel,
        href: link.href,
        title: link.title,
      }));
    
    console.log('Available actions:');
    for (const action of actions) {
      console.log(`  [${action.rel}] ${action.title}`);
    }
    
    return { threat, actions };
  }
  
  async executeAction(threatId, rel) {
    const threat = await this.fetch(`/threats/${threatId}`);
    const action = threat._links[rel];
    
    if (!action) {
      throw new Error(`Action '${rel}' not available in current state`);
    }
    
    console.log(`Executing: ${action.title}`);
    
    const res = await fetch(`${this.baseUrl}${action.href}`, {
      method: action.method || 'POST',
      headers: { 'Content-Type': 'application/json' },
    });
    
    return res.json();
  }
  
  async walkThroughWorkflow(threatId) {
    // Navigate through the state machine step by step
    let currentState = 'new';
    const workflow = [
      'dt:analyze',
      'dt:classify',
      'dt:quarantine',
      'dt:clean',
    ];
    
    for (const action of workflow) {
      const { threat } = await this.getAvailableActions(threatId);
      console.log(`\n--- Step: ${action} ---`);
      
      const result = await this.executeAction(threatId, action);
      console.log(`New state: ${result.status}`);
      currentState = result.status;
    }
    
    console.log('\nWorkflow complete!');
  }
}

// Usage
const client = new StateMachineClient('https://api.dodatech.com');
await client.walkThroughWorkflow('threat-101');

Common Mistakes

  1. Showing invalid actions — never show links for actions that can't be executed in the current state. This misleads clients and causes 422 errors.
  2. Not documenting the state machine — clients need to understand the full state machine. Include a state diagram in your API documentation or serve it via a link.
  3. Allowing invalid transitions server-side — even if the client somehow calls an invalid action, the server must reject it with a proper error.
  4. Hardcoding state transitions in clients — clients should discover available transitions from links, not hardcode which actions follow which state.
  5. Ignoring race conditions — two clients might both try to transition the same resource. Use optimistic locking (ETags) to prevent double transitions.

Practice Questions

  1. How does HATEOAS naturally prevent invalid state transitions?
  2. Why should actions change based on resource state?
  3. How do you handle race conditions in state transitions?
  4. What should the server return when an invalid transition is attempted?
  5. How do clients discover available transitions?

Challenge: Design a state machine for a vulnerability management workflow with states: open, triaging, in-progress, resolved, verified, closed. Define available actions and transitions for each state. Implement as a HATEOAS API with state-dependent links.

Mini Project

Build a HATEOAS workflow engine with: state machine definition per resource type, automatic link filtering based on current state, transition validation with error handling, optimistic locking for concurrent transitions, and a client library that walks through workflows automatically.

FAQ

How do I model complex workflows with HATEOAS?

Break the workflow into resource states. Each state has a fixed set of possible transitions. Links show available transitions. The client follows links to advance the workflow.

What if a client tries an action not in the links?

The server must validate the transition server-side and return 422 Unprocessable Entity with a clear error message explaining why the transition is invalid.

How do I handle concurrent state changes?

Use ETags or version fields. When transitioning, the client sends the current version. If another client changed it, the server returns 409 Conflict.

Can a resource have different state machines per user role?

Yes. An admin might see quarantine and delete actions while a viewer only sees read and export actions. Filter links based on the authenticated user's permissions.

How do I document the state machine?

Include a state diagram in your API docs. Also serve a /threats/state-machine endpoint that returns the machine definition as JSON for automated clients.

What's Next

Learn about HATEOAS actions and forms

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro