DEV Community

Solon Framework
Solon Framework

Posted on

Solon Flow: Lightweight Process Orchestration Without BPMN XML

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:

  • start and end are required (though in simplified mode, the engine auto-generates them).
  • inclusive, parallel, and loop must be used in pairs (open and close).
  • exclusive supports a default branch (the link without a when condition).

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) or SolonContainer (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
  • StateController variants: BlockStateController, NotBlockStateController, ActorStateController
  • StateRepository implementations: 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:

  1. Simplicity — flat YAML, no XML schema, no database required
  2. Embeddability — runs anywhere a JVM runs, in any framework
  3. 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

Top comments (0)