06-langchain4j

AiServices: LangChain4j's Declarative Alternative to ChatClient

Everything from the first two guides of this roadmap, rebuilt with LangChain4j's AiServices — a declarative Java interface instead of a fluent builder, with real code comparisons throughout.

August 13, 2026
langchain4jaiservicesdeclarativememoryidspring-boot-starter

A Second Opinion

A sibling team at Acme Fintech is spinning up a fraud-signals service — a new codebase, not an extension of the support bot this roadmap has been building. Before committing to Spring AI again by default, their lead wants a genuine second opinion: LangChain4j is the older, community-driven library for LLM applications on the JVM, framework-agnostic by design, with the broadest provider and vector-store coverage of anything in this space. This phase is that second opinion — built the same way the rest of this roadmap was, with real, runnable code, using the same kind of support-assistant example so the comparison to what you already know is direct.

The biggest shift to get used to: Spring AI's ChatClient is a builder you call; LangChain4j's AiServices is an interface you declare. You write a plain Java interface describing what the AI does, and LangChain4j generates the implementation for you at runtime — closer to how Spring Data generates a repository implementation from an interface than to how you'd hand-build a RestClient call.


1. Setup

groovy
// build.gradle — Spring Boot 4 (this roadmap's default)
implementation 'dev.langchain4j:langchain4j-spring-boot4-starter'
implementation 'dev.langchain4j:langchain4j-ollama-spring-boot4-starter'
groovy
// Spring Boot 3 projects use the plain starter names instead
implementation 'dev.langchain4j:langchain4j-spring-boot-starter'
implementation 'dev.langchain4j:langchain4j-ollama-spring-boot-starter'
yaml
# application.yml — note the langchain4j.* prefix, not spring.ai.*
langchain4j:
  ollama:
    chat-model:
      base-url: http://localhost:11434
      model-name: llama3.1

LangChain4j ships starters for both Spring Boot 3 and 4 in parallel (langchain4j-*-spring-boot-starter and langchain4j-*-spring-boot4-starter), exactly like Spring AI does with its own dual-track versions. Pin the exact version from Maven Central or the GitHub releases page rather than copying a number out of an article — LangChain4j ships frequent "beta"-tagged releases as its normal cadence, and it moves fast.


2. Your First AiService

Compare this directly to the very first ChatClient example from this roadmap's opening guide:

java
// LangChain4j
interface SupportAssistant {
    @SystemMessage("You are a support agent for Acme Fintech. Be concise and factual.")
    String chat(String userMessage);
}
java
// The Spring AI equivalent from this roadmap's first guide, for comparison
ChatClient chatClient = builder
    .defaultSystem("You are a support agent for Acme Fintech. Be concise and factual.")
    .build();
String reply = chatClient.prompt().user(userMessage).call().content();

With the starter on the classpath, LangChain4j auto-configures a chat model bean from application.yml and registers a Spring bean implementing SupportAssistant — you never write AiServices.builder(...) yourself in a Spring Boot app; the starter does it. Inject SupportAssistant like any other Spring bean and call .chat(...) like a plain method, because as far as your calling code is concerned, it is one.

java
@Service
public class SupportController {
    private final SupportAssistant assistant; // injected, fully wired
 
    public SupportController(SupportAssistant assistant) {
        this.assistant = assistant;
    }
 
    public String handle(String message) {
        return assistant.chat(message);
    }
}
⚠️

Outside Spring Boot (plain Java, Quarkus, Micronaut), you do call AiServices.builder(SupportAssistant.class).chatModel(model).build() yourself — the Spring Boot starter's whole value proposition is removing that one line via auto-configuration, the same trade Spring AI's starters make for ChatClient.Builder.


3. Prompt Templates: @UserMessage and @V

Where Spring AI's PromptTemplate used {placeholder} substitution via .param(...), LangChain4j templates the method signature itself:

java
interface SupportAssistant {
    @SystemMessage("You are a support agent for Acme Fintech.")
    @UserMessage("""
        Customer tier: {{tier}}
        Ticket category: {{category}}
        Draft a reply to: {{ticketText}}
        """)
    String draftReply(@V("tier") String tier, @V("category") String category, @V("ticketText") String ticketText);
}

{{doubleBraces}} mark the template variables; @V("name") binds each method parameter to one. Call it like any typed method — assistant.draftReply("premium", "BILLING", ticketText) — and LangChain4j builds the prompt from the template plus your arguments. There's no Map.of(...) to keep in sync with the template by hand; the compiler checks the parameter list for you.


4. Multiple Models: Explicit Wiring

A single @AiService-annotated interface picks up the auto-configured default model automatically. Once a service needs a specific model among several configured providers, switch to explicit wiring:

java
@AiService(wiringMode = AiServiceWiringMode.EXPLICIT, chatModel = "anthropicChatModel")
interface FraudRiskAssessor {
    @SystemMessage("You are a conservative fraud risk assessor. Prefer false positives over false negatives.")
    RiskAssessment assess(String transactionSummary);
}

chatModel = "anthropicChatModel" names the exact Spring bean this interface should bind to, rather than whichever chat model bean happens to be the default — necessary the moment a project has both an OpenAI and an Anthropic starter on the classpath and different services need different providers.


5. Structured Output: The Return Type Is the Contract

The same lesson from this roadmap's second guide, in LangChain4j's own words: change the return type, not the prompt.

java
record RiskAssessment(int riskScore, String reasoning, boolean requiresManualReview) {}
 
interface FraudRiskAssessor {
    @UserMessage("Assess the fraud risk of this transaction: {{summary}}")
    RiskAssessment assess(@V("summary") String summary);
}

No .entity(RiskAssessment.class) call needed — the interface method's return type is the instruction. LangChain4j generates the schema, appends the formatting instructions, and parses the response the same way Spring AI's BeanOutputConverter does; the API surface is just the method signature itself, one level more declarative than .entity().


6. Conversation Memory: @MemoryId

This is the piece Spring AI splits across ChatMemory, ChatMemoryRepository, and MessageChatMemoryAdvisor. LangChain4j folds it into one parameter annotation and one builder call:

java
interface SupportAssistant {
    String chat(@MemoryId Long customerId, @UserMessage String userMessage);
}
java
@Bean
ChatMemoryProvider chatMemoryProvider() {
    return memoryId -> MessageWindowChatMemory.withMaxMessages(10);
}

Every call passes a customerId as the first argument; LangChain4j uses it purely as a memory-scoping key (it never reaches the model as part of the prompt), routing to a separate MessageWindowChatMemory instance per distinct ID. Two customers calling chat(...) with different IDs get entirely isolated conversation histories from the same injected SupportAssistant bean.

🚨

dev.langchain4j.memory.chat.MessageWindowChatMemory is a different class from Spring AI's org.springframework.ai.chat.memory.MessageWindowChatMemory covered earlier in this roadmap — same name, same sliding-window idea, unrelated implementations in unrelated packages. If a project ever has both frameworks on the classpath (this phase's last guide covers exactly that combination), let your IDE's import autocomplete pick the wrong one and you'll get a confusing compile error pointing at the wrong builder API.

For persistence across restarts, back the provider with a ChatMemoryStore implementation (JDBC, Redis, and others exist as community integrations) instead of the default in-memory store — conceptually the same swap as Spring AI's InMemoryChatMemoryRepositoryJdbcChatMemoryRepository from this roadmap's second guide.


7. Streaming

java
interface SupportAssistant {
    TokenStream chatStream(@UserMessage String userMessage);
}
java
assistant.chatStream(userMessage)
    .onPartialResponse(token -> emitter.send(token))
    .onCompleteResponse(response -> emitter.complete())
    .onError(emitter::completeWithError)
    .start();

Where Spring AI streams as a reactive Flux<String> you return directly from a controller, TokenStream is callback-based — you call .start() to kick it off and handle tokens as they arrive via .onPartialResponse(...). Both get you the same perceived-latency win from this roadmap's production guide; the wiring into a Server-Sent Events endpoint just looks different on each side.


What's Next

You've rebuilt the declarative equivalent of everything from this roadmap's first two guides. The next guide covers tools, RAG, and LangChain4j's own answer to multi-step agents — including a workflow-composition API with no direct Spring AI equivalent.

Frequently asked questions

Can I use @AiService and ChatClient in the same Spring Boot application?

Yes, and the last guide in this phase shows exactly that combination. Both starters auto-configure independently; there's no conflict as long as you're deliberate about which beans back which interfaces when both an OpenAI ChatModel and a LangChain4j equivalent are on the classpath.

Does @MemoryId need to be the first parameter?

No — LangChain4j identifies it by the annotation, not position, though keeping it first is a common convention for readability. What's required is exactly one @MemoryId-annotated parameter per method when a ChatMemoryProvider is configured; LangChain4j throws a configuration error if it's missing or duplicated.

Is wiringMode = EXPLICIT required as soon as I have more than one AiService?

No — it's only required when a single AiService interface needs to bind to a specific, non-default model bean. Multiple @AiService interfaces can all happily share the same auto-configured default model with no explicit wiring at all; EXPLICIT mode is for disambiguation, not a mandatory step past service #1.

How does LangChain4j know which method in my interface is the 'chat' method versus a tool or something else?

Every method on an @AiService-annotated interface is treated as an AI-backed operation by default — there's no ambiguity to resolve, because the interface's only job is describing AI operations. Tools are registered separately, on the builder or via a @Component elsewhere, and are invoked by the model during a call, not declared as methods on the AiService interface itself.