When you need process orchestration — approval workflows, business rules, data pipelines — the usual answer is a heavyweight engine: BPMN 2.0 XML, database schemas, a management UI, and a framework that drags in half of enterprise Java.
Solon Flow takes a different approach. It's a ~200KB engine that treats process definitions as flat YAML or JSON, runs without a database, and lets you resume interrupted processes from a JSON snapshot. You can embed it in any JVM framework — Solon, Spring Boot, Quarkus, or even a plain main() method.
This article walks through the core API, node types, context persistence, and driver customization — all verified against the official documentation at solon.noear.org.
Getting Started
Add the dependency:
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-flow</artifactId>
</dependency>
Enter fullscreen mode Exit fullscreen mode
Define a flow in YAML (flow/demo1.yml):
id: "c1"
layout:
- { id: "n1", type: "start", link: "n2" }
- { id: "n2", type: "activity", link: "n3", task: 'System.out.println("hello world!");' }
- { id: "n3", type: "end" }
Enter fullscreen mode Exit fullscreen mode
Load and execute:
FlowEngine engine = FlowEngine.newInstance();
engine.load("classpath:flow/demo1.yml");
engine.eval("c1");
Enter fullscreen mode Exit fullscreen mode
That's it. No database, no XML schema, no deployment step.
In a Solon application, you can inject the engine directly and let it auto-load flow definitions:
solon.flow:
- "classpath:flow/*.yml"
Enter fullscreen mode Exit fullscreen mode
@Component
public class DemoCom implements LifecycleBean {
@Inject
private FlowEngine flowEngine;
@Override
public void start() throws Throwable {
flowEngine.eval("c1");
}
}
Enter fullscreen mode Exit fullscreen mode
The engine scans all matching files on startup, so adding a new flow is just dropping a YAML file.
Node Types
Solon Flow supports seven node types via the NodeType enum:
| Type | Description | Task | Condition | Parallel | In | Out |
|---|---|---|---|---|---|---|
start |
Entry point | — | — | — | 0 | 1 |
activity |
Default node | Yes | — | — | 1..n | 1..n |
exclusive |
Exclusive gateway (if/else) | Yes | Yes | — | 1..n | 1..n |
inclusive |
Inclusive gateway (multi-select) | Yes | Yes | — | 1..n | 1..n |
parallel |
Parallel gateway (fork/join) | Yes | — | Yes | 1..n | 1..n |
loop |
Loop gateway (iteration) | Yes | — | — | 1 | 1 |
end |
Exit point | — | — | — | 1..n | 0 |
Key rules:
-
startandendare required (though in simplified mode, the engine auto-generates them). -
inclusive,parallel, andloopmust be used in pairs (open and close). -
exclusivesupports a default branch (the link without awhencondition).
Exclusive Gateway Example
id: "approval"
layout:
- { type: start, link: g1 }
- { type: exclusive, id: g1, link: [n1, { nextId: n2, when: "day >= 3" }] }
- { type: activity, id: n1, task: "@tl_approve", link: e }
- { type: activity, id: n2, task: "@dm_approve", link: e }
- { type: end, id: e }
Enter fullscreen mode Exit fullscreen mode
When day >= 3, the flow goes to n2 (department manager). Otherwise, it takes the default path to n1 (team lead).
Parallel Gateway Example
id: "parallel_demo"
layout:
- { type: start, link: g1 }
- { type: parallel, id: g1, link: [n1, n2] }
- { type: activity, id: n1, task: "@credit_check", link: g2 }
- { type: activity, id: n2, task: "@fraud_check", link: g2 }
- { type: parallel, id: g2 }
- { type: end }
Enter fullscreen mode Exit fullscreen mode
Both n1 and n2 execute. The second parallel node (g2) waits for all incoming links to arrive before proceeding.
Loop Gateway Example
id: "loop_demo"
layout:
- { type: start, link: g1 }
- { type: loop, id: g1, meta: { "$for": "id", "$in": "idList" } }
- { type: activity, task: "@process_item" }
- { type: loop }
- { type: end }
Enter fullscreen mode Exit fullscreen mode
The $for key names the loop variable pushed into context. The $in key accepts a variable name, a static array like [1,2,5,8], or a numeric range like "1:9:2".
Task and Condition Description Formats
Each node's task and when fields support multiple description styles:
| Prefix | Style | Example |
|---|---|---|
@ |
Component lookup from container | @risk_score |
# |
Cross-graph sub-process call | #sub_flow_1 |
$ |
Script from graph meta | $script.validator |
| (none) | Inline script (default: full Java syntax via Liquor) | order.setScore(1); |
For conditions, the same @ prefix looks up a ConditionComponent. Without a prefix, it's an inline expression evaluated by the built-in SnEL engine.
Component-Based Tasks
Instead of inline scripts, you can delegate to a container-managed component:
@Component
public class RiskScoreTask implements TaskComponent {
@Override
public void run(FlowContext context, Node node) throws Throwable {
int score = calculateScore(context.get("user"));
context.put("score", score);
}
}
Enter fullscreen mode Exit fullscreen mode
Reference it in YAML:
- { type: activity, task: "@RiskScoreTask", link: g1 }
Enter fullscreen mode Exit fullscreen mode
The @ prefix tells the driver to look up RiskScoreTask from the container (Solon's SolonContainer by default, or a MapContainer for non-Solon environments).
FlowContext: Variables, Persistence, and Recovery
FlowContext is the runtime state carrier. It holds variables, provides an event bus, and supports serialization for pause/resume.
Passing Data Through Context
FlowContext context = FlowContext.of();
context.put("amount", 1500);
flowEngine.eval("c1", context);
int score = context.getAs("score");
Enter fullscreen mode Exit fullscreen mode
Inside the flow, scripts can access variables directly:
layout:
- task: 'context.put("result", amount * 0.1);'
Enter fullscreen mode Exit fullscreen mode
Interrupting and Resuming
Any task can call context.stop() to halt execution:
spec.addActivity("n3").task((ctx, node) -> {
if (!ctx.getOrDefault("approved", false)) {
ctx.stop(); // Halt here
}
}).linkAdd("n4");
Enter fullscreen mode Exit fullscreen mode
Serialize the state:
String snapshot = context.toJson();
db.save(context.getInstanceId(), snapshot);
Enter fullscreen mode Exit fullscreen mode
Later, restore and resume:
FlowContext restored = FlowContext.fromJson(snapshot);
restored.put("approved", true);
flowEngine.eval(graph, restored); // Resumes from n3
Enter fullscreen mode Exit fullscreen mode
The engine tracks the last executed node (context.lastNodeId()) and automatically continues from the interruption point. This works because toJson() captures the full execution trace and variable state.
Event Bus
FlowContext includes a built-in event bus (backed by DamiBus) for decoupled communication between flow nodes and external listeners:
// Inside a task
context.eventBus().send("notification.topic", "order approved");
// External listener
context.eventBus().listen("notification.topic", event -> {
System.out.println(event.getContent());
});
Enter fullscreen mode Exit fullscreen mode
For synchronous request-reply:
String reply = context.eventBus()
.<String, String>call("validation.topic", order)
.get();
Enter fullscreen mode Exit fullscreen mode
Building Graphs in Code
For dynamic flows or test cases, you can construct graphs programmatically using the Fluent API:
Graph graph = Graph.create("approval", spec -> {
spec.addStart("s")
.title("Initiator")
.metaPut("role", "employee")
.linkAdd("n1");
spec.addActivity("n1")
.title("Team Lead")
.metaPut("role", "tl")
.linkAdd("g1");
spec.addExclusive("g1")
.linkAdd("e", l -> l.title("Under 3 days"))
.linkAdd("n2", l -> l.title("3+ days").condition("day >= 3"));
spec.addActivity("n2")
.title("Department Manager")
.metaPut("role", "dm")
.linkAdd("g2");
spec.addExclusive("g2")
.linkAdd("e", l -> l.title("Under 7 days"))
.linkAdd("n3", l -> l.title("7+ days").condition("day >= 7"));
spec.addActivity("n3")
.title("VP")
.metaPut("role", "vp")
.linkAdd("e");
spec.addEnd("e");
});
flowEngine.eval(graph, FlowContext.of());
Enter fullscreen mode Exit fullscreen mode
The GraphSpec builder mirrors the YAML structure exactly — addStart, addActivity, addExclusive, addParallel, addInclusive, addLoop, addEnd — with fluent chaining for title(), task(), when(), metaPut(), linkAdd(), and condition().
Driver Customization
The FlowDriver interface is the execution engine's extension point. Think of it like a JDBC driver — same engine, different behavior.
public interface FlowDriver {
void onNodeStart(FlowExchanger exchanger, Node node);
void onNodeEnd(FlowExchanger exchanger, Node node);
boolean handleCondition(FlowExchanger exchanger, String condition);
void handleTask(FlowExchanger exchanger, String task);
void postHandleTask(FlowExchanger exchanger, String task);
}
Enter fullscreen mode Exit fullscreen mode
The default implementation, SimpleFlowDriver, supports:
- Evaluation: Inline script execution via pluggable engines (Liquor for full Java syntax, Aviator, Beetl, or Magic)
-
Container: Component lookup via
MapContainer(no framework) orSolonContainer(Solon IoC) - Executor: Custom thread pool for parallel node execution
SimpleFlowDriver driver = SimpleFlowDriver.builder()
.evaluation(new LiquorEvaluation())
.container(new SolonContainer())
.executor(Executors.newVirtualThreadPerTaskExecutor())
.build();
FlowEngine engine = FlowEngine.newInstance(driver);
Enter fullscreen mode Exit fullscreen mode
This is how Solon Flow adapts to different use cases: a workflow engine, a rules engine, a data pipeline, or an AI orchestration layer — all by swapping the driver.
Interceptors
FlowInterceptor provides cross-cutting concerns — logging, metrics, access control:
engine.addInterceptor(new FlowInterceptor() {
@Override
public void onNodeStart(FlowContext context, Node node) {
System.out.println("Starting: " + node.getId());
}
@Override
public void onNodeEnd(FlowContext context, Node node) {
System.out.println("Completed: " + node.getId());
}
});
Enter fullscreen mode Exit fullscreen mode
Interceptors run on every node transition, regardless of which driver is active.
Simplified Mode
For quick prototypes or single-task flows, Solon Flow can infer start and end nodes:
id: "quick"
layout:
- { task: 'System.out.println("just one step");' }
Enter fullscreen mode Exit fullscreen mode
The engine auto-generates a start before this node and an end after it. Node IDs are auto-assigned as n-1, n-2, etc.
What Makes Solon Flow Different
| Aspect | Solon Flow | Traditional BPM Engines |
|---|---|---|
| Definition format | Flat YAML/JSON | BPMN 2.0 XML |
| Database dependency | Optional (in-memory or Redis) | Required |
| Framework coupling | None (works in any JVM) | Usually tied to a runtime |
| Script engine | Pluggable (Liquor/Aviator/Beetl/Magic) | Fixed |
| Persistence | JSON snapshot (toJson() / fromJson()) |
Database state tables |
| Footprint | ~200KB | 10MB+ |
| Resume mechanism | Context deserialization + eval()
|
Session recovery from DB |
Solon Flow doesn't aim to replace full-featured BPM platforms. It targets scenarios where you need orchestration logic — approval chains, rule evaluation, data processing pipelines — without the operational overhead of a dedicated BPM server.
Workflow Extension
For approval-style workflows with task assignment, the optional solon-flow-workflow plugin adds:
-
WorkflowExecutor— orchestrates human-task flows -
StateControllervariants:BlockStateController,NotBlockStateController,ActorStateController -
StateRepositoryimplementations:InMemoryStateRepository,RedisStateRepository - Task lifecycle:
findTask(),claimTask(),completeTask()
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-flow-workflow</artifactId>
</dependency>
Enter fullscreen mode Exit fullscreen mode
Conclusion
Solon Flow brings three things to process orchestration:
- Simplicity — flat YAML, no XML schema, no database required
- Embeddability — runs anywhere a JVM runs, in any framework
- Resumability — JSON snapshots for pause/resume without infrastructure
If you're building approval flows, rule engines, or data pipelines in Java and find traditional BPM engines too heavy, Solon Flow is worth a look.
Documentation: solon.noear.org/article/learn-solon-flow
Source: github.com/opensolon/solon-flow
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.