Building a drag-and-drop flow builder: the data model comes first

The canvas is the easy part. What decides whether the product survives is storing flows as a versioned graph, keeping runs separate from definitions, and pinning each run to the version it started with.

A branching graph of nodes, and the rows it executes into

Store the flow as nodes and edges, version the definition, and pin every run to the version it started with. Get that wrong and no amount of work on the canvas will save you — you will be shipping a builder where editing a flow corrupts the runs already in flight.

The drag-and-drop part is a weekend. This is the part that is hard to change later.

A flow is a graph, not a list

The tempting model is a list of steps, because that is what simple automations look like. It survives until the first user asks for "if the amount is over 1000, do this instead" — and branching does not fit a list.

const flow = {
  nodes: [
    { id: 'trigger', type: 'webhook' },
    { id: 'check',   type: 'branch', on: 'amount > 1000' },
    { id: 'notify',  type: 'slack' },
    { id: 'log',     type: 'db' },
    { id: 'done',    type: 'noop' },
  ],
  edges: [
    { from: 'trigger', to: 'check' },
    { from: 'check',   to: 'notify', when: 'true'  },
    { from: 'check',   to: 'log',    when: 'false' },
    { from: 'notify',  to: 'done' },
    { from: 'log',     to: 'done' },
  ],
}

Nodes carry configuration; edges carry the routing, including the condition. The canvas is a rendering of this, not the source of truth — which means the same flow can be executed by a headless runner, exported, diffed in a pull request, and generated by an API.

Store node positions, but keep them out of the logic. x and y belong on the node for the editor's benefit; the executor must never read them. Once layout affects behaviour, tidying up a canvas changes what a flow does.

Execution is a topological walk

function order(flow) {
  const indeg = new Map(flow.nodes.map(n => [n.id, 0]))
  for (const e of flow.edges) indeg.set(e.to, indeg.get(e.to) + 1)

  const ready = flow.nodes.filter(n => indeg.get(n.id) === 0).map(n => n.id)
  const out = []
  while (ready.length) {
    const id = ready.shift()
    out.push(id)
    for (const e of flow.edges.filter(e => e.from === id)) {
      indeg.set(e.to, indeg.get(e.to) - 1)
      if (indeg.get(e.to) === 0) ready.push(e.to)
    }
  }
  return out.length === flow.nodes.length ? out : null   // null means a cycle
}

Run it on the flow above and you get:

trigger -> check -> notify -> log -> done

That final comparison is the whole cycle detection. If some nodes never reach zero in-degree, they are in a loop, and the function returns null instead of looping forever. Users will connect a node back to an earlier one — either by accident or because they want a retry loop — and a builder that hangs instead of saying "this flow has a cycle" is a builder that eats a worker.

Validate on save, not on run. The error belongs next to the edge that caused it, while the person who drew it is still looking at the screen.

Definitions and runs are different tables

Flow definitions are versioned; each run pins the version it started with

This is the decision people regret skipping.

CREATE TABLE flows (
  id         BIGSERIAL PRIMARY KEY,
  name       TEXT NOT NULL,
  current_version_id BIGINT
);

CREATE TABLE flow_versions (
  id         BIGSERIAL PRIMARY KEY,
  flow_id    BIGINT NOT NULL REFERENCES flows(id) ON DELETE CASCADE,
  graph      JSONB NOT NULL,          -- the nodes and edges above
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE flow_runs (
  id         BIGSERIAL PRIMARY KEY,
  version_id BIGINT NOT NULL REFERENCES flow_versions(id),   -- NOT flow_id
  status     TEXT NOT NULL DEFAULT 'running',
  trigger    JSONB NOT NULL,
  started_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE flow_run_steps (
  run_id     BIGINT NOT NULL REFERENCES flow_runs(id) ON DELETE CASCADE,
  node_id    TEXT NOT NULL,
  status     TEXT NOT NULL,
  input      JSONB,
  output     JSONB,
  error      TEXT,
  started_at TIMESTAMPTZ,
  ended_at   TIMESTAMPTZ,
  PRIMARY KEY (run_id, node_id)
);

The critical line is flow_runs.version_id. A run that started an hour ago keeps executing the graph it began with, even if someone has edited the flow since. Point runs at flow_id instead and an edit mid-run sends execution into nodes that did not exist when it started — a bug that appears rarely, only under load, and is close to impossible to reproduce from a report.

Versioning also gives you the things users ask for next without new work: history, rollback, and "what changed between the run that worked and the one that did not".

Run history is a feature, not logging

flow_run_steps — one row per node per run, with its input, output and error — is the difference between a product people trust and one they abandon.

When an automation does not do what someone expected, the question is always which step, and what did it see? If the answer requires reading server logs, only you can answer it, and every question becomes a support ticket. If the answer is a table the UI renders, the user debugs their own flow.

Store the input and output per step. It costs disk, and it is the single highest -value thing in the schema. Trim old runs on a schedule rather than storing less.

What I would not build first

A few things look essential and are not, at least not on day one:

  • A custom expression language. amount > 1000 is tempting to make powerful. It becomes a language you now maintain, with its own parser, error messages and security model. Start with a tiny safe evaluator over the run's data and see what people actually reach for.
  • Parallel branch execution. The topological order gives you the correct sequence; running independent branches concurrently is an optimisation. Sequential is fine until someone complains, and it makes run history far easier to read.
  • Loops. Users ask for them; most cases turn out to be "run this flow once per item", which is a trigger concern, not a graph concern. Loops in the graph bring iteration limits, per-iteration state and step history that no longer has one row per node.

Check it yourself

Topological ordering and cycle detection, in one file:

// save as flow.mjs — with the flow and order() from above
console.log('order:', order(flow).join(' -> '))

flow.edges.push({ from: 'done', to: 'check' })   // draw a loop by accident
console.log('with a cycle:', order(flow))
node flow.mjs
# order: trigger -> check -> notify -> log -> done
# with a cycle: null

Twenty lines, and it is the entire execution model. Everything else — the canvas, the node library, the retries — is built on that ordering.

The code

Runnable, and CI keeps it that way: CSTSolution/examples/flow-executor — the topological walk with cycle detection.

git clone https://github.com/CSTSolution/examples
cd examples/flow-executor

Where this goes next

This is the model under Workflow Builder: flows as versioned graphs, runs pinned to a version, and a step table the user can read. The steps themselves run on the Postgres job queue, with retries and dead-lettering around each one, triggered by webhooks that are verified on arrival.