Part one built a two-node LangGraph4j graph and used it to explain state,
channels, and checkpoints. Part two added cross-cutting tracing
without touching either node. Both posts left one thing untouched on purpose: IntentClarifierNode and ResponderNode
both implement NodeAction<SimpleState> directly, the textbook way to write a LangGraph4j node.
This post asks when that textbook way is the right choice, and when a different seam is worth the extra files.
Verdict up front: use
NodeActiondirectly by default. Add a domain object and an adapter the moment any of these are true you need to unit-test nodes in isolation, persist state to a database, or protect node logic from framework churn.
The Two Approaches
Approach A Direct NodeAction (standard)
The node owns the framework interface. It takes the graph state, computes a result, and returns a map delta:
public class ResponderNode implements NodeAction<SimpleState> {
@Override
public Map<String, Object> apply(SimpleState state) {
String response = translate(state.sourceText(), state.targetLanguage());
return Map.of(SimpleState.FINAL_RESPONSE, response);
}
}
Wired into the graph with no intermediary:
graph.addNode("responder", new ResponderNode());
Approach B (Domain Object + Adapter)
The node knows nothing about the graph. It takes and returns a plain typed record:
public class ResponderNode {
public IntentTranslationState execute(IntentTranslationState state) {
String response = translate(state.sourceText(), state.targetLanguage());
return state.withFinalResponse(response);
}
}
A thin adapter in the graph-wiring code bridges the two worlds:
private NodeAction<SimpleState> responderAction() {
return state -> {
IntentTranslationState before = SimpleStateAdapter.toDomain(state);
IntentTranslationState after = responderNode.execute(before);
return SimpleStateAdapter.diff(before, after);
};
}
graph.addNode("responder", responderAction());
Side-by-Side
| Approach A (Direct NodeAction) | Approach B (Domain + Adapter) | |
|---|---|---|
| Type safety | typo’d key compiles, fails at runtime | compiler enforces every field and type |
| Framework coupling | node imports org.bsc.langgraph4j |
zero langgraph4j imports in nodes |
| Unit testing | must construct AgentState map wrapper |
execute(record) — no framework object |
| Boilerplate | none, idiomatic, fewer files | adapter + domain record files |
| Ecosystem fit | matches all official langgraph4j examples | custom pattern, not conventional |
The Domain Type
IntentTranslationState is a plain Java record, no LangGraph4j import, no AgentState ancestry:
public record IntentTranslationState(
String userQuery,
String clarificationAnswer,
String targetLanguage,
String sourceText,
String finalResponse
) implements Serializable {
public IntentTranslationState withFinalResponse(String value) {
return new IntentTranslationState(
userQuery, clarificationAnswer, targetLanguage, sourceText, value);
}
// one withX() per field nodes need to change
}
execute(IntentTranslationState): IntentTranslationState is now a pure function typed input, typed output, no graph in between.
Testing it is responder.execute(new IntentTranslationState(...)) and an assertion on the result; no AgentState, no map, no graph.
Where the Coupling Goes
Removing a dependency doesn’t remove the need for it, it moves it. SimpleStateAdapter is that one place:
final class SimpleStateAdapter {
static IntentTranslationState toDomain(SimpleState state) {
return new IntentTranslationState(
state.userQuery(), state.clarificationAnswer(),
state.targetLanguage(), state.sourceText(), state.finalResponse());
}
static Map<String, Object> diff(IntentTranslationState before, IntentTranslationState after) {
Map<String, Object> delta = new LinkedHashMap<>();
putIfChanged(delta, SimpleState.FINAL_RESPONSE, before.finalResponse(), after.finalResponse());
// one putIfChanged per field — only emits keys that actually changed
return delta;
}
}
The graph wiring, conditional edges, NodeTraceHook from Part Two, and the checkpoint saver are all unchanged they already
operated on SimpleState and Map<String, Object> one level away from the node. This is the Anti-Corruption Layer pattern
from Domain-Driven Design and Ports and Adapters from Hexagonal Architecture: execute(IntentTranslationState): IntentTranslationState
is the port the domain defines for itself; SimpleStateAdapter plus the wiring lambda is the adapter that plugs LangGraph4j into it.
Swap the graph engine, and the core never changes.
Doesn’t This Break Part One’s “Nodes Return Deltas” Rule?
Part One showed that a node should return a partial update so untouched fields aren’t erased. ResponderNode.execute()
now returns a full IntentTranslationState. That looks like a regression.
It isn’t because that full object never reaches the graph directly. SimpleStateAdapter.diff() compares before and after
snapshots and emits only the keys that changed. The partial-update contract is intact; it moved from inside each node to the adapter boundary,
where diff() computes it once.
When Approach A Is Right
Use implements NodeAction directly when:
- The graph is stateless: no HITL pause-and-resume, no DB serialization, no checkpoints you manage yourself.
- State is message-centric: a conversation history list fits naturally into
MessagesState<T>; a separate domain record adds nothing. - The graph is small and stable: two to four nodes, unlikely to grow; testing nodes through the graph is acceptable.
- Ecosystem alignment matters: working alongside teams using langgraph4j conventions, or following the official examples closely.
Every official langgraph4j project — AgentExecutor, CallModel, langgraph4j-deepagents
falls into this category. None of them persist state outside LangGraph4j’s own checkpointing or need isolated node-level tests.
When Approach B Is Right
Reach for the adapter seam when any one of these is true:
- HITL or cross-request persistence you serialize and restore state across separate HTTP requests. A
Serializablerecord with named fields is safe to version and migrate; a rawMap<String, Object>is not. - Per-request infrastructure that can’t live in the map event emitters, streaming channels, coroutine jobs. These belong in a per-request runtime object; nodes take them as parameters rather than reading them from graph state.
- Node-level unit tests
node.execute(state)is one line.node.apply(new AgentState(Map.of(...)))plus unwrapping the returned map is framework noise that multiplies across every test file. - Package layering if your
node/package must not import from yourgraph/package (domain must not depend on wiring),NodeAction<YourGraphState>creates a cycle; the adapter removes it.
The Honest Tradeoff
For a two-node graph, IntentTranslationState and SimpleStateAdapter are two files that duplicate SimpleState’s fields
for a payoff the graph barely needs yet. The textbook approach is fewer files, zero indirection, and completely adequate when none of the Approach B triggers apply.
The one-time cost of the adapter pays for itself once the graph grows: more nodes sharing the same state shape, logic worth testing in isolation, or a state object that lives outside the graph. The decision rule is simple: default to Approach A, switch to Approach B the moment any trigger fires.