A Workflow Engine's Entire Runtime Is One JSON File
Series Positioning: jeeflow Series Part 4 (Season 2 "Core Design" Part 1) Platform: Juejin (high code density, thorough principles) Material Version: Engine v1.8.15, pro site real-time screenshots Prerequisite Reading: Part 2 · jeeflow: What Does a 98KB Workflow Engine Look Like
1. Starting from a Flowchart
Open the jeeflow-pro integrated demo site (jeeflow-pro.mldong.com), and click on "Reimbursement Application" under "Process Design":
Four nodes, one straight line:
Start → Initiate Application → Department Manager Approval → Financial Review (Countersign) → General Manager Approval → End
Among them, the "Financial Review" node has a blue tag in the upper right corner — Parallel Countersign.
This diagram is for humans to read. But the engine doesn't consume diagrams; the engine consumes a JSON file.
The core question this article answers: What does this JSON look like? How does it turn "drawing" into "running a process"?
2. JSON Structure Anatomy
jeeflow's process definition file is a standard JSON, located in the jeeflow-java/jeeflow-core/src/test/resources/flows/ directory, loaded uniformly when the five-language demo starts.
Take the simplest 01-simple.json (simple approval process) as an example:
{
"name": "simple",
"displayName": "Simple Approval Process",
"type": "approval",
"instanceUrl": "/form/apply",
"nodes": [ ... ],
"edges": [ ... ]
}
The top level has only 5 fields:
| Field | Meaning | Example |
|---|---|---|
name |
Unique code (for program use) | "simple" |
displayName |
Display name (for humans) | "Simple Approval Process" |
type |
Process type | "approval" |
instanceUrl |
Initiation page route | "/form/apply" |
nodes / edges |
Node array + Edge array | See below |
2.1 Nodes
Each node is an object with core fields:
{
"id": "task1",
"type": "snaker:task",
"x": 300,
"y": 200,
"properties": {
"width": 100,
"height": 50,
"form": "leave-form",
"assignee": "leader",
"taskType": 0,
"performType": 0
},
"text": {
"value": "Superior Approval"
}
}
| Field | Purpose |
|---|---|
id |
Unique node identifier, referenced by edges via sourceNodeId / targetNodeId |
type |
Node type: snaker:start / snaker:task / snaker:decision / snaker:end |
x / y |
Canvas coordinates (used by designer, engine doesn't care) |
properties.assignee |
Approver expression — engine resolves via IUserProvider SPI |
properties.performType |
Execution mode — 0=normal, 1=parallel countersign, 2=sequential countersign |
properties.countersignType |
Countersign type — PARALLEL / SEQUENTIAL / RATIO |
properties.form |
Associated form route |
text.value |
Node display text |
2.2 Edges
Edges define the connection relationships between nodes:
{
"id": "e3",
"sourceNodeId": "decision1",
"targetNodeId": "task2",
"properties": {
"expr": "amount > 1000"
},
"text": {
"value": "Amount>1000"
}
}
For normal edges, properties is an empty object {}. Conditional edges carry the expr field — this is the basis for the engine's routing decisions.
2.3 Node Type System
jeeflow has only 4 node types, covering all process patterns:
| Type | Identifier | Purpose |
|---|---|---|
| Start Node | snaker:start |
Process entry, auto-generated |
| Task Node | snaker:task |
Manual approval / automated task |
| Decision Node | snaker:decision |
Conditional branch gateway |
| End Node | snaker:end |
Process exit |
No "sub-process nodes", no "event nodes", no "timer nodes" — jeeflow's philosophy is to compose complex behaviors from a small set of primitives, rather than piling up node types.
3. Dissecting the 10 Shared Processes One by One
jeeflow's test resource directory contains 10 shared process JSONs (01-simple → 10-mixed-mode), shared by the five language engines and loaded automatically at startup.
3.1 Overview Table
| # | Filename | Scenario | Core Pattern | Key JSON Feature |
|---|---|---|---|---|
| 01 | simple |
Simple Approval | Single Task | 1 task, assignee=leader |
| 02 | multi-task |
Sequential Multi-Node | Linear Series | Multiple tasks connected end-to-end |
| 03 | decision-expr |
Conditional Branch | Expression Gateway | Edge carries expr condition |
| 04 | fork-join |
Parallel Fork/Join | fork+join | Multiple parallel paths after fork, join converges |
| 05 | countersign-parallel |
Parallel Countersign | Multi-person Simultaneous Approval | performType:1 + countersignType:PARALLEL |
| 06 | countersign-sequential |
Sequential Countersign | Approve One by One | performType:2 + countersignType:SEQUENTIAL |
| 07 | countersign-ratio |
Ratio Countersign | Pass Ratio Threshold | countersignType:RATIO + ratio value |
| 08 | sequential-approve |
Sequential Approval | Special Countersign Mode | Create tasks one by one, complete one by one |
| 09 | with-reject |
Rejection Process | Return to Upstream | Edge points back to previous node |
| 10 | mixed-mode |
Mixed Mode | Combination of All Above | Conditional branch + countersign + rejection |
3.2 Conditional Branch in Detail (03-decision-expr)
This is the most commonly used and most easily misunderstood pattern. Complete JSON structure:
{
"name": "decision-expr",
"displayName": "Decision Expression Process",
"type": "approval",
"nodes": [
{
"id": "start",
"type": "snaker:start",
"text": { "value": "Start" }
},
{
"id": "apply",
"type": "snaker:task",
"properties": {
"assignee": "applicant",
"taskType": 0
},
"text": { "value": "Initiate Application" }
},
{
"id": "decision1",
"type": "snaker:decision",
"properties": {
"expr": "amount > 1000"
},
"text": { "value": "Amount>1000?" }
},
{
"id": "task2",
"type": "snaker:task",
"properties": { "assignee": "manager" },
"text": { "value": "Manager Approval" }
},
{
"id": "task3",
"type": "snaker:task",
"properties": { "assignee": "director" },
"text": { "value": "Director Approval" }
},
{
"id": "end",
"type": "snaker:end",
"text": { "value": "End" }
}
],
"edges": [
{ "sourceNodeId": "start", "targetNodeId": "apply" },
{ "sourceNodeId": "apply", "targetNodeId": "decision1" },
{
"sourceNodeId": "decision1",
"targetNodeId": "task2",
"properties": { "expr": "amount > 1000" },
"text": { "value": "Amount>1000" }
},
{
"sourceNodeId": "decision1",
"targetNodeId": "task3",
"properties": { "expr": "amount <= 1000" },
"text": { "value": "Amount≤1000" }
},
{ "sourceNodeId": "task2", "targetNodeId": "end" },
{ "sourceNodeId": "task3", "targetNodeId": "end" }
]
}
Routing Logic:
decision1 node evaluates expr → iterates all outgoing edges → finds the first edge where expr is true → takes that path
Key points:
- The
decision1node's ownproperties.expris the default expression (fallback) - Each outgoing edge's
properties.expris a branch condition (matched first) - The engine calculates expressions via the
IExpressionEvaluatorSPI, not bound to any specific expression engine (SpEL / OGNL / custom all work)
3.3 Parallel Countersign in Detail (05-countersign-parallel)
{
"id": "task1",
"type": "snaker:task",
"properties": {
"assignee": "userA,userB,userC",
"performType": "1",
"countersignType": "PARALLEL",
"field": {
"candidateUsers": "userA,userB,userC"
}
},
"text": { "value": "Countersign Approval" }
}
Three key fields determine countersign behavior:
| Field | Value | Meaning |
|---|---|---|
performType |
"1" |
Countersign mode (0=normal, 1=parallel, 2=sequential) |
countersignType |
"PARALLEL" |
Parallel countersign (everyone receives tasks simultaneously) |
assignee |
"userA,userB,userC" |
Countersign personnel list |
Engine Behavior:
- When the process reaches this node, tasks are created for all countersigners at once
- Each task is approved independently, without blocking each other
- The process only moves to the next node after everyone has passed
- If any node rejects → the entire countersign is rejected (one-vote veto,
submitType=20)
Cross-language Difference: The Java engine creates all tasks at once; Python/Node engines create them one by one. Behavior is consistent, implementation strategy differs — this is a classic example of "contract alignment, implementation freedom".
4. From JSON to Business: 9 Real Scenarios on the Pro Site
The shared JSONs are "textbooks"; what runs on the pro site (jeeflow-pro.mldong.com) is "real combat".
4.1 Process Definition List
After logging in as superAdmin and entering "Process Definitions", you can see 28 records (9 business scenarios × multiple versions):
| Process Name | Unique Code | Process Type | Core Pattern |
|---|---|---|---|
| Leave Application | biz_leave |
Attendance Management | Conditional Branch (Days Gateway) |
| Purchase Application | biz_purchase |
Business Management | Conditional Branch (Amount Gateway) |
| Reimbursement Application | biz_reimburse |
HR Management | Sequential + Parallel Countersign |
| Seal Application | biz_seal |
Business Management | Simple Approval |
| Overtime Application | biz_overtime |
Business Management | Conditional Branch (Hours Gateway) |
| Contract Approval | biz_contract |
Business Management | Conditional Branch (Contract Amount) |
| Business Trip Application | biz_business_trip |
Business Management | Conditional Branch (Budget Gateway) |
| Position Transfer Application | biz_transfer |
HR Management | Sequential Multi-Node |
| Asset Requisition Application | biz_asset |
Business Management | Simple Approval |
4.2 Conditional Branch in Action: Leave Application
Leave Application designer view:
Conditional branch logic:
- ≤3 days → Department Manager Approval
- >3 days → Supervising Leader Approval
Initiate a 1-day personal leave with the admin account, the process trace is as follows:
The green highlighted path clearly shows: Start → Applicant (Meng Lidong) → Conditional Branch (≤3 days path) → Department Manager Approval (Li Na) → End.
Corresponding JSON Core Structure:
{
"nodes": [
{ "id": "apply", "type": "snaker:task",
"properties": { "assignee": "applicant" },
"text": { "value": "Initiate Application" } },
{ "id": "decision_days", "type": "snaker:decision",
"properties": { "expr": "days > 3" } },
{ "id": "task_manager", "type": "snaker:task",
"properties": { "assignee": "deptLeader" },
"text": { "value": "Department Manager Approval" } },
{ "id": "task_director", "type": "snaker:task",
"properties": { "assignee": "chargeLeader" },
"text": { "value": "Supervising Leader Approval" } }
],
"edges": [
{ "sourceNodeId": "decision_days", "targetNodeId": "task_manager",
"properties": { "expr": "days <= 3" },
"text": { "value": "≤3 days" } },
{ "sourceNodeId": "decision_days", "targetNodeId": "task_director",
"properties": { "expr": "days > 3" },
"text": { "value": ">3 days" } }
]
}
4.3 Amount Gateway in Action: Purchase Application
Purchase applications use the same conditional branch pattern, just with the judgment condition changed from "days" to "amount":
- ≤20,000 → Department Manager Approval
- >20,000 → General Manager Approval
Admin initiates a 10,000 yuan purchase, process trace:
The path highlight shows it took the "≤20,000" branch, approved by Department Manager Li Na.
4.4 Parallel Countersign in Action: Reimbursement Application
This is the most complex scenario. Designer view:
Approval chain: Applicant → Department Manager → Financial Review (Parallel Countersign) → General Manager → End
Admin initiates a 5,000 yuan travel reimbursement, complete approval trace:
All four nodes passed:
- Applicant ✓ Meng Lidong
- Department Manager Approval ✓ Li Na
- Parallel Countersign Financial Review ✓ Zhao Min, Huang Hong, Meng Lidong, Wu Hao, Zheng Fang, Li Qing (all 6 passed)
- General Manager Approval ✓ Wang Qiang
Note: The actual countersign group has 6 people, not just the two words "Financial Review" seen on the designer. Countersign personnel are dynamically resolved by the
assigneefield +IUserProviderSPI, which can be a fixed list, department role, or even an expression calculation result.
4.5 Mapping Summary
| Shared Process | Pro Site Business Scenario | JSON Pattern |
|---|---|---|
03-decision-expr |
Leave Application (Days Gateway) | Conditional Branch |
03-decision-expr |
Purchase Application (Amount Gateway ≤20k) | Conditional Branch |
03-decision-expr |
Contract Approval (100k Threshold) | Conditional Branch |
03-decision-expr |
Business Trip Application (5000 Budget) | Conditional Branch |
03-decision-expr |
Overtime Application (4h Hours) | Conditional Branch |
05-countersign-parallel |
Reimbursement Application (Financial Countersign ALL) | Parallel Countersign |
01-simple |
Seal Application (Single Approval) | Simple Approval |
01-simple |
Asset Requisition (Single Approval) | Simple Approval |
02-multi-task |
Position Transfer (Multi-Node Sequential) | Sequential Multi-Node |
9 business scenarios = permutations and combinations of 4 JSON patterns.
5. Version Management and Designer
5.1 Multi-Version Coexistence
The pro site's process definition list shows 28 records — the same process (e.g., "Purchase Application") has multiple versions like v3, v4 coexisting.
The significance of version management:
- New versions do not affect in-flight instances: Initiated processes run to completion using the version at the time of initiation
- New initiations take the latest version: Automatically routed to the highest version number
- Rollback capability: Switch back to an old version at any time
5.2 Designer Operations
The process designer (based on the mldong-flow-designer-plus npm package) provides the following operations:
| Function | Description |
|---|---|
| Zoom Out/In | Canvas zoom |
| Fit | Auto-zoom to canvas size |
| Clear | Clear canvas |
| View Data | View/Edit underlying JSON |
| Import | Import process definition from JSON file |
| Save | Save current design to backend |
| Fullscreen | Fullscreen editing mode |
The designer is a pure frontend component, not bound to any backend — it produces JSON, the engine consumes JSON, and the two are decoupled through the JSON contract.
6. JSON as Protocol
Returning to the opening question: How does this JSON turn "drawing" into "running a process"?
The answer is three-layer decoupling:
┌─────────────┐ JSON ┌──────────────┐ JSON ┌──────────────┐
│ Process │ ───────────→ │ Process │ ───────────→ │ Workflow │
│ Designer │ Produces │ Definition │ Parses │ Engine │
│ (vben5-wf) │ JSON │ File (.json) │ JSON │ (jeeflow) │
└─────────────┘ └──────────────┘ └──────────────┘
Frontend File Backend
Not bound to engine Not bound to frontend Not bound to framework
- Designer not bound to engine: flow-designer / vben5-wf can both render the same JSON
- JSON not bound to frontend: It's pure data, any system can generate it
- Engine not bound to framework: Java/Go/Python/Node/PHP five languages can all parse the same JSON
This is the foundation of jeeflow's "one set of process definitions, four language implementations" — not a slogan, but a single JSON file.
References
- jeeflow GitHub Repository
- jeeflow Documentation Site
- Open Source Demo Site (Five-language backend switching)
- Integrated Demo Site (admin/123456)
- Shared Process JSON Source Code
Next Article Preview: Part 5 · Designing a Workflow Engine with DDD: Aggregate Roots and Rich Domain Models —— Why must ProcessInstance be an aggregate root? What is the responsibility boundary of ProcessTask as a child entity? Why can't an anemic domain model handle workflow scenarios?
Top 2 of 3 from juejin.cn, machine-translated. The original thread is authoritative.
I want to ask, if from a certain step, it's sent back to the previous step, how does jeeflow draw this?
The flowchart in the process designer only defines the forward flow path (from start to end). Sending back/rejecting does not need to draw reverse lines on the diagram. Sending back is a runtime behavior, not a design-time connection: When handling, the front end provides a 'Send Back' button. After clicking, the engine automatically lists all nodes that have been traversed (jumpAbleTaskNameList), and the approver chooses which step to send back to. The engine jumps back to the target node and regenerates the to-do item. The flowchart's responsibility is to define 'how the process goes'; which step to send back to is decided by the approver at runtime and does not need to be pre-drawn in the designer.
Free? What's the use?