Skip to main content

Class 5: LangChain4j Integration

Duration: ~25 minutes | Difficulty: Advanced | Prerequisites: Class 4 completed; Ollama with a tool-calling model.

Classes 5 to 7 are this course's framework track: the same catalog and the same safety layer driven by LangChain4j (this lesson), LangGraph4j (Class 6), and Spring AI (Class 7). Pick the lesson that matches your stack, and read the others for comparison. Classes 8 to 12 write their code against Spring AI, because the examples need one framework. What those lessons teach applies to all three.


Project code

This lesson carries on from Class 4. If you followed along, keep working on the project you already have. If you skipped it, clone the agents_class_4 branch to start from the same place:

git clone --branch agents_class_4 https://github.com/graphqlguy/graphQLBeyondMCP.git

Code blocks are titled with the file they belong to, and the note in brackets says what to do with it. (new file) is a file to create. (edited, whole file) replaces a file you already have. Anything else names the one part to add or replace, as in (edited, the call method). Where a block shows part of a new file, the complete file follows in a collapsed block underneath.

Everything the course has built so far works without a framework, by design:

  • a generated catalog
  • a plain-text allow-list
  • a callback that logs in, gates writes and counts calls

This lesson plugs all of it into LangChain4j, and the work came to two small classes. This course claims that the schema work is the part that stays stable when the framework changes. If that is true, adopting a second framework should take an afternoon. This lesson tests the claim against a framework the course had not used before.

Spring AI can do everything this lesson does, so why teach LangChain4j at all? This course promises that your GraphQL schema works as a tool contract for whatever agent framework your team already uses. On the JVM, there are two mainstream frameworks.

LangChain4j is common in Quarkus and plain-Java teams. It is also common in teams that use LangChain's concepts in both their Python and Java services. As of August 2026 it is a mature project: version 1.19.0, under the Apache 2.0 license, with roughly thirteen thousand GitHub stars. Its programming model differs enough from Spring AI's to be worth comparing. None of LangChain4j's two-hundred-plus published modules covers GraphQL, and nobody has ported Python LangChain's BaseGraphQLTool. So we did not find a ready-made integration, and we build one in this lesson.

The framework arrives as two ordinary dependencies, the core library and the Ollama model binding:

pom.xml (edited, the added dependencies)
<properties>
<langchain4j.version>1.19.0</langchain4j.version>
</properties>

<!-- LangChain4j, the second JVM agent framework this course teaches. The plain
GA libraries, deliberately: the Boot 4 starters exist but are beta-versioned
as of August 2026, and the course pins GA artifacts only. -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-ollama</artifactId>
<version>${langchain4j.version}</version>
</dependency>

The Boot 4 starters (-spring-boot4-starter) exist. This project skips them, because they are beta-versioned while the core is GA. Without them we lose a few auto-configured beans, and the lc4j-agent command below builds the chat model itself with a four-line builder.

The AiServices Idea​

LangChain4j's best-known abstraction works the other way round from the previous lessons. Those lessons wrote a loop that calls a model. With LangChain4j, we declare the agent as a Java interface, and the framework generates the implementation:

src/main/java/com/graphqlguy/moviedb/agents/langchain/MovieConcierge.java (new file)
package com.graphqlguy.moviedb.agents.langchain;

import dev.langchain4j.service.SystemMessage;

/**
* LangChain4j's main idea: the agent is an interface, and the framework
* synthesizes the implementation. Calling chat() runs the whole loop (model,
* tools, memory) behind an ordinary Java method call. Compare this with the
* hand-driven loop of Class 4 and Spring AI's ChatClient in Class 7. All three
* run on the same tool layer.
*/
public interface MovieConcierge {

@SystemMessage("""
You are a concierge for the Movie Database. Use the tools to answer and to act;
never invent ids, and never claim an action succeeded without a tool result
proving it. Writes may require the human's approval; if a write is denied,
accept the decision, explain it, and ask what they would like instead.
When you need an id (a watchlist, a movie), find it with a read tool first.""")
String chat(String message);
}

The system message is the same text the hand-driven runner used in Class 4, moved from a Java constant onto an annotation. The text moves across unchanged, which is the first piece of evidence for this lesson's claim: the prompt, like the catalog, belongs to the application, and any framework can use it.

Calling chat("add the movie with id 3 to my watchlist") runs the whole conversation behind one ordinary method:

  • the model call
  • the tool-execution loop
  • the memory update
  • the final answer

Spring Data repositories and Feign clients use the same style: an interface states the contract, and a proxy supplies the implementation. A builder assembles the proxy:

MovieConcierge concierge = AiServices.builder(MovieConcierge.class)
.chatModel(chatModel) // any of ~25 providers; here Ollama, locally
.tools(bridgedTools) // the generated catalog, bridged below
.maxToolCallingRoundTrips(8) // the model-call ceiling, framework edition
.chatMemory(MessageWindowChatMemory.withMaxMessages(20))
.build();

The hand-driven loops of Classes 3 and 4 handled memory without naming it. They rebuilt the conversation list themselves on each turn, so the memory was simply that list. When the framework owns the loop, memory becomes a separate component that we can replace. MessageWindowChatMemory keeps the last N messages, which is the simplest policy. It also controls token cost, which Class 10 returns to: every message the window keeps is sent again on every call, and paid for each time.

The Bridge: The Same Facts, in LangChain4j's Types​

LangChain4j describes a tool with two objects. A ToolSpecification carries the name, description and parameters, and is what the model sees. A ToolExecutor runs when the model calls. The parameters are where the two frameworks differ. Spring AI accepted our JSON Schema as a string, and LangChain4j models the same information as typed objects: JsonObjectSchema, JsonEnumSchema, JsonIntegerSchema and the rest. So the bridge includes a small translator that walks our schema maps into their builders. The information is the same and only the form differs, so the translation is mechanical.

src/main/java/com/graphqlguy/moviedb/agents/langchain/LangChainSchemaMapper.java (new file, one part)
public class LangChainSchemaMapper {

@SuppressWarnings("unchecked")
public JsonObjectSchema toObjectSchema(Map<String, Object> schema) {
JsonObjectSchema.Builder builder = JsonObjectSchema.builder();
Object description = schema.get("description");
if (description instanceof String text) {
builder.description(text);
}
Map<String, Object> properties =
(Map<String, Object>) schema.getOrDefault("properties", Map.of());
for (Map.Entry<String, Object> property : properties.entrySet()) {
builder.addProperty(property.getKey(),
toElement((Map<String, Object>) property.getValue()));
}
Object required = schema.get("required");
if (required instanceof List<?> names) {
builder.required(names.stream().map(Object::toString).toList());
}
return builder.build();
}

@SuppressWarnings("unchecked")
private JsonSchemaElement toElement(Map<String, Object> schema) {
String description = schema.get("description") instanceof String s ? s : null;
if (schema.get("enum") instanceof List<?> values) {
return JsonEnumSchema.builder()
.enumValues(values.stream().map(Object::toString).toList())
.description(description)
.build();
}
String type = String.valueOf(schema.get("type"));
return switch (type) {
case "integer" -> JsonIntegerSchema.builder().description(description).build();
case "number" -> JsonNumberSchema.builder().description(description).build();
case "boolean" -> JsonBooleanSchema.builder().description(description).build();
case "array" -> JsonArraySchema.builder()
.items(toElement((Map<String, Object>) schema.get("items")))
.description(description)
.build();
case "object" -> toObjectSchema(schema);
default -> JsonStringSchema.builder().description(description).build();
};
}
}
The complete LangChainSchemaMapper.java, collapsed because it runs to 61 lines
src/main/java/com/graphqlguy/moviedb/agents/langchain/LangChainSchemaMapper.java
package com.graphqlguy.moviedb.agents.langchain;

import dev.langchain4j.model.chat.request.json.JsonArraySchema;
import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
import dev.langchain4j.model.chat.request.json.JsonEnumSchema;
import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
import dev.langchain4j.model.chat.request.json.JsonStringSchema;

import java.util.List;
import java.util.Map;

/**
* Translates the catalog's JSON Schema maps into LangChain4j's typed schema model.
* Spring AI accepts a JSON Schema string; LangChain4j models the same information
* as objects (JsonObjectSchema, JsonEnumSchema and similar classes). The facts are
* the same in a second format. The catalog stays the same, and each framework
* reads it in its own format.
*/
public class LangChainSchemaMapper {

@SuppressWarnings("unchecked")
public JsonObjectSchema toObjectSchema(Map<String, Object> schema) {
JsonObjectSchema.Builder builder = JsonObjectSchema.builder();
Object description = schema.get("description");
if (description instanceof String text) {
builder.description(text);
}
Map<String, Object> properties =
(Map<String, Object>) schema.getOrDefault("properties", Map.of());
for (Map.Entry<String, Object> property : properties.entrySet()) {
builder.addProperty(property.getKey(),
toElement((Map<String, Object>) property.getValue()));
}
Object required = schema.get("required");
if (required instanceof List<?> names) {
builder.required(names.stream().map(Object::toString).toList());
}
return builder.build();
}

@SuppressWarnings("unchecked")
private JsonSchemaElement toElement(Map<String, Object> schema) {
String description = schema.get("description") instanceof String s ? s : null;
if (schema.get("enum") instanceof List<?> values) {
return JsonEnumSchema.builder()
.enumValues(values.stream().map(Object::toString).toList())
.description(description)
.build();
}
String type = String.valueOf(schema.get("type"));
return switch (type) {
case "integer" -> JsonIntegerSchema.builder().description(description).build();
case "number" -> JsonNumberSchema.builder().description(description).build();
case "boolean" -> JsonBooleanSchema.builder().description(description).build();
case "array" -> JsonArraySchema.builder()
.items(toElement((Map<String, Object>) schema.get("items")))
.description(description)
.build();
case "object" -> toObjectSchema(schema);
default -> JsonStringSchema.builder().description(description).build();
};
}
}

Notice that the enum check comes before the type switch. A GraphQL enum arrives as {"type": "string", "enum": [...]}. Testing the type first would classify it as a plain string and drop the value list. That list is what stops a model from inventing a genre. Checking the more specific case first prevents that. The same ordering matters wherever one JSON Schema shape refines another.

The bridge builds both objects for every tool. Read the ToolExecutor closely, because this is where the lesson's claim is tested:

src/main/java/com/graphqlguy/moviedb/agents/langchain/LangChainToolBridge.java (new file)
package com.graphqlguy.moviedb.agents.langchain;

import com.graphqlguy.moviedb.agents.catalog.GraphQlToolCallback;
import com.graphqlguy.moviedb.agents.toolgen.OperationTool;
import dev.langchain4j.agent.tool.ToolSpecification;
import dev.langchain4j.service.tool.ToolExecutor;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;

/**
* The whole LangChain4j integration fits in this small class. Each generated
* tool becomes a ToolSpecification (what the model sees) paired with a
* ToolExecutor (what runs), and the executor simply delegates to the SAME
* GraphQlToolCallback that served Spring AI in Classes 2 and 4, so the login,
* the approval gate, the run budget, and the persisted operations all carry
* over unchanged into the new framework.
*/
public class LangChainToolBridge {

private final LangChainSchemaMapper schemaMapper = new LangChainSchemaMapper();

public Map<ToolSpecification, ToolExecutor> bridge(
List<OperationTool> tools,
Function<OperationTool, GraphQlToolCallback> callbackFactory) {
Map<ToolSpecification, ToolExecutor> bridged = new LinkedHashMap<>();
for (OperationTool tool : tools) {
ToolSpecification specification = ToolSpecification.builder()
.name(tool.name())
.description(tool.description())
.parameters(schemaMapper.toObjectSchema(tool.inputSchema()))
.build();
GraphQlToolCallback callback = callbackFactory.apply(tool);
ToolExecutor executor = (request, memoryId) -> {
System.out.println("tool call: " + request.name() + " " + request.arguments());
return callback.call(request.arguments());
};
bridged.put(specification, executor);
}
return bridged;
}
}

The bridge takes a factory, because the callback needs the login, the gate and the budget of one particular run. The command that starts the run assembles those. The println is the source of the tool call: lines in the transcript further down.

The executor delegates to the same GraphQlToolCallback that served Spring AI in Classes 2 and 4. We did not edit that file for this lesson, so everything it does carries over:

  • The real login is still used.
  • The approval gate still pauses every mutation.
  • The tool-call ceiling still counts.
  • The persisted operations still mean that the model does not write any GraphQL.
  • The argument repair still fixes flattened arguments.

These controls survived the framework change because Class 4 put them where execution happens, instead of in the loop. The next section covers the one control that did move, and why.

The reply loop in that command is short, because the framework already keeps the memory. MessageWindowChatMemory in the builder makes a second chat call continue the first. So when the model asks "which watch list?", the user can answer, and the model goes on to make the write.

Who Owns the Loop, Owns the Loop's Controls​

The first LangChain4j run ended with a budget line that looked wrong:

budget: 0 model calls (ceiling 8), 2 tool calls (ceiling 6)

It shows two tool calls and zero model calls, although the run clearly made several model calls. Model-call counting lived in Class 4's hand-written loop. This lesson handed the loop to AiServices, so the loop no longer runs our counter. The tool-call ceiling still works, because it lives in the callback. The general rule follows: a control at the execution layer survives a framework change. A control in the loop belongs to whoever owns the loop, so it has to move when the owner changes.

The diagram shows the three layers for this lesson and marks the control that moved. Classes 6 and 7 redraw the same diagram, and only the top layer and the dashed arrow change:

In LangChain4j, the model-call ceiling is maxToolCallingRoundTrips(int), which the builder above sets. The builder also accepts maxSequentialToolsInvocations(int). That is the same ceiling under another name, and its one line delegates to the first.

Lesser-known fact

LangChain4j has a builder option for a failure you are likely to meet in production: hallucinatedToolNameStrategy decides what happens when the model requests a tool that does not exist. By default, the run fails with an exception that names the invented tool, which is a safe default. The strategy is pluggable, so a deployment can instead answer the model with an error message as a tool result. That usually prompts self-correction, at the price of another round trip. A mainstream framework has a dedicated setting for invented tool names, which shows how common this failure is. The allow-list from Class 2 keeps it harmless here.

The Run​

Check It Works​

mvn clean spring-boot:run

Choose lc4j-agent, then answer the prompt:

task: add The Matrix to my To watch this weekend watchlist

The prerequisites are the same as in Class 4: the service runs on 8081, and Ollama on 11434. Check that the header now says LangChain4j (AiServices), while the approval prompt, the tool names and the budget line match what you saw in Class 4. If a run reaches the write without pausing, the callbacks were built without the gate.

The lc4j-agent command assembles the same three controls as Class 4's agent command, then hands them to the framework instead of to a hand-written loop:

The complete Lc4jAgentCommand.java, collapsed because it runs to 85 lines
src/main/java/com/graphqlguy/moviedb/agents/command/Lc4jAgentCommand.java (new file)
package com.graphqlguy.moviedb.agents.command;

import com.graphqlguy.moviedb.agents.auth.AuthSession;
import com.graphqlguy.moviedb.agents.catalog.GraphQlToolCallback;
import com.graphqlguy.moviedb.agents.langchain.LangChainToolBridge;
import com.graphqlguy.moviedb.agents.langchain.MovieConcierge;
import com.graphqlguy.moviedb.agents.safety.ApprovalGate;
import com.graphqlguy.moviedb.agents.safety.RunBudget;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.model.ollama.OllamaChatModel;
import dev.langchain4j.service.AiServices;
import org.springframework.stereotype.Component;

import java.util.List;

/**
* Class 5: the same catalog and the same safety layer, with LangChain4j owning
* the loop. The callbacks underneath are the ones Class 4 built, unchanged.
*/
@Component
public class Lc4jAgentCommand implements AgentCommand {

@Override
public String name() {
return "lc4j-agent";
}

@Override
public int order() {
return 50;
}

@Override
public String summary() {
return "the same task through LangChain4j's AiServices";
}

@Override
public String prompt() {
return "task";
}

@Override
public void run(List<String> args, CommandContext context) {
String task = String.join(" ", args);
if (task.isBlank()) {
System.out.println("lc4j-agent needs a task");
return;
}
String role = context.role();
AuthSession auth = context.login();
RunBudget budget = context.budget();
ApprovalGate gate = new ApprovalGate(context.console());

var bridged = new LangChainToolBridge().bridge(
context.tools(role),
tool -> new GraphQlToolCallback(tool, context.endpoint(), auth, gate, budget));
var chatModel = OllamaChatModel.builder()
.baseUrl(context.env().getProperty("spring.ai.ollama.base-url", "http://localhost:11434"))
.modelName(context.modelName())
.build();
MovieConcierge concierge = AiServices.builder(MovieConcierge.class)
.chatModel(chatModel)
.tools(bridged)
// The framework owns this loop, so the model-call ceiling must live in
// the framework; the tool-call ceiling stays in the callback, where it
// works whoever is driving.
.maxToolCallingRoundTrips(
context.env().getProperty("agents.budget.max-model-calls", Integer.class, 8))
.chatMemory(MessageWindowChatMemory.withMaxMessages(20))
.build();

System.out.println("framework : LangChain4j (AiServices)");
System.out.println("role : " + role + " (" + bridged.size() + " tools)");
System.out.println("user : " + (context.username().isBlank()
? "(anonymous)" : context.username()));
System.out.println("task : " + task);
System.out.println();

// The framework already keeps the history: MessageWindowChatMemory above is
// what makes a second call continue the first. This command only has to ask
// the user for a reply, so when the model answers a task with a question, the
// user can answer it and the run continues.
String turn = task;
while (!turn.isBlank()) {
String answer = concierge.chat(turn);
System.out.println();
System.out.println("answer: " + answer);
System.out.println("budget: " + budget.summary());
System.out.println();
System.out.print("reply, or blank to return to the menu: ");
turn = context.console().hasNextLine() ? context.console().nextLine().strip() : "";
System.out.println();
}
}
}

Every line up to var bridged is copied from the agent command. The login, the gate and the budget are assembled the same way, whichever code runs the loop. The model-call ceiling is the one control that had to move, and the comment in the builder says where it went.

Here is an unedited run of the same kind of task as in Class 4, through the new framework:

framework : LangChain4j (AiServices)
role : concierge (7 tools)
user : user
task : add the movie with id 3 to my To watch this weekend watchlist

tool call: myWatchLists {}
tool call: addWatchListItem {"input":{"titleId":"3","titleType":"MOVIE","watchListId":"1"}}

APPROVAL REQUIRED: the agent wants to run a write.
operation : mutation Tool_addWatchListItem($input: AddWatchListItemInput!) { ... }
arguments : {"input":{"titleId":"3","titleType":"MOVIE","watchListId":"1"}}
Execute this mutation? [y/N] approved by the human.

answer: The movie with ID 3 has been successfully added to your "To watch this
weekend" watchlist.
budget: 0 model calls (ceiling 8), 2 tool calls (ceiling 6)

The run has the same shape as the hand-driven loop: it finds the watch list first, then writes. The same gate pauses the write, and the same budget limits the calls, while our own loop code is now a builder call. The budget line still shows zero model calls, for the reason the previous section explains.

If you assemble your own project, note that LangChain4j runs inside the existing Boot application here, and the command above builds the chat model by hand. That four-line builder is the extra work the plain GA libraries need.

Summary​

LangChain4j gets its own lesson because many JVM teams already use it. It is the JVM's other mainstream agent framework, and it does not ship a GraphQL module. Its AiServices model is a different way to write the same conversation: we declare the agent as a Java interface, and the framework builds the loop, with memory included.

The integration took two pieces:

  • a translator from our schema maps to LangChain4j's own schema types
  • a bridge that pairs each generated tool with an executor, which delegates to the unchanged GraphQlToolCallback

That shows the course's portability claim in practice. The login, the approval gate, the budgets and the persisted operations all moved to the new framework unchanged. The one exception follows the design rule: a control in the loop belongs to the loop's owner. So the model-call ceiling moved into maxToolCallingRoundTrips, and everything at the execution layer stayed where it was. The verified run made an approved write through the same gate as Class 4, with two tool calls and a different framework underneath.

Further Reading​

  • LangChain4j: AI Services: the agent-as-interface model, memory, and the builder options this lesson uses.
  • LangChain4j: Tools: both registration styles, the @Tool annotations this lesson skipped and the programmatic ToolSpecification route it used, plus the hallucinated-tool-name strategy.
  • LangChain4j: model providers: the provider matrix; Ollama's tools-and-streaming support is what Class 6's graph runs depend on.
  • langchain4j/langchain4j on GitHub: releases and the module list, useful for confirming the state of the beta Boot starters before adopting them.

What's Next​

AiServices hid the loop, and Class 6 does the opposite. LangGraph4j, the JVM port of LangGraph, makes the agent's control flow an explicit, typed state machine. It has nodes, conditional edges, checkpoints and interrupts that pause a run, and those interrupts let the framework itself provide an approval gate as well. Class 6 uses the same catalog with a third programming model. It is the last lesson before Spring AI's ChatClient completes the track.