"The Second Opinion" — LangChain4j, Side by Side
The architecture review board asks what happens if Spring AI is the wrong bet, and Kabir answers with a second adapter. We implement the same ports with LangChain4j behind a profile, rebuild its index by replaying Kafka, run one test suite against both libraries, look at where Kotlin meets two Java APIs, and compare the libraries honestly. Then we wrap up the series.
Story Opening
The board’s chief architect had left a sketch on the whiteboard after the meeting: an AiFacade, a ChatPort, an EmbeddingPort, a VectorPort, and arrows to two boxes labelled “Spring AI” and “LangChain4j”. “Wrap both,” the note underneath said, “so we can swap.” It was the design fourteen years of Java had taught Kabir to draw too, whenever a dependency looked risky, and for a minute he considered building it.
Lena read it over his shoulder. “That’s a third AI library,” she said, “with the features of neither. You already have the abstraction: two interfaces, in your words, not theirs.” She pointed at ProductIndexer and ProductAssistant on his screen. “Implement them again. Whatever doesn’t fit is the comparison.”
He photographed the sketch for the architect, and wiped the board.
Java → Kotlin: The Quick Map
This part maps one AI library onto the other:
| Spring AI 2.0 | LangChain4j 1.21 | Note |
|---|---|---|
ChatClient fluent calls | An interface implemented by AiServices | Imperative builder vs declarative proxy |
VectorStore (embeds for you) | EmbeddingStore + EmbeddingModel | LangChain4j keeps the two separate |
Document with a metadata map | TextSegment with Metadata | Metadata holds strings and numbers, no lists |
FilterExpressionBuilder | MetadataFilterBuilder.metadataKey(…) | Both translate to Qdrant filters |
RetrievalAugmentationAdvisor + QueryAugmenter | DefaultRetrievalAugmentor + ContentInjector | Opposite defaults when nothing is retrieved |
| Similarity score = cosine | Relevance score = (cosine + 1) / 2 | Same threshold number, different meaning |
@Tool + @ToolParam | @Tool + @P | Different annotations with the same simple name |
initialize-schema: true | Create the collection yourself | No Boot 4 starter for LangChain4j’s Qdrant store |
spring.ai.ollama.* | langchain4j.ollama.* | Both from Boot starters |
Conceptual Deep-Dive
Abstract your needs, not the libraries
The service already defines what it needs in its own terms: two interfaces, four methods between them, written in Part 15. Each AI library is an adapter that implements them. The facade on the whiteboard would have meant designing a lowest common denominator of two fast-moving libraries and maintaining it forever. The ports describe the product instead (index this product, forget that one, find products like this, answer this question), and neither library’s concepts appear in them.
product-events")] --> L["ProductEventListener"] L --> PI(["ProductIndexer"]) W["AssistantController"] --> PA(["ProductAssistant"]) PI -.->|"profile: default"| SAI["Spring AI adapter"] PA -.->|"profile: default"| SAI PI -.->|"profile: lc4j"| LC["LangChain4j adapter"] PA -.->|"profile: lc4j"| LC SAI --> Q1[("Qdrant
products_springai")] LC --> Q2[("Qdrant
products_lc4j")] SAI & LC --> O["Ollama
same models"]
Ports and adapters are familiar Java architecture. What this part adds is the Kotlin half of the lesson the series has been building since Part 12: both libraries are Java APIs, and Kotlin’s job in an adapter is to make them feel native without letting their names and conventions leak into yours. Kotlin’s interop features do most of that work (SAM lambdas, import aliases, extension functions, const vals for annotations). It also has a few collisions of its own, in names and in overload resolution, which a Java developer wouldn’t meet at all. The Technical Explanation goes through both.
The adapter gets its own Qdrant collection and Kafka consumer group, for the reasons Part 15 gave; the lc4j profile’s new group replays the topic and builds products_lc4j from the same events that built products_springai.
Technical Explanation
The build and the profile
// Fragment of build.gradle.kts // LangChain4j (Part 16): AI services and RAG, Ollama models from the Boot 4 starter, and the Qdrant store. implementation("dev.langchain4j:langchain4j") implementation("dev.langchain4j:langchain4j-ollama-spring-boot4-starter") implementation("dev.langchain4j:langchain4j-qdrant")LangChain4j’s BOM, imported next to Spring AI’s, manages its versions: the core modules are 1.21.0, while the Qdrant store, the Spring Boot 4 starters and langchain4j-kotlin are released on a separate beta line, 1.21.0-beta31. Both libraries run on the pinned io.qdrant:client 1.19.0, although LangChain4j was built against 1.17.0 and Spring AI against 1.18.0, and the Ollama starter, built against Spring Boot 4.0.5, runs on this service’s 4.1.1; the tests for both adapters pass.
# The LangChain4j adapter. Spring AI's models and vector store are switched off; LangChain4j's Boot 4# starter creates its Ollama models because their base URLs are set here.spring: ai: model: chat: none embedding: none vectorstore: type: none
langchain4j: ollama: chat-model: base-url: http://localhost:11434 model-name: qwen3:4b-instruct # the same models as the Spring AI adapter temperature: 0.2 timeout: 120s embedding-model: base-url: http://localhost:11434 model-name: nomic-embed-text # must match: vectors from different models aren't comparable
assistant: consumer-group: product-ai-lc4j # a new group replays the topic and builds this adapter's index lc4j: collection: products_lc4j # LangChain4j stores text under "text_segment", Spring AI under "doc_content"The profile switches Spring AI’s models and vector store off; LangChain4j’s Ollama starter creates its models only when their base URLs are set, so its configuration lives in this file and nowhere else.
Infrastructure and indexing
// Fragment of lc4j/Lc4jConfiguration.kt// The LangChain4j adapter's infrastructure. The chat and embedding models come from// langchain4j-ollama-spring-boot4-starter (application-lc4j.yaml); Qdrant is wired here, because// there is no Boot 4 starter for LangChain4j's Qdrant store.@Configuration(proxyBeanMethods = false)@Profile("lc4j")class Lc4jConfiguration { @Bean(destroyMethod = "close") fun qdrantClient( @Value("\${assistant.qdrant.host}") host: String, @Value("\${assistant.qdrant.port}") port: Int, ) = QdrantClient(QdrantGrpcClient.newBuilder(host, port, false).build())
// Unlike Spring AI's initialize-schema, LangChain4j doesn't create collections: do it once, sized // to the embedding model (one probe embedding tells us the dimensions). @Bean fun embeddingStore( client: QdrantClient, embeddingModel: EmbeddingModel, @Value("\${assistant.lc4j.collection}") collection: String, ): QdrantEmbeddingStore { if (!client.collectionExistsAsync(collection).get()) { val dimensions = embeddingModel.dimension() client.createCollectionAsync( collection, VectorParams.newBuilder().setSize(dimensions.toLong()).setDistance(Distance.Cosine).build(), ).get() } return QdrantEmbeddingStore(client, collection, "text_segment") }}// Fragment of lc4j/Lc4jProductIndexer.kt@Component@Profile("lc4j")class Lc4jProductIndexer( private val embeddingModel: EmbeddingModel, private val store: QdrantEmbeddingStore,) : ProductIndexer { override fun upsert(product: ProductSnapshot) = upsert(IndexedDocuments.productId(product.sku), IndexedDocuments.productText(product), lc4jMetadata(product))
override fun delete(sku: String) = store.remove(IndexedDocuments.productId(sku))
override fun upsertPolicy(name: String, text: String) = upsert(IndexedDocuments.policyId(name), text, mapOf("type" to "policy", "policy" to name))
// addAll with explicit ids: the overload without ids (and EmbeddingStoreIngestor, which uses it) // generates random ones, and every redelivered event would add a duplicate point. private fun upsert(id: String, text: String, metadata: Map<String, Any>) { val segment = TextSegment.from(text, Metadata.from(metadata)) store.addAll(listOf(id), listOf(embeddingModel.embed(segment).content()), listOf(segment)) }
// LangChain4j metadata holds strings and numbers, not lists: the tags become one string. private fun lc4jMetadata(product: ProductSnapshot) = IndexedDocuments.productMetadata(product) + ("dietaryTags" to product.dietaryTags.joinToString(","))}Spring AI’s initialize-schema: true created its collection; LangChain4j’s store expects one to exist, so the adapter creates it, sized from embeddingModel.dimension(). The indexer’s comments carry its two traps (random ids from the convenient ingestion path, no lists in metadata), and the Gotchas expand on both. Here is the result, the same product in both collections, from Qdrant’s REST API:
products_springai: {"type":"product","sku":"SHW-1003","name":"Gluten-Free Millet Pasta 250 g","category":"PASTA_AND_GRAINS", "dietaryTags":["gluten-free","vegan"],"pricePaise":19500,"doc_content":"Gluten-Free Millet Pasta 250 g (SKU SHW-1003)…"}products_lc4j: {"type":"product","sku":"SHW-1003","name":"Gluten-Free Millet Pasta 250 g","category":"PASTA_AND_GRAINS", "dietaryTags":"gluten-free,vegan","pricePaise":19500,"text_segment":"Gluten-Free Millet Pasta 250 g (SKU SHW-1003)…"}The text key is why the libraries can’t share a collection. Pointed at Spring AI’s points, LangChain4j finds no text_segment, and its retriever fails with IllegalArgumentException: textSegment cannot be null; Spring AI, reading LangChain4j’s points, finds no doc_content. The vectors are identical, because both adapters embed the same IndexedDocuments text with nomic-embed-text: the millet pasta scores 0.76887 against “gluten-free pasta” in both.
The assistant: an interface, implemented for you
// Fragment of lc4j/Lc4jProductAssistant.kt// LangChain4j's model of an assistant: an interface, implemented at runtime by AiServices.// Result<String> carries the answer and the retrieved sources. @SystemMessage needs a constant,// which is why AssistantPrompts.SYSTEM is a const val (and keeps its indentation).interface ShopAssistant { @SystemMessage(AssistantPrompts.SYSTEM) fun answer(question: String): Result<String>}
@Component@Profile("lc4j")class Lc4jProductAssistant( private val embeddingModel: EmbeddingModel, private val store: QdrantEmbeddingStore, chatModel: ChatModel, catalogTools: CatalogTools, @Value("\${assistant.rag.similarity-threshold}") similarityThreshold: Double,) : ProductAssistant { // LangChain4j scores are "relevance", (cosine + 1) / 2, where Spring AI's are cosine similarity. // The configured threshold is a cosine, so it's converted: 0.6 cosine is 0.8 relevance. private val retriever = EmbeddingStoreContentRetriever.builder() .embeddingStore(store) .embeddingModel(embeddingModel) .minScore(RelevanceScore.fromCosineSimilarity(similarityThreshold)) .maxResults(4) .build()
private val assistant: ShopAssistant = AiServices.builder(ShopAssistant::class.java) .chatModel(chatModel) .retrievalAugmentor( DefaultRetrievalAugmentor.builder() .contentRetriever(retriever) // LangChain4j's default injector passes the question through untouched when nothing was // retrieved, which lets the model improvise. Same prompts as the Spring AI adapter instead. .contentInjector { contents, message -> val question = (message as UserMessage).singleText() UserMessage.from( if (contents.isEmpty()) { AssistantPrompts.withoutContext(question) } else { AssistantPrompts.WITH_CONTEXT.trimIndent() .replace("{context}", contents.joinToString("\n\n") { it.textSegment().text() }) .replace("{query}", question) }, ) } .build(), ) .tools(catalogTools) // LangChain4j's recommended settings: bad arguments go back to the model so it can retry, but a // tool's exception fails the call unless it was written for the model (ToolErrorVisibleToLlm), // so internal URLs and SQL never reach the prompt. .toolArgumentsErrorHandler(ToolArgumentsErrorHandler.sendExceptionMessageToLlm()) .toolExecutionErrorHandler(ToolExecutionErrorHandler.failInvocationUnlessVisibleToLlm()) .build()LangChain4j’s central idea is the AI service: you declare an interface, ShopAssistant, and AiServices implements it at runtime, much as Spring Data implements a repository. @SystemMessage supplies the system prompt; annotation values must be compile-time constants, which is why Part 15 made AssistantPrompts.SYSTEM a const val, and why its indentation reaches the model (harmless in our runs; the builder’s systemMessageProvider would take a trimmed string instead). Result<String> returns the answer together with the retrieved content, the equivalent of Spring AI’s DOCUMENT_CONTEXT.
Three pieces needed care. The retriever converts the threshold, because LangChain4j reports a different score scale (the first Gotcha). The content injector is a custom lambda, because LangChain4j’s default does the opposite of Spring AI’s when nothing is retrieved: it passes the user’s message through unchanged, so the model answers from whatever it knows. It also fills in the shared prompts’ {context} and {query} itself, so Spring AI’s placeholder syntax, one of the two leaks Part 15 admitted, stays a detail of AssistantPrompts. And the tool error handlers are set explicitly. By default LangChain4j, like Spring AI, sends a tool’s exception message to the model, and it logs a startup notice that this default will change because exception messages “can expose internal application details”. The recommended execution handler is stricter: a tool’s exception fails the call unless the tool throws one written for the model (ToolErrorVisibleToLlm). The recommended arguments handler is more lenient than today’s default: malformed arguments go back to the model so it can retry.
The Spring starter offers a shortcut for all this: an interface annotated with @AiService is implemented and registered as a bean automatically, with explicit wiring by bean names and support for @Profile. The builder is used here to keep the profile-specific pieces (the threshold conversion, the injector, the handlers) visible in one class next to the code that needs them; in a LangChain4j-only service, @AiService would be the idiomatic choice.
// Fragment of lc4j/Lc4jProductAssistant.kt override fun search(query: String, maxPricePaise: Long?, dietaryTag: String?, limit: Int): List<ProductHit> { val filter = listOfNotNull( metadataKey("type").isEqualTo("product"), maxPricePaise?.let { metadataKey("pricePaise").isLessThanOrEqualTo(it) }, dietaryTag?.let { metadataKey("dietaryTags").containsString(it) }, // a substring of "gluten-free,vegan" ).reduce(Filter::and) val request = EmbeddingSearchRequest.builder() .queryEmbedding(embeddingModel.embed(query).content()) .maxResults(limit) .filter(filter) .build() return store.search(request).matches().map { match -> val metadata = match.embedded().metadata() ProductHit( sku = metadata.getString("sku")!!, name = metadata.getString("name")!!, pricePaise = metadata.getLong("pricePaise")!!, // stored as an Int; getLong accepts any number score = 2 * match.score() - 1, // back to cosine, as the Spring AI adapter reports it ) } }search assembles its filter the same way the Spring AI adapter does, with listOfNotNull and reduce, here over Filter::and. Two lines differ for reasons above: containsString is a substring match on the joined tags, and the score is converted back to a cosine so both adapters report the same number. Under the hood the two stores also apply thresholds differently: Spring AI sends its threshold to Qdrant, while LangChain4j’s store asks Qdrant for the vectors, recomputes each cosine in the JVM and filters there.
Kotlin on two Java libraries
Both libraries are Java, and the adapters lean on four Kotlin interop features:
- SAM conversion. Spring AI’s
QueryAugmenterand LangChain4j’sContentInjectorare Java interfaces with one abstract method, so a Kotlin lambda implements each (Part 5). - Import aliases. One method carries both libraries’
@Toolannotations;import dev.langchain4j.agent.tool.Tool as Lc4jToolkeeps them apart (Part 1):
// Fragment of tools/CatalogTools.kt// Indexed prices can be minutes old; a tool lets the model ask the catalog for the price right now.// Both libraries' annotations on one method: an import alias keeps the two @Tool names apart.@Componentclass CatalogTools(private val catalog: CatalogClient) { @Tool(description = DESCRIPTION) @Lc4jTool(DESCRIPTION) fun currentPrice(@ToolParam(description = SKU) @P(SKU) sku: String): String = try { catalog.product(sku).let { "${it.name} (${it.sku}) costs ${it.price}" } } catch (e: HttpClientErrorException.NotFound) { "There is no product with SKU $sku" }
companion object { const val DESCRIPTION = "Get the current price of a Shelfwise product from the catalog, by SKU. Use it when asked what something costs now." const val SKU = "The product SKU, for example SHW-1001" }}const valfor annotations. The tool descriptions and the system prompt are constants, so both libraries’ annotations can use them, and both libraries describe the tool to the model in identical words.- Nullability through JSpecify. Both libraries publish JSpecify annotations, Spring AI across its API and LangChain4j in parts of its core (
Metadata.getStringis@Nullable), so the!!and?:in the adapters follow real contracts rather than guesses.
Two collisions are Kotlin’s own, and a test pins down each:
// Fragment of KotlinOnJavaLibrariesTest.kt// Without an import, Result is kotlin.Result: a value class, erased to Object in the JVM signature.interface ForgotTheImport { fun answer(question: String): Result<String>}
class KotlinOnJavaLibrariesTest { private val model = Lc4jRecordingChatModel() private val request = ChatRequest.builder().messages(UserMessage.from("hello")).build()
@Test fun `a member function beats an extension with the same parameters`() { // langchain4j-kotlin's suspend chatAsync(request) is shadowed by ChatModel's own chatAsync(request), // an @Experimental member whose future fails for models without a native async call (Ollama's too). val future = model.chatAsync(request) assertIs<CompletableFuture<*>>(future) assertIs<AsyncNotSupportedException>(assertFailsWith<ExecutionException> { future.get() }.cause) // Only an argument the member doesn't take selects the suspend extension. assertIs<ChatResponse>(runBlocking { model.chatAsync(request, Dispatchers.IO) }) }
@Test fun `kotlin Result is not LangChain4j's Result`() { val failure = assertFailsWith<IllegalConfigurationException> { AiServices.builder(ForgotTheImport::class.java).chatModel(model).build().answer("hi") } assertEquals("Illegal method return type: class java.lang.Object", failure.message) }}The first is a name. Most clashes between Kotlin’s default imports and a library’s names fail to compile (kotlin.Metadata against LangChain4j’s Metadata, for instance). Result is the dangerous one because it type-checks: fun answer(question: String): Result<String> without importing dev.langchain4j.service.Result declares kotlin.Result, a value class erased to Object in the method’s JVM signature, and AiServices rejects the interface only at runtime, with “Illegal method return type: class java.lang.Object”.
The second is overload resolution. langchain4j-kotlin adds a suspend fun ChatModel.chatAsync(request, coroutineContext = …), but ChatModel already has a Java member chatAsync(request), @Experimental since 1.20.0, and in Kotlin a member always beats an extension with matching arguments. The compiler doesn’t warn: model.chatAsync(request) returns the member’s CompletableFuture, and for a model without a native asynchronous call, which includes Ollama’s, that future fails with AsyncNotSupportedException. Passing a coroutine context selects the suspend extension, which runs the blocking chat on a virtual-thread dispatcher. That’s what this service’s blocking ports already get from Tomcat, so neither adapter uses it.
Step-by-Step Hands-On: One Service, Two Libraries
Code: kotlin-for-java-survivors/services/product-ai-service, tag kotlin-for-java-survivors/part-16.
Step 1 — Switch profiles and watch the replay.
docker compose -f infra/compose.yaml up -d postgres kafka qdrant ollama./gradlew :services:product-catalog-service:bootRunSPRING_PROFILES_ACTIVE=lc4j ./gradlew :services:product-ai-service:bootRunWithin seconds of starting, the new consumer group had read the 18 events on the topic (Part 15’s republish and two price changes), and products_lc4j held 19 points, the same count as products_springai: sixteen products and three policies.
Step 2 — Ask the same questions.
curl -s -X POST localhost:8081/api/assistant/ask -H 'Content-Type: application/json' \ -d '{"question":"Is there any gluten-free pasta under ₹200?"}'Same machine, same models (qwen3:4b-instruct and nomic-embed-text on four CPU cores in Docker), same prompts, one run each:
| Question | Spring AI adapter | LangChain4j adapter |
|---|---|---|
| ”Is there any gluten-free pasta under ₹200?" | "Yes, the Gluten-Free Millet Pasta (SKU SHW-1003) is gluten-free and costs ₹195.00, which is under ₹200.” (14.1 s) | The same sentence, word for word (17.4 s) |
| “My mixer grinder broke after three weeks. You have a 30-day no-questions-asked return policy, right?" | "…does not have a “no-questions-asked” return policy… unused mixer grinders can be returned within 10 days… After that period, returns are not allowed under any circumstances.” (5.8 s) | “…does not have a “no-questions-asked” return policy… your appliance broke after three weeks, which exceeds the return window. Additionally, used appliances are not returnable under any circumstances.” (15.0 s) |
| “What is the delivery charge on a ₹300 order?" | "The delivery charge on a ₹300 order is ₹40, as delivery is free only for orders of ₹499 or more. …” (7.7 s) | “The delivery charge on a ₹300 order is ₹40, as delivery is free only for orders of ₹499 or more.” (8.1 s) |
| “What does SHW-1001 cost right now?" | "…costs ₹165.00.” via the tool (5.0 s) | “…costs ₹165.00.” via the tool (4.6 s) |
| “Who won the cricket match yesterday?" | "I can’t help with that query.” (0.7 s) | “I can’t help with that. No store documents or tools provide information about cricket match results.” (1.3 s) |
With the same prompts and the same retrieved documents, the answers converge, which is the point of keeping the prompts in the ports package: differences in quality come from retrieval and models, not libraries. The return-policy answers differ by sampling (this LangChain4j run happened to state the “used appliances” rule correctly), and they share a gap: neither mentions the 24-month motor warranty, the real help for a grinder that broke after three weeks, although the policy text was in both prompts. Timings swing by several seconds between runs on a CPU, so neither column is faster in any meaningful sense.
Step 3 — One test suite, two adapters.
// Fragment of AssistantContract.kt@AssistantSpringTestclass SpringAiAssistantTest( @Autowired assistant: ProductAssistant, @Autowired chatModel: RecordingChatModel, @Autowired kafka: KafkaConnectionDetails,) : AssistantContract(assistant, chatModel, kafka)
@AssistantSpringTest@ActiveProfiles("lc4j")class Lc4jAssistantTest( @Autowired assistant: ProductAssistant, @Autowired chatModel: Lc4jRecordingChatModel, @Autowired kafka: KafkaConnectionDetails,) : AssistantContract(assistant, chatModel, kafka)Part 15’s five behavioural tests moved into an abstract AssistantContract; each subclass runs all of them against one adapter, and @ActiveProfiles("lc4j") is the only difference. Each fake chat model implements a small PromptRecorder interface, so the assertions about prompts are shared too. The suite caught the score-scale difference on its first run, before any model was involved. With the schema and interop tests, the service has 15 tests and two Spring contexts.
Step 4 — Compare.
| Spring AI 2.0 | LangChain4j 1.21 | |
|---|---|---|
| Core abstraction | ChatClient, a fluent client, with advisors around each call; reads like RestClient | AI services: an interface implemented by a proxy; reads like Spring Data |
| RAG building blocks | Retriever, augmenter, advisor; query transformers and expanders | Retriever, augmentor, injector; query transformers and routers |
| RAG defaults | Empty retrieval: refuses, and drops the question | Empty retrieval: passes the question through unguarded |
| Spring integration | First-party: auto-configuration for models and stores, @ServiceConnection for test containers, initialize-schema | Boot 4 starters for models and @AiService (beta line); stores wired by hand; no Qdrant starter |
| Kotlin ergonomics | JSpecify across the API; Kotlin nullability shapes tool schemas; streaming via Flux → Flow | JSpecify in parts of the core; a Kotlin module with suspend and Flow helpers (shadowed by members in places); metadata types are Java-centric |
| Vector store semantics | Cosine scores, threshold applied by Qdrant; Long metadata written as strings | Relevance scores, (cos + 1) / 2, threshold applied in the JVM after fetching vectors; Long stored as numbers |
| Tool safety | Exception messages go to the model by default | Same default today, with a startup warning and a stricter recommended execution handler |
| Observability | Micrometer meters out of the box (gen_ai.client.token.usage, db.vector.client.operation, …) | None by default; langchain4j-micrometer-metrics and langchain4j-observation modules exist (beta; not wired here) |
| Release cadence | 1.0 in May 2025, 2.0 in June 2026, patch releases between | Monthly minor releases (1.0 in May 2025, 1.21 by October 2026); integrations on a beta version line |
When I’d pick which.
Spring AI, for a Spring Boot service whose AI features are one part among many: auto-configuration, metrics and test-container wiring that match the rest of the stack, and an API that reads like the Spring clients a team already knows. It’s the default in this series for that reason.
LangChain4j, when the AI part is the product: agent-style flows, many tools, conversational memory, a long list of model providers and stores, or a codebase that isn’t Spring (it works in Quarkus and in plain Java). Its AI-service interfaces read naturally, and its monthly releases bring integrations quickly; the cost is a faster-moving dependency, with many integrations still on a beta version line.
Either, behind your own ports. The adapter in this part is about 190 lines of Kotlin, imports and blank lines included, and the shared test suite says it behaves like the first one. The cheapest insurance against picking the wrong library is making the choice easy to undo.
Tips, Tricks & Gotchas
Gotcha — the same score threshold means different things. Spring AI’s Qdrant store reports cosine similarity; LangChain4j’s reports relevance,
(cosine + 1) / 2. The tests’ threshold of 0.25, a cosine, became a cosine of −0.5 to LangChain4j, and every policy “matched” a question about cricket. Keep one canonical threshold, convert at the boundary withRelevanceScore.fromCosineSimilarity, and convert scores back if you report them.
Gotcha —
Resultwithout an import iskotlin.Result. An AI-service method declared to returnResult<String>compiles without importingdev.langchain4j.service.Result, then fails at runtime: “Illegal method return type: class java.lang.Object”. Kotlin imports its standard library’sResultby default, and it type-checks where a library expects its own; import the library’s type explicitly.
Gotcha — a member beats an extension.
model.chatAsync(request)callsChatModel’s experimental Java member, notlangchain4j-kotlin’s suspend extension, and the compiler doesn’t warn; with Ollama, the returned future fails withAsyncNotSupportedException. Pass the extension’s coroutine-context parameter, call.await()on a future you know is supported, or call the blockingchatinside your ownwithContext.
Gotcha —
EmbeddingStoreIngestoris not idempotent. It, andEmbeddingStore.addAllwithout ids, assign random UUIDs. Fed from Kafka, every redelivery, republish or replay adds duplicates that crowd out other results. Pass deterministic ids toaddAll(ids, embeddings, segments).
Gotcha — LangChain4j’s metadata has no lists.
Metadata.from(mapOf("tags" to listOf("a", "b")))throwsIllegalArgumentException: The metadata key 'tags' has the value '[a, b]', which is of the unsupported type 'java.util.Arrays$ArrayList'. A joined string andcontainsStringwork, but a substring match lets “vegan” match “non-vegan”, and a word-tokenised full-text index would match it too, for a different reason. Wrap values in delimiters (,vegan,) and match those, or use one integer field per tag (0 or 1);Metadatatakes no booleans either.
Tip — changing embedding models is a replay, not a migration. Vectors from a new model aren’t comparable with the old ones, so the whole collection must be re-embedded. Point the service at a new collection with a new consumer group, let it replay the topic, and switch traffic when it has caught up; the old collection keeps serving until then.
Debugging and Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Upserts fail because Qdrant has no products_lc4j collection | LangChain4j’s Qdrant store doesn’t create collections | Create it at startup, sized by embeddingModel.dimension() |
IllegalArgumentException: textSegment cannot be null during retrieval | LangChain4j pointed at a collection Spring AI wrote (no text_segment key) | One collection per library, or align the keys explicitly |
Two beans of type ChatModel in tests | The Boot starter creates real Ollama models because the profile sets their URLs | @Primary fakes, or test properties that unset the URLs |
| Startup notice about tool error handling | LangChain4j’s defaults send exception messages to the model | Set toolArgumentsErrorHandler and toolExecutionErrorHandler explicitly |
Spring AI beans fail to start under the lc4j profile | Spring AI’s models or vector store still configured | spring.ai.vectorstore.type: none, spring.ai.model.chat/embedding: none |
Key Takeaways
| Concept | Remember |
|---|---|
| Ports, not facades | Abstract what the service needs, in its own terms; each library is an adapter |
| Profiles and storage | One profile, one collection and one consumer group per adapter |
| LangChain4j model | Declarative AI-service interfaces; EmbeddingStore and EmbeddingModel kept apart; @AiService in Spring |
| Differences that bite | Relevance vs cosine scores; random ids in the ingestor; no list metadata; opposite empty-retrieval defaults |
| Kotlin on Java libraries | SAM lambdas, import aliases and const vals make them native; watch kotlin.Result and member-over-extension |
| Testing | One abstract contract suite, one subclass per adapter |
| The choice | Spring AI for Spring-first services; LangChain4j when AI is the product; either behind ports |
Where to Go Next
- The language: the Kotlin documentation and its “What’s new” page for each release; Kotlin 2.5 is planned for December 2026.
- Coroutines in depth: the kotlinx.coroutines guide, especially cancellation and exception handling, which Parts 10 and 11 only began.
- Spring with Kotlin: Spring Framework’s Kotlin reference, and Spring Boot’s Kotlin support pages.
- The AI libraries: the Spring AI reference and the LangChain4j documentation; both move fast enough that the versions in Parts 15 and 16 will date first.
- The code: every example in this series, runnable and tested, in the companion repository’s
kotlin-for-java-survivorsfolder.
Story Closing
The review board got its answer on the second day: a profile switch, a replayed topic, a second collection, and a test suite that passed against both libraries. The board recorded Spring AI as the default and LangChain4j as a tested alternative, and the architect pinned Kabir’s photo of the facade sketch to the decision record, under “considered”.
Three months earlier, Kabir’s first Kotlin pull request had been a PriceUtils class with a companion object and semicolons, and Lena hadn’t commented on it. She had pushed a commit that deleted the class and added two lines. Since then the platform had gained a catalog service, an event stream and an assistant that knew what it didn’t know, all in a language he now read without translating.
That afternoon a review request arrived from a team that hadn’t moved yet: a change to the old order service, in Java. Kabir scrolled through a getter, a setter, a builder, a null check, another null check, and a try block wrapped around a checked exception that nobody could do anything about. Every line ended in a semicolon. He winced.
Then he did for them what Lena had once done for him, in their own language: he pushed a commit that deleted the class and replaced it with a four-line Java record.
This is Part 16 of a 16-part series: “Kotlin for Java Survivors: Life After Semicolons.”