Embabel vs LangGraph4j: Two Agentic Philosophies for Investment and Risk Analysis in BFSI
The architectural divide between state-machine rigidity and agentic flexibility in financial systems, comparing stateful, multi-agent workflows.
Join the DZone community and get the full member experience.
Join For FreeQuick Summary
- Both Embabel and LangGraph4j let a Java developer build multi-step AI agents without leaving the JVM.
- Embabel hands the framework a goal and a bag of typed actions, and lets a planner decide the order on its own.
- LangGraph4j asks the developer to draw the exact graph of nodes and edges by hand.
- We will understand both philosophies through a real Embabel agent, a small Kolkata street-crossing example, a comparison table, and finally a bigger question — is Java catching up with Python in enterprise AI work?
Where the Story Starts
If you are a Java developer today, you are watching two worlds collide. On one side are large language models, which grew up almost entirely in Python. On the other side is enterprise Java, which has spent twenty-five years learning to build systems that banks, insurance companies, and hospitals can actually trust.
Two frameworks are now trying to bring these two worlds together on the JVM: Embabel and LangGraph4j. Both help you build an "agent" — a piece of software that uses an LLM to complete a task in several steps, rather than in one single prompt. But the way they think about "steps" is completely different. That difference is what this article is about.
Meeting Embabel Through a Real Piece of Code
The best way to understand Embabel is to look at actual code, not a slide. Here is a small agent that gives retirement planning advice, written the Embabel way.
package com.example.demo;
import com.embabel.agent.api.annotation.AchievesGoal;
import com.embabel.agent.api.annotation.Action;
import com.embabel.agent.api.annotation.Agent;
import com.embabel.agent.api.common.OperationContext;
import com.embabel.agent.domain.io.UserInput;
import java.util.Arrays;
import java.util.List;
@Agent(name = "RetirementPlannerAgent", description = "This agent provides retirement planning advice.")
public class RetirementPlannerAgent {
record RetirementUserInput(int presentAge, int targetRetirementAge, double annualIncome) {
}
record RetirementPlanAdvice(String advice) {
}
record RetirementPlanAdvices(List<RetirementPlanAdvice> advices) {
}
enum RiskToleranceLevel {
LOW, MEDIUM, HIGH
}
@Action(description = "Identify the present age, target retirement age, and annual income of the user from the user message.")
public RetirementUserInput identifyRetirementUserInput(UserInput userInput, OperationContext context) {
String content = userInput.getContent();
return context.ai().withDefaultLlm()
.creating(RetirementUserInput.class)
.fromPrompt("""
Identify the present age, target retirement age, and annual income of the user from the following message and return them as a JSON object.
User message: %s
""".formatted(content));
}
@Action(description = "Identify the risk tolerance level of the user based on the present age, target retirement age, and annual income.")
public RiskToleranceLevel identifyRiskToleranceLevel(RetirementUserInput retirementUserInput, OperationContext context) {
return context.ai().withDefaultLlm()
.creating(RiskToleranceLevel.class)
.fromPrompt("""
Identify the risk tolerance level of the user based on the following information and return it as a JSON object.
Permitted values for risk tolerance level are: %s
Present age: %d
Target retirement age: %d
Maximum years to retirement: %d
Annual income: %.2f
""".formatted(Arrays.toString(RiskToleranceLevel.values()), retirementUserInput.presentAge(),
retirementUserInput.targetRetirementAge(),
(retirementUserInput.targetRetirementAge() - retirementUserInput.presentAge()),
retirementUserInput.annualIncome()));
}
@Action(description = "Provide retirement plan advice based on the user's risk tolerance level.")
@AchievesGoal(description = "Provide retirement plan advice based on the user's risk tolerance level.")
public RetirementPlanAdvices provideRetirementPlanAdvice(RiskToleranceLevel riskToleranceLevel, OperationContext context) {
String systemPrompt = """
You are a retirement planning advisor. Based on the user's risk tolerance level, provide a list of retirement plan advices.
""";
return context.ai().withDefaultLlm()
.creating(RetirementPlanAdvices.class)
.fromPrompt("""
%s
User's risk tolerance level: %s
""".formatted(systemPrompt, riskToleranceLevel.name()));
}
}
Now look closely at what is missing from this code. There is no method called runAgent() that calls identifyRetirementUserInput(), then identifyRiskToleranceLevel(), then provideRetirementPlanAdvice(), in that order. Nowhere did the developer type out the sequence.
Instead, each @Action simply states two things:
- What type it needs as input (its precondition).
- What type it produces as output (its effect).
provideRetirementPlanAdvice needs a RiskToleranceLevel. identifyRiskToleranceLevel happens to produce a RiskToleranceLevel from a RetirementUserInput. And identifyRetirementUserInput produces that RetirementUserInput from the raw UserInput. Embabel's planner looks at all this at runtime and works out, on its own, that this is the only order in which the goal (@AchievesGoal) can be reached.
This is Embabel's whole philosophy in one sentence: give the framework a goal and a set of typed building blocks, and let it plan.
The Same Investment Advisory Workflow, Wired by Hand in LangGraph4j
Now let us build the exact same three-step advisory flow — read the user's numbers, work out risk tolerance, give advice — the LangGraph4j way. Here, the developer draws the graph explicitly, and the shared state is a plain key-value map (an AgentState) rather than Soham's strongly typed records.
package com.example.demo;
import org.bsc.langgraph4j.CompiledGraph;
import org.bsc.langgraph4j.StateGraph;
import org.bsc.langgraph4j.action.NodeAction;
import org.bsc.langgraph4j.state.AgentState;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.bsc.langgraph4j.StateGraph.END;
import static org.bsc.langgraph4j.StateGraph.START;
import static org.bsc.langgraph4j.action.AsyncNodeAction.node_async;
/**
* @author Soham Sengupta
* @since 2026-09-13
* @description The retirement/investment advisory workflow from the
* Embabel example above, this time wired explicitly as a LangGraph4j
* graph. Every step, and the order between the steps, is declared
* here by the developer - there is no planner discovering it.
*/
public class RetirementPlannerGraph {
// Step 1: pull the present age, target retirement age, and income out of free text.
static class IdentifyUserInputNode implements NodeAction<AgentState> {
@Override
public Map<String, Object> apply(AgentState state) throws Exception {
String userMessage = state.<String>value("userMessage").orElseThrow();
// In a real system: call the LLM here (say, via langchain4j) and parse
// presentAge / targetRetirementAge / annualIncome out of userMessage.
return Map.of(
"presentAge", 32,
"targetRetirementAge", 60,
"annualIncome", 1200000.0);
}
}
// Step 2: classify how much investment risk this user can reasonably take.
static class IdentifyRiskToleranceNode implements NodeAction<AgentState> {
@Override
public Map<String, Object> apply(AgentState state) throws Exception {
int presentAge = state.<Integer>value("presentAge").orElseThrow();
int targetRetirementAge = state.<Integer>value("targetRetirementAge").orElseThrow();
// In a real system: call the LLM here with these values and ask it to
// return LOW, MEDIUM, or HIGH as the risk tolerance level.
String riskTolerance = (targetRetirementAge - presentAge) > 20 ? "HIGH" : "MEDIUM";
return Map.of("riskTolerance", riskTolerance);
}
}
// Step 3: turn the risk tolerance into a concrete list of investment advice.
static class ProvideAdviceNode implements NodeAction<AgentState> {
@Override
public Map<String, Object> apply(AgentState state) throws Exception {
String riskTolerance = state.<String>value("riskTolerance").orElseThrow();
// In a real system: call the LLM here to draft actual advice - suitable
// Indian investment instruments for this riskTolerance level, and so on.
List<String> advice = List.of("Suggested investment mix for a " + riskTolerance + " risk profile.");
return Map.of("advice", advice);
}
}
public static void main(String[] args) throws Exception {
StateGraph<AgentState> graph = new StateGraph<>(AgentState::new)
.addNode("identifyUserInput", node_async(new IdentifyUserInputNode()))
.addNode("identifyRiskTolerance", node_async(new IdentifyRiskToleranceNode()))
.addNode("provideAdvice", node_async(new ProvideAdviceNode()))
.addEdge(START, "identifyUserInput")
.addEdge("identifyUserInput", "identifyRiskTolerance")
.addEdge("identifyRiskTolerance", "provideAdvice")
.addEdge("provideAdvice", END);
CompiledGraph<AgentState> workflow = graph.compile();
Optional<AgentState> result = workflow.invoke(
Map.of("userMessage", "I am 32, want to retire at 60, and earn 12 lakh a year."));
result.ifPresent(state -> System.out.println(state.data()));
}
}
Two things stand out next to the Embabel version. First, there is a main method here that explicitly lists identifyUserInput -> identifyRiskTolerance -> provideAdvice as edges — in the Embabel version, that sequence was never written down anywhere; it was worked out by the planner. Second, the shared state (AgentState) is just a bag of string keys and values, read back out with state.value("presentAge"), instead of Soham's own strongly typed RetirementUserInput and RiskToleranceLevel. For this particular workflow, which happens to be a strict straight line with no branching, LangGraph4j's graph is refreshingly easy to read top to bottom. The real difference shows up once branching enters the picture, which is exactly where our next example — crossing a Kolkata road — comes in.
A Bit of History: From Servlet to Spring, Now From Spring AI to Embabel
To understand why Embabel is built this way, it helps to know who built it.
Embabel comes from Rod Johnson — the same person who created the Spring Framework more than two decades ago. Back in the early 2000s, enterprise Java was drowning in heavy J2EE application servers and Enterprise Java Beans. Rod was solving real problems in the finance industry at the time, found the existing tools too heavy, and wrote a book and a framework that simplified things a great deal. That framework became Spring, and it changed how an entire generation of Java developers worked.
In 2025, Rod did something similar again, this time for AI agents. When he introduced Embabel to the Java community, he framed it using a comparison that Java developers will find very familiar: Spring AI is to Embabel roughly what the plain old Servlet API once was to Spring MVC. Spring AI gives you the low-level plumbing — talking to a model, building a prompt, calling a tool. Embabel sits one level above that, giving you the actual application framework — goals, actions, planning, and a proper domain model — the same kind of jump in abstraction that Spring itself brought to raw Servlets and EJBs, twenty years back.
Embabel (pronounced "Em-BAY-bel") is written mainly in Kotlin, but as you can see from the retirement planner code above, it feels completely natural to use from plain Java. It is also built to sit closely with Spring, which is exactly why an existing Spring shop can pick it up without much friction.
GOAP: The Planning Engine Hiding Inside Embabel
The planning idea inside Embabel is not new — it is borrowed from video games, and it is called GOAP, short for Goal-Oriented Action Planning. GOAP was built to make game characters (think of soldiers in an old shooter game) decide, on their own, a believable sequence of actions to reach a goal, instead of following a fixed script.
GOAP needs three things:
- A state of the world as it stands right now.
- A set of actions, each with a precondition (what must be true to run it) and an effect (what becomes true after it runs).
- A goal, which is simply a desired state.
A search algorithm (usually the well-known A* algorithm) then works out the cheapest chain of actions that gets you from where you are to where you want to be.
Embabel uses exactly this idea, but instead of asking the LLM to "think step by step" about the plan (which is often unreliable) or asking the developer to hard-code the entire flow (which is rigid), it asks a deterministic planner to search over your own typed Java or Kotlin methods. The precondition of an action is simply the input type it needs. The effect is simply the output type it produces. Your domain classes — RetirementUserInput, RiskToleranceLevel, RetirementPlanAdvices — literally become the "world state" the planner reasons about. No LLM guesswork is involved in deciding the order; the LLM is only used inside each action, for the part it is actually good at — understanding and generating language.
Understanding the GOAP + OOAD Loop, With a Kolkata Traffic Signal
All this can feel a bit abstract, so let us make it concrete with something every Kolkata resident understands very well — crossing a busy road.
Picture Soham standing at a signal with his four-year-old son, Kit, holding his hand. It is a typical Kolkata crossing — buses, yellow taxis, and autos, and the signal, while present, is not always fully obeyed. Sometimes there is a traffic constable standing in the middle of the road, waving vehicles through by hand, overriding the signal completely.
Step one — model the world as an object (this is the OOAD part). In Object-Oriented Analysis and Design, we are trained to represent a real-world situation as a class with clearly named fields. Here, the "world" Soham is observing can be written as one simple record:
record RoadSituation(boolean signalGreen, boolean trafficClear, boolean handHeld, boolean policeOnDuty) { }
Step two — define the goal. The goal is not "the signal is green." The goal is "Soham and Kit have reached the other side, safely." Reaching a green signal is only useful if it actually leads there.
Step three — define the actions, each with its precondition and effect (this is the GOAP part). Notice that none of these actions know about each other. Each one only knows what it needs and what it produces:
- holdChildHand – produces handHeld = true. This is usually the very first thing a responsible parent does, well before even looking at the signal.
- waitForSafeSignal – needs the current RoadSituation, and produces signalGreen = true once either the light turns green or a traffic constable is present and waving pedestrians across.
- confirmTrafficClear – because a green signal in Kolkata does not always mean an auto will not sneak through, this action looks both ways and produces trafficClear = true.
- crossTheRoad (the
@AchievesGoalaction) – only runs once handHeld, trafficClear, and (signalGreen or policeOnDuty) are all true.
Step four — let the planner loop. This is the actual "GOAP + OOAD loop": the planner looks at the current RoadSituation object, picks whichever action's precondition is already satisfied and whose effect moves the world closer to the goal, executes it, updates the RoadSituation, and checks again if the goal is reached. It keeps looping — plan, act, update state, re-check — until crossTheRoad finally fires.
The beautiful part is what happens on a day when the signal itself is not working — a fairly common event in Kolkata, especially during a power cut or during Puja season when the police fully take charge of a crossing. If tomorrow you add one more action, say waitForPoliceWave, which also produces a "safe to proceed" fact, the planner will simply discover this new path on its own the next time it runs. Nobody needs to redraw anything, because nobody drew anything explicit in the first place.
The Same Scenario as Embabel Code
Here is a simplified sketch of the above scenario, written in the same style as the retirement planner. Treat it as a teaching example rather than a compiled, production-ready class:
/**
* @author Soham Sengupta
* @since 2026-09-13
* @description A small agent that plans how Soham and his four-year-old
* son Kit can safely cross a busy Kolkata road. Written purely to show
* how Embabel's GOAP-style planner reasons over typed domain objects
* (OOAD) to reach a goal, without the developer wiring the order by hand.
*/
@Agent(name = "StreetCrossingAgent", description = "Plans a safe road crossing for a parent and a young child.")
public class StreetCrossingAgent {
record RoadSituation(boolean signalGreen, boolean trafficClear, boolean handHeld, boolean policeOnDuty) {
}
record CrossingPlan(String narrative) {
}
@Action(description = "Hold Kit's hand before anything else - the first rule of the road.")
public RoadSituation holdChildHand(UserInput userInput) {
return new RoadSituation(false, false, true, false);
}
@Action(description = "Wait till the signal turns green, or till a traffic constable waves pedestrians across.")
public RoadSituation waitForSafeSignal(RoadSituation situation, OperationContext context) {
boolean safeToMove = situation.signalGreen() || situation.policeOnDuty();
return new RoadSituation(safeToMove, situation.trafficClear(), situation.handHeld(), situation.policeOnDuty());
}
@Action(description = "Look right, then left, then right again - the signal alone is not a guarantee in Kolkata traffic.")
public RoadSituation confirmTrafficClear(RoadSituation situation) {
return new RoadSituation(situation.signalGreen(), true, situation.handHeld(), situation.policeOnDuty());
}
@Action(description = "Cross only when hand is held, the way is clear, and the signal or constable allows it.")
@AchievesGoal(description = "Soham and Kit have reached the other side of the road safely.")
public CrossingPlan crossTheRoad(RoadSituation situation) {
return new CrossingPlan(
"Soham held Kit's hand tight, waited for the green man, checked both sides once more, then crossed together.");
}
}
Now compare this with how the same scenario would look in LangGraph4j's philosophy — as an explicit graph you draw yourself, branches and all:
package com.example.demo;
import org.bsc.langgraph4j.CompiledGraph;
import org.bsc.langgraph4j.StateGraph;
import org.bsc.langgraph4j.action.AsyncEdgeAction;
import org.bsc.langgraph4j.state.AgentState;
import java.util.Map;
import java.util.Optional;
import static org.bsc.langgraph4j.StateGraph.END;
import static org.bsc.langgraph4j.StateGraph.START;
import static org.bsc.langgraph4j.action.AsyncNodeAction.node_async;
public class StreetCrossingGraph {
// Stand-ins for a real signal sensor and a quick look both ways.
private static boolean checkSignal() { return true; }
private static boolean lookBothWays() { return true; }
public static void main(String[] args) throws Exception {
StateGraph<AgentState> graph = new StateGraph<>(AgentState::new)
.addNode("holdHand", node_async(state -> Map.of("handHeld", true)))
.addNode("waitForSignal", node_async(state -> {
boolean policeOnDuty = state.<Boolean>value("policeOnDuty").orElse(false);
return Map.of("signalGreen", checkSignal() || policeOnDuty);
}))
.addNode("checkTraffic", node_async(state -> Map.of("trafficClear", lookBothWays())))
.addNode("cross", node_async(state -> Map.of("crossed", true)))
.addEdge(START, "holdHand")
.addEdge("holdHand", "waitForSignal")
.addConditionalEdges("waitForSignal",
AsyncEdgeAction.edge_async(state ->
state.<Boolean>value("signalGreen").orElse(false) ? "proceed" : "retry"),
Map.of("proceed", "checkTraffic", "retry", "waitForSignal"))
.addConditionalEdges("checkTraffic",
AsyncEdgeAction.edge_async(state ->
state.<Boolean>value("trafficClear").orElse(false) ? "proceed" : "retry"),
Map.of("proceed", "cross", "retry", "checkTraffic"))
.addEdge("cross", END);
CompiledGraph<AgentState> workflow = graph.compile();
Optional<AgentState> result = workflow.invoke(Map.of("policeOnDuty", false));
result.ifPresent(state -> System.out.println(state.data()));
}
}
Notice the two addConditionalEdges calls — this is how LangGraph4j handles a branch: an edge action returns a label ("proceed" or "retry"), and a small map resolves that label to the actual next node. This is exactly the graph the developer must draw by hand, action by action and branch by branch, for a scenario that Embabel's planner worked out on its own.
As a flowchart, that graph looks like this:

Both pieces of code reach the same goal. But in Embabel, nobody drew this flowchart — the planner found it. In LangGraph4j, this flowchart is the code. If a new real-world case turns up tomorrow, Embabel's planner can absorb it automatically as long as the new action's types fit; LangGraph4j needs a human to open the graph and add a new node or edge.
Comparing the Two Philosophies
|
Aspect |
Embabel |
LangGraph4j |
|---|---|---|
|
Core idea |
Give a goal and typed actions; a GOAP/A* planner works out the order |
Developer explicitly wires nodes and edges into a graph |
|
Mental model |
"What do I want, and what building blocks do I have?" |
"What are my steps, and how do they branch?" |
|
Control flow |
Discovered at runtime by the planner |
Declared upfront by the developer |
|
Role of the LLM |
Used only inside actions, never for deciding sequence |
Can be used inside nodes; sequence is still fixed by the graph |
|
Adapting to a new case |
Often automatic, if a new action's types fit the gap |
Needs a human to add a new node or edge |
|
Tracing "why this order" |
Needs the planner's own logging/tooling to see the chosen path |
Very direct — the graph is already the flowchart |
|
Roots |
Kotlin-first, Java-friendly, close to Spring |
A faithful Java port of Python's LangGraph, works with Langchain4j and Spring AI |
|
Maturity (as of late 2026) |
Young, pre-1.0, moving fast |
Older and more widely adopted, with a large existing community |
When to Reach for Which
Reach for Embabel when:
- Your goal is clear, but the exact path to it can honestly vary depending on the situation.
- You want a deterministic, non-LLM planner deciding the order, not the LLM guessing it.
- You are already deep in the Spring ecosystem and like strongly typed domain models.
- New cases keep appearing over time, and you would rather add one new action than redraw a graph.
Reach for LangGraph4j when:
- You already know the exact stages of your workflow — say, a well-understood pipeline of retrieve, rerank, generate, and validate.
- You want the flow to be visible as an actual graph, easy to explain to a non-technical stakeholder.
- Your team is porting an existing Python LangGraph pipeline and wants the Java version to mirror it closely.
- You value a larger, more mature community with more examples to learn from, at least for now.
Neither approach is "better" in an absolute sense. Embabel bets on planning; LangGraph4j bets on explicitness. Pick the one that matches how well you actually know your workflow in advance.
To Conclude: Java Still Has a Say in Enterprise AI
Python remains, without question, the home of AI research — the notebooks, the training loops, the enormous ecosystem of machine learning libraries were built there first, and will likely stay there. Nobody sensible is arguing Java should train the next large language model.
But training a model is only one part of the story. The other part — the much bigger part, in terms of sheer lines of code running in the real world — is taking an already-trained model and safely wiring it into systems that already exist: a bank's core banking platform, an insurance company's policy engine, a hospital's records system. The overwhelming majority of that existing code, in most large enterprises, is written in Java and Spring, not Python. That is Java's home ground, built up over more than two decades.
This is exactly the ground both Embabel and LangGraph4j are fighting on. Java also tends to run this kind of orchestration work faster than Python at execution time, which matters once you are calling these agents thousands of times a day inside a live enterprise system. And when your applications are already written in Java, keeping the AI layer in Java too — rather than routing every call out to a separate Python service — often turns out to be the simpler, safer choice.
So, the real contest in enterprise AI is perhaps not "who trains the smarter model" — Python wins that one comfortably. It is "who can be trusted to make that model's decisions reliably inside a bank's core system, an insurance engine, or a hospital record system." That is precisely the kind of trust Java has spent two decades earning. With Rod Johnson effectively writing a sequel to his own Spring story, and with LangGraph4j bringing a proven Python pattern faithfully onto the JVM, Java is not sitting out this wave of AI. It has simply chosen to fight the battle it already knows how to win.
This piece focused on Embabel's goal-and-planner philosophy. Embabel also has other ideas worth a separate deep-dive later — like its approach to agentic search and enterprise memory. A hands-on, step-by-step guide to setting up Embabel from scratch will follow as a companion piece.
Here's the link to the source code: https://github.com/trainerpb/embabel-hello-world/tree/feature/revision.
Opinions expressed by DZone contributors are their own.
Comments