"Think in Graphs, Not Just Chains: JGraphlet for TaskPipelines" with Shaaf Syed - JVM Weekly vol. 152
Today, Shaaf will share with us some insight from his library, JGraphlet.
Year ago I announced that JVM Weekly had joined the Friends of OpenJDK (Foojay.io) family. Foojay.io is a dynamic, community-driven platform for OpenJDK users, primarily Java and Kotlin enthusiasts. As a hub for the “Friends of OpenJDK,” Foojay.io gathers a rich collection of articles written by industry experts and active community members, offering valuable insights into the latest trends, tools, and practices within the OpenJDK ecosystem.
Today I’ve got for you Think in Graphs, Not Just Chains: JGraphlet for TaskPipelines by Shaaf Syed from RedHat. Most of us build task-processing pipelines as simple, linear “chains” - A, then B, then C. The article points out why this is a brittle and inefficient model that doesn’t cope well with complex dependencies. The author proposes a paradigm shift: “stop thinking in chains, start thinking in graphs.” At the center is JGraphlet, a minimalist Java library that lets you model workflows as directed acyclic graphs (DAGs).
Think in Graphs, Not Just Chains: JGraphlet for TaskPipelines
JGraphlet is a tiny, zero-dependency Java library for building task pipelines. It uses a graph model where you define tasks as nodes and connect them to create simple or complex workflows (like fan-in/fan-out). It supports both asynchronous (default) and synchronous tasks, has a simple API, allows data sharing via a PipelineContext, and offers optional caching to avoid re-computing results.
Its power comes not from a long list of features, but from a small set of core design principles that work together in harmony.
At the heart of JGraphlet is simplicity, backed by a Graph. Add Tasks to a pipeline and connect them to create your graph. Each Task has an input and output. A TaskPipeline builds and executes a pipeline while managing the I/O for each Task.
For example, a Map for Fan-in, a Record for your own data model, etc. A Task pipeline also has a way PipelineContext to share data between Tasks, and Tasks can also be cached, so the computation doesn’t need to take place again and again.
You can choose how your Task pipeline flow should be, and you can decide whether it should be synchronous SyncTask or asynchronous. By default, all Tasks are asynchronous.
Let’s dive into the eight core principles that define JGraphlet.
1. A Graph-First Execution Model
JGraphlet treats your workflow as a Directed Acyclic Graph (DAG). You define tasks as nodes and explicitly draw the connections (edges) between them. This makes complex patterns like fan-out (one task feeding many) and fan-in (many tasks feeding one) natural.
Example:
import dev.shaaf.jgraphlet.*;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
try (TaskPipeline pipeline = new TaskPipeline()) {
Task<String, String> fetchInfo = (id, ctx) -> CompletableFuture.supplyAsync(() -> “Info for “ + id);
Task<String, String> fetchFeed = (id, ctx) -> CompletableFuture.supplyAsync(() -> “Feed for “ + id);
Task<Map<String, Object>, String> combine = (inputs, ctx) -> CompletableFuture.supplyAsync(() ->
inputs.get(”infoNode”) + “ | “ + inputs.get(”feedNode”)
);
pipeline.addTask(”infoNode”, fetchInfo)
.addTask(”feedNode”, fetchFeed)
.addTask(”summaryNode”, combine);
pipeline.connect(”infoNode”, “summaryNode”)
.connect(”feedNode”, “summaryNode”);
String result = (String) pipeline.run(”user123”).join();
System.out.println(result); // “Info for user123 | Feed for user123”
}2. Two Task Styles: Task<I> and SyncTask<I>
JGraphlet provides two distinct task types you can mix and match:
Task (Async): Returns a
CompletableFuture. Perfect for I/O operations or heavy computations.SyncTask (Sync): Returns a direct O - output. Ideal for fast, CPU-bound operations.
Example:
try (TaskPipeline pipeline = new TaskPipeline()) {
Task<String, String> fetchName = (userId, ctx) ->
CompletableFuture.supplyAsync(() -> “John Doe”);
SyncTask<String, String> toUpper = (name, ctx) -> name.toUpperCase();
pipeline.add(”fetch”, fetchName)
.then(”transform”, toUpper);
String result = (String) pipeline.run(”user-42”).join();
System.out.println(result); // “JOHN DOE”
}3. A Simple, Explicit API
JGraphlet avoids complex builders or magic configurations. The API is lean and explicit:
Create a pipeline:
new TaskPipeline()Register nodes:
addTask(“uniqueId”, task)Wire them up:
connect(“fromId”, “toId”)
Example:
try (TaskPipeline pipeline = new TaskPipeline()) {
SyncTask<String, Integer> lengthTask = (s, c) -> s.length();
SyncTask<Integer, String> formatTask = (i, c) -> “Length is “ + i;
pipeline.addTask(”calculateLength”, lengthTask);
pipeline.addTask(”formatOutput”, formatTask);
pipeline.connect(”calculateLength”, “formatOutput”);
String result = (String) pipeline.run(”Hello”).join();
System.out.println(result); // “Length is 5”
}4. A Clear Fan-In Input Shape
A fan-in task receives a
Map
, where keys are parent task IDs and values are their results.
Example:
try (TaskPipeline pipeline = new TaskPipeline()) {
SyncTask<String, String> fetchUser = (id, ctx) -> “User: “ + id;
SyncTask<String, String> fetchPerms = (id, ctx) -> “Role: admin”;
Task<Map<String, Object>, String> combine = (inputs, ctx) -> CompletableFuture.supplyAsync(() -> {
String userData = (String) inputs.get(”userNode”);
String permsData = (String) inputs.get(”permsNode”);
return userData + “ (” + permsData + “)”;
});
pipeline.addTask(”userNode”, fetchUser)
.addTask(”permsNode”, fetchPerms)
.addTask(”combiner”, combine);
pipeline.connect(”userNode”, “combiner”).connect(”permsNode”, “combiner”);
String result = (String) pipeline.run(”user-1”).join();
System.out.println(result); // “User: user-1 (Role: admin)”
}5. A Clear Run Contract
Executing a pipeline is straightforward:
String input = “my-data”;
// Blocking approach
try {
String result = (String) pipeline.run(input).join();
System.out.println(”Result (blocking): “ + result);
} catch (Exception e) {
System.err.println(”Pipeline failed: “ + e.getMessage());
}
// Non-blocking approach
pipeline.run(input)
.thenAccept(result -> System.out.println(”Result (non-blocking): “ + result))
.exceptionally(ex -> {
System.err.println(”Async pipeline failed: “ + ex.getMessage());
return null;
});6. A Built-in Resource Lifecycle
JGraphlet implements
AutoCloseable
. Use try-with-resources to guarantee safe shutdown of internal resources.
Example:
try (TaskPipeline pipeline = new TaskPipeline()) {
pipeline.add(”taskA”, new SyncTask<String, String>() {
@Override
public String executeSync(String input, PipelineContext context) {
if (input == null) {
throw new IllegalArgumentException(”Input cannot be null”);
}
return “Processed: “ + input;
}
});
pipeline.run(”data”).join();
} // pipeline.shutdown() is called automatically
System.out.println(”Pipeline resources have been released.”);7. Context
PipelineContext
is a thread-safe, per-run workspace for metadata.
Example:
SyncTask<String, String> taskA = (input, ctx) -> {
ctx.put(”requestID”, “xyz-123”);
return input;
};
SyncTask<String, String> taskB = (input, ctx) -> {
String reqId = ctx.get(”requestID”, String.class).orElse(”unknown”);
return “Processed input “ + input + “ for request: “ + reqId;
};8. Optional Caching
Tasks can opt into caching to prevent re-computation.
Task<String, String> expensiveApiCall = new Task<>() {
@Override
public CompletableFuture<String> execute(String input, PipelineContext context) {
System.out.println(”Performing expensive call for: “ + input);
return CompletableFuture.completedFuture(”Data for “ + input);
}
@Override
public boolean isCacheable() { return true; }
};
try (TaskPipeline pipeline = new TaskPipeline()) {
pipeline.add(”expensive”, expensiveApiCall);
System.out.println(”First call...”);
pipeline.run(”same-key”).join();
System.out.println(”Second call...”);
pipeline.run(”same-key”).join(); // Result is from cache
}The result is a clean, testable way to orchestrate synchronous or asynchronous tasks for composing complex flows, such as parallel retrieval, merging, judging, and guardrails - without requiring a heavyweight workflow engine.
Originally published at Foojay.io
And now, let’s review some of the other cool things that appeared on Foojay.io last month 😁
Foojay Podcast #79: AI4Devs Interviews - Part 1
In this episode, Frank Delporte doesn’t have one guest - he has an entire conference. The podcast is the first part of his special on-site coverage from AI4Devs in Amsterdam. Frank plays the role of a “front-line” reporter, roaming with a microphone and grabbing short, punchy conversations with key speakers and attendees. Instead of one long discussion, we get a series of quick hits about the AI revolution in programming.
The whole episode distills what the industry is buzzing about right now. Frank talks with heavyweights like Josh Long and Christian Tzolov about what’s new in Spring AI, asks Brian Vermeer for the brutal truth about AI security and developer responsibility, and catches Anton Arhipov from JetBrains to discuss the future of tooling. It’s a dense survey of topics: from scaling AI on Quarkus, through LLM costs, to real use cases that are already working today.
PS: You can find part two here.
How MongoDB Decides What to Forget?
Before we return to AI (and we will), here’s a side topic—what does it actually mean when MongoDB “forgets” something? Elie Hannouch’s How MongoDB Decides What to Forget? dives into the fascinating, rarely discussed page-eviction mechanism in the WiredTiger storage engine. It’s not about simply deleting old documents (like with TTL indexes), but about the continuous process of managing the RAM cache. The author explains that eviction isn’t “clean-up,” but a complex “arbitration between volatility and durability,” deciding which chunks of data must leave RAM to make room for new operations.
The key takeaway is that the whole system acts like a control-theory feedback loop, dynamically regulating load and escalating eviction aggressiveness as memory fills up. For engineers, this means the system’s “health” isn’t indicated by a static memory size, but by the frequency of its oscillations—signs that the system is actively “breathing” and managing data instead of choking under load.
7 Habits of Highly Effective Java Coding
Back to AI. We all use it today - but does it help us write better code, or just produce a “ball of mud” faster? Jonathan Vila’s 7 Habits of Highly Effective Java Coding outlines seven concrete habits, with two standout ones: the “Golden Rule” (you, not AI, are responsible for the code) and “feeding the beast” (give AI full context, not scraps). The rest is pure discipline: avoiding the ball of mud, verifying code and dependencies, writing meaningful tests, and ruthless, human code review.
The most important lesson is brutally simple: AI is only your co-pilot, but you’re the one landing the plane - and your name is on the pull request. The takeaway: treat AI-generated code like any other dependency from the internet - with absolute zero trust.
Agents Meet Databases: The Future of Agentic Architectures
AI agents need data, and Thibaut Gourdel’s article Agents Meet Databases: The Future of Agentic Architectures explores how to give them database access safely. The main dilemma is choosing one of two paths: rapid standardization (like the MCP protocol), which works “plug-and-play,” or fully custom integration (e.g., with LangChain), which offers total control at the cost of much more work.
The cautionary note is that naively wiring an agent to a database is a shortcut to disaster. The key takeaway for developers: the connection itself isn’t the problem; securing it is the real work. The article boils this down to three pillars: ensuring accuracy (what if the AI misreads the schema?), security (how do you prevent data leaks?), and performance (how do you ensure unpredictable AI queries don’t take production down?). Conclusion: the future belongs to strict access rules and workload isolation.
Transactions and ThreadLocal in Spring
And since Spring Framework 7 is about to be released—have you ever wondered how @Transactional in Spring really works? Transactions and ThreadLocal in Spring by Nicolas Frankel explains the key mechanism: ThreadLocal. It’s the “magical” variable Spring (via TransactionSynchronizationManager) uses to bind the transaction context—like the active database connection—to a single execution thread. That’s why when your service calls a repository, both components “see” the same transaction even though you never pass it as a parameter.
The article bluntly highlights a fundamental limitation of this magic: it only works on that one thread. The key takeaway for developers is this: if, inside a transactional method, you “jump” to a new thread (e.g., via @Async or new Thread()), you lose the transaction. The new thread has an empty ThreadLocal and starts working outside your transaction - a simple recipe for data corruption. Understanding this is essential to avoid hellishly hard-to-debug errors.
And it’s all, folks!
BTW: Greetings from Øredev in Malmø. I have my talk here today.
BTW: No edition next week - I’m on 🌴 and my wife wouldn’t be happy seeing me with laptop 😁







