Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

LangChain4j Introduction & Architecture

A complete LangChain4j introduction for Java developers: core abstractions, the AiServices declarative style, memory, tools and retrieval — plus an honest comparison with Spring AI.

Beginner6 min readUpdated
On this page

LangChain4j takes a different route to the same destination as Spring AI. Where Spring AI gives you a fluent client you call, LangChain4j lets you declare an interface and generates the implementation. Both work. The declarative style is unusually pleasant once it clicks.

Hello, AiServices

The whole thing
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.openai.OpenAiChatModel;
import dev.langchain4j.service.AiServices;
 
// 1. Declare what you want as an ordinary Java interface.
interface Assistant {
    String chat(String message);
}
 
public class Demo {
    public static void main(String[] args) {
 
        ChatModel model = OpenAiChatModel.builder()
                .apiKey(System.getenv("OPENAI_API_KEY"))
                .modelName("gpt-4o-mini")
                .temperature(0.2)
                .build();
 
        // 2. LangChain4j implements the interface against the model.
        Assistant assistant = AiServices.create(Assistant.class, model);
 
        // 3. Call it like any other bean.
        System.out.println(assistant.chat("Explain Java records in two sentences."));
    }
}

There is no prompt assembly, no response parsing and no HTTP client. The interface is the contract.

The architecture

AiServices composes memory, retrieval, tools and output parsing around a chat model.

Five concepts carry the library:

ConceptWhat it does
ChatModelTalks to a provider. Swappable; the rest of your code does not change.
ChatMemoryHolds conversation history within a bounded window.
ContentRetrieverFetches relevant context — usually from an EmbeddingStore.
@ToolA method the model may invoke.
AiServicesWires the above into a proxy behind your interface.

Prompts as annotations

SupportAssistant.java
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.V;
 
interface SupportAssistant {
 
    @SystemMessage("""
            You are a support agent for the Acme billing product.
            Answer only questions about Acme billing.
            Never invent prices, policies or account details.
            """)
    String answer(@UserMessage String question);
 
    @SystemMessage("You extract structured data. Output nothing but the requested fields.")
    @UserMessage("Extract the customer's intent from: {{message}}")
    Intent classify(@V("message") String message);
}

The prompt lives next to the method that uses it, which — unlike a prompt buried in a string constant three classes away — means it gets reviewed when the method changes.

Typed return values

This is where the declarative style earns its keep. The return type drives the parsing.

enum Sentiment { POSITIVE, NEUTRAL, NEGATIVE }
 
record Ticket(String summary, Priority priority, List<String> components) {
    enum Priority { LOW, MEDIUM, HIGH, CRITICAL }
}
 
interface Analyst {
 
    // Returns the enum directly. LangChain4j constrains the model to the
    // permitted values, so you cannot receive "VERY_POSITIVE".
    Sentiment sentimentOf(String review);
 
    // Returns a record. The JSON schema is derived from the type.
    Ticket triage(String bugReport);
 
    // Even booleans work, which makes routing logic trivial.
    boolean isSpam(String message);
}
Analyst analyst = AiServices.create(Analyst.class, model);
 
if (analyst.isSpam(message)) {
    return;
}
Ticket ticket = analyst.triage(message);

Compare that with parsing free text into a Ticket by hand, with retries for malformed JSON. The library does the schema derivation, the instruction and the deserialisation.

Memory

import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.service.MemoryId;
 
interface Assistant {
    // @MemoryId keeps each user's conversation separate.
    String chat(@MemoryId String userId, @UserMessage String message);
}
 
Assistant assistant = AiServices.builder(Assistant.class)
        .chatModel(model)
        // A memory per user, each bounded to 20 messages. Without a bound the
        // prompt grows on every turn until it exceeds the context window —
        // and you pay for the entire history on every single call.
        .chatMemoryProvider(userId -> MessageWindowChatMemory.withMaxMessages(20))
        .build();

Tools

Calculator.java
import dev.langchain4j.agent.tool.Tool;
 
class BookingTools {
 
    private final BookingRepository bookings;
 
    BookingTools(BookingRepository bookings) {
        this.bookings = bookings;
    }
 
    @Tool("Look up a booking by its reference. Returns the booking details or NOT_FOUND.")
    String getBooking(String reference) {
        return bookings.findByReference(reference)
                .map(Booking::describe)
                .orElse("NOT_FOUND: no booking with reference " + reference);
    }
 
    @Tool("Cancel a booking. Only call this after the customer has explicitly confirmed.")
    String cancelBooking(String reference) {
        // Authorisation belongs here, in code — not in the prompt.
        return bookings.cancel(reference) ? "CANCELLED" : "ERROR: could not cancel";
    }
}
Assistant assistant = AiServices.builder(Assistant.class)
        .chatModel(model)
        .tools(new BookingTools(repository))
        // Cap the loop. A confused agent will otherwise retry a failing tool
        // until it exhausts your budget.
        .maxSequentialToolsInvocations(5)
        .build();

Full treatment in LangChain4j agents and tools.

Retrieval in three lines

RAG with AiServices
import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever;
import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore;
 
// Ingest: split, embed, store.
EmbeddingStoreIngestor.builder()
        .documentSplitter(DocumentSplitters.recursive(500, 100))
        .embeddingModel(embeddingModel)
        .embeddingStore(embeddingStore)
        .build()
        .ingest(documents);
 
// Retrieve: attach to the service and RAG happens on every call.
Assistant assistant = AiServices.builder(Assistant.class)
        .chatModel(model)
        .contentRetriever(EmbeddingStoreContentRetriever.builder()
                .embeddingStore(embeddingStore)
                .embeddingModel(embeddingModel)
                .maxResults(5)
                .minScore(0.6)          // drop weak matches
                .build())
        .build();

The retriever runs before every call, injects what it finds, and your interface method signature never changes. See LangChain4j retrievers and RAG.

Using it from Spring Boot

LangChain4jConfig.java
package com.javaagentic.demo.config;
 
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.openai.OpenAiChatModel;
import dev.langchain4j.service.AiServices;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
 
@Configuration
public class LangChain4jConfig {
 
    @Bean
    ChatModel chatModel(@Value("${OPENAI_API_KEY}") String apiKey) {
        return OpenAiChatModel.builder()
                .apiKey(apiKey)
                .modelName("gpt-4o-mini")
                .temperature(0.2)
                .timeout(java.time.Duration.ofSeconds(60))
                .maxRetries(3)
                .build();
    }
 
    // Now injectable anywhere, like any other bean.
    @Bean
    Assistant assistant(ChatModel chatModel) {
        return AiServices.builder(Assistant.class)
                .chatModel(chatModel)
                .chatMemoryProvider(id -> MessageWindowChatMemory.withMaxMessages(20))
                .build();
    }
}

LangChain4j vs Spring AI, honestly

LangChain4jSpring AI
StyleDeclarative interfacesFluent client + advisors
Framework couplingNone requiredSpring Boot
ConfigurationBuilders, or Spring/Quarkus startersapplication.yml auto-config
Document loadersBroader out of the boxGrowing
ObservabilityListener API, wire it upMicrometer built in
Learning curveVery low for the happy pathVery low if you know Spring
Ceremony for custom behaviourMore manual wiringAdvisors are a clean seam

Choose Spring AI if you are inside Spring Boot and want configuration, metrics and testing to follow the conventions your team already uses.

Choose LangChain4j if you want framework independence, if the declarative style fits how you think, or if you need one of its integrations that Spring AI does not have yet.

Use both if it helps — this happens more often than either project's documentation suggests. A common arrangement is Spring AI for the request path (because it is already wired into your metrics and security) and LangChain4j for offline ingestion (because its document loaders cover more formats). They are ordinary libraries; nothing prevents it.

Next

Frequently Asked Questions

What is LangChain4j?
LangChain4j is a Java library for building applications on top of large language models. It provides chat model abstractions, embedding stores, document loaders, chat memory, tool calling and retrieval, plus a declarative AiServices style where you define a Java interface and the library implements it against a model.
Is LangChain4j a port of Python LangChain?
It was inspired by it but is not a port. The abstractions are designed around Java idioms — interfaces, builders, annotations, type-driven output parsing — rather than translated from Python. If you know Python LangChain, expect familiar concepts and unfamiliar APIs.
LangChain4j or Spring AI?
Spring AI if your application is already Spring Boot and you value auto-configuration and built-in Micrometer observability. LangChain4j if you want framework independence, its broader integration surface, or the declarative AiServices style. Both are stable at 1.x, and using both in one application is common and unproblematic.
Does LangChain4j work with Spring Boot?
Yes. There are Spring Boot starters that auto-configure models and let you declare AI Services as beans, and it also works perfectly well with plain builders inside a @Configuration class. Quarkus support is available through the LangChain4j Quarkus extension.
Can LangChain4j run models locally?
Yes, through the Ollama integration for chat models and through in-process ONNX embedding models such as all-MiniLM-L6-v2, which needs no external service at all. Local embeddings are a good default for development because they remove both cost and network latency from your test loop.

Related tutorials