Picking Between LangChain4j 1.15 and Spring AI 2.0

I spent weeks weighing LangChain4j 1.15 against Spring AI 2.0 before writing a single production line for ITJobOpportunities. Here's the honest breakdown, with real code, of what I picked and why it mattered for shipping AI features that actually work.

LangChain4j feels like a toolbox built by people who came from the LangChain Python ecosystem and ported the mental model over. Spring AI feels like something the Spring team built from scratch to fit Spring Boot's conventions. Neither is better in some abstract sense. They solve different problems well, and I ended up using pieces of both in different corners of jobs-posting . Setting Up the Model Client This is where the philosophical difference shows up first. LangChain4j wants you to build a model instance explicitly, almost like constructing a JDBC connection by hand. Spring AI wants you to inject a ChatModel bean and let Spring's autoconfiguration handle the wiring. Here's the LangChain4j 1.15 version, pointed at OpenRouter through the OpenAI-compatible endpoint: ChatLanguageModel model = OpenAiChatModel.builder() .baseUrl("https://openrouter.ai/api/v1") .apiKey(System.getenv("OPENROUTER_API_KEY")) .modelName("deepseek/deepseek-chat") .temperature(0.2) .timeout(Duration.ofSeconds(30)) .logRequests(true) .logResponses(true) .build(); String response = model.generate("Extract the top 5 technical skills from this resume text: ..."); And here's roughly the same thing in Spring AI 2.0, where the client comes from configuration and gets injected: spring: ai: openai: base-url: https://openrouter.ai/api/v1 api-key: ${OPENROUTER_API_KEY} chat: options: model: deepseek/deepseek-chat temperature: 0.2 @Service public class SkillsExtractionService { private final ChatClient chatClient; public SkillsExtractionService(ChatClient.Builder builder) { this.chatClient = builder.build(); } public String extractSkills(String resumeText) { return chatClient.prompt() .user("Extract the top 5 technical skills from this resume text: " + resumeText) .call() .content(); } } The Spring AI version felt more natural the moment I wrote it, mostly because it's the same pattern I already use for every other bean in jobs-posting . LangChain4j's builder pattern isn't bad, it's just one more thing to configure by hand instead of dropping into application.yml . On ITJobOpportunities, everything runs on Spring Boot 3.5.6 with Java 25, so that autoconfiguration convenience adds up fast across a codebase with a dozen different AI-touching services. Structured Output: Where LangChain4j Surprised Me This is the part where I'll admit LangChain4j won a round. Getting a model to return clean, typed JSON for something like a Job Fit result (score, matched skills, missing skills, a short summary) matters a lot when your frontend is polling an SSE endpoint expecting a specific shape. LangChain4j has an AiServices pattern that lets you define an interface, and it handles the prompt-to-POJO mapping for you: public interface JobFitAnalyzer { @SystemMessage("You are a technical recruiter assistant. Compare the resume against the job description.") @UserMessage("Resume: {{resume}}\nJob description: {{jobDescription}}") JobFitResult analyze(@V("resume") String resume, @V("jobDescription") String jobDescription); } JobFitAnalyzer analyzer = AiServices.create(JobFitAnalyzer.class, model); JobFitResult result = analyzer.analyze(resumeText, jobDescriptionText); No manual JSON schema, no parsing step in my controller. LangChain4j inspects the return type, builds the schema behind the scenes, and hands back a populated JobFitResult record. When it works, it's the cleanest structured output experience I've had in Java. Spring AI gets you most of the way there with BeanOutputConverter , but you have to be more deliberate about it: BeanOutputConverter<JobFitResult> converter = new BeanOutputConverter<>(JobFitResult.class); String prompt = """ Resume: %s Job description: %s %s """.formatted(resumeText, jobDescriptionText, converter.getFormat()); JobFitResult result = chatClient.prompt() .user(prompt) .call() .entity(converter); It works, and it's honestly fine once you've written the pattern once but LangChain4j's interface-based approach removes a whole category of prompt-plumbing mistakes, which is the kind of thing that matters when four different services on your team are calling four different LLM prompts and you don't want each one hand-rolling its own format instructions. Model Provider Support Everything I run goes through OpenRouter's OpenAI-compatible endpoint, so on paper both frameworks treat it the same way, as an OpenAI-shaped chat model with a different base URL. In practice the provider abstraction is where the two frameworks diverge the most in ambition. LangChain4j ships dedicated integrations for a long list of providers: OpenAI, Azure OpenAI, Anthropic, Google Vertex AI/Gemini, Amazon Bedrock, Ollama, Mistral, HuggingFace, and more, each with its own module and its own quirks exposed through the builder. If I wanted to run Claude for one flow and DeepSeek through OpenRouter for another, I'd pull in langchain4j-anthropic and instantiate a second model with almost no shared configuration between the two. Spring AI takes the same breadth-of-providers approach but leans harder on convention. Switching providers is mostly a matter of swapping the spring.ai.<provider> block and the starter dependency, and the ChatClient abstraction stays identical in your service code: spring: ai: anthropic: api-key: ${ANTHROPIC_API_KEY} chat: options: model: claude-3-5-sonnet-latest That consistency is nice in theory, but I haven't needed it yet on ITJobOpportunities. Everything runs through OpenRouter because it gives me one billing surface and one rate-limit policy to reason about across DeepSeek and whatever model I want to test next. If I ever split providers by flow (say, a cheaper model for skills extraction and a stronger one for the recruiter assessment), Spring AI's config-driven swap is the one I'd reach for first, since it doesn't touch service code at all. RAG and Vector Stores I haven't shipped full RAG on ITJobOpportunities yet, but I prototyped it for the resume improvement pipeline, where grounding suggestions against a corpus of strong resume phrasing (rather than letting the model invent buzzwords) is the obvious next step. Since jobs-posting already runs on PostgreSQL, pgvector was the natural choice for both frameworks. Spring AI's VectorStore abstraction plugs into the same ChatClient idiom: @Bean VectorStore vectorStore(JdbcTemplate jdbcTemplate, EmbeddingModel embeddingModel) { return PgVectorStore.builder(jdbcTemplate, embeddingModel) .dimensions(1536) .distanceType(PgVectorStore.PgDistanceType.COSINE_DISTANCE) .build(); } public List<Document> findSimilarPhrasing(String resumeBullet) { return vectorStore.similaritySearch( SearchRequest.query(resumeBullet).withTopK(5) ); } LangChain4j's equivalent uses EmbeddingStore with a nearly identical shape, but you assemble the embedding model and the store as separate collaborators rather than getting them wired through a single bean: EmbeddingStore<TextSegment> store = PgVectorEmbeddingStore.builder() .host("localhost") .port(5432) .database("jobs_posting") .table("resume_embeddings") .dimension(1536) .build(); EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder() .baseUrl("https://openrouter.ai/api/v1") .apiKey(System.getenv("OPENROUTER_API_KEY")) .modelName("text-embedding-3-small") .build(); Embedding queryEmbedding = embeddingModel.embed(resumeBullet).content(); List<EmbeddingMatch<TextSegment>> matches = store.findRelevant(queryEmbedding, 5); Functionally these are close to a wash. Where Spring AI pulls ahead is that PgVectorStore is a first-class Spring Boot autoconfigured bean, so it shows up in the same actuator health checks and the same dependency graph as everything else in the app. That matt...