---
title: Provider Abstraction Without Framework Lock-in
number: 4
date: 2026-07-30
author: Shukri Shukriev
medium: https://shukriev.medium.com/provider-abstraction-without-framework-lock-in-df81466017df
description: We needed Claude, GPT-4o, and Ollama to all look identical to the agent loop. The tempting move was to use Spring AI’s ChatClient directly in…
---

**How to wrap Spring AI so the agent loop never imports it.**

![Illustration for Provider Abstraction Without Framework Lock-in](image-1.webp)

We needed Claude, GPT-4o, and Ollama to all look identical to the agent loop. The tempting move was to use Spring AI’s ChatClient directly in sophi-core. We didn't. Here's why that decision was worth the extra indirection layer, and exactly how we built it.

This article stays inside sophi-ai, the module that owns provider access. If you haven't read article 02, the short version: sophi-core depends on sophi-ai, never the reverse, and sophi-core has zero Spring AI imports. This is that boundary, at the code level.

## The isolation requirement

The agent loop must never import Spring AI. Not ChatClient, not ChatResponse, not Prompt — none of it. If the loop imported Spring AI types directly, replacing Spring AI later would mean changing the loop, which is exactly the coupling ADR-002 exists to prevent. The whole point of choosing Spring AI as "the thing we didn't write ourselves" was to contain the decision to one module, not to let it leak into the one piece of SoPhi that's supposed to be framework-agnostic.

The fix is a plain Kotlin interface that the loop pattern-matches against and nothing more:

```kotlin
// sophi-ai/src/main/kotlin/dev/sophi/ai/api/LLMProvider.kt
package dev.sophi.ai.api

interface LLMProvider {
    suspend fun send(conversation: Conversation): LLMResponse
}
```

sophi-core calls provider.send(conversation) and matches on the sealed LLMResponse it gets back. Every Spring AI type — ChatModel, ChatResponse, Prompt, provider-specific options classes — stays behind that one function boundary, inside sophi-ai. The loop doesn't know Spring AI exists. That's not an accident of naming; it's the entire design goal of this module.

## Why the contract lives in sophi-ai, not a shared module

The obvious objection: if LLMProvider and LLMResponse are meant to be Spring-AI-free, shouldn't they live in their own module — a sophi-contracts, say — so that a future module could depend on the contract without pulling in Spring AI at all?

We considered it and rejected it, for now. sophi-core already depends on sophi-ai. Adding a third module for five data classes and one interface means a new Maven module, a new JAR, another entry in the parent POM's reactor, and another pom.xml to keep in sync — real coordination cost for a contract that, today, only one consumer needs. That's YAGNI: you aren't going to need the separate module until something other than sophi-core needs the contract without wanting Spring AI on its classpath, and nothing does yet.

Instead, the isolation unit is a package boundary: dev.sophi.ai.api holds the contract, dev.sophi.ai.providers holds everything Spring-AI-specific, and nothing in api imports anything from providers. sophi-core depends on sophi-ai as a Maven module but only ever touches the api package — a discipline enforced by convention today and easy to promote to an actual module boundary later, because the code is already organized as if it were one.

That extraction trigger is what ADR-004 records: if a future module needs LLMProvider and LLMResponse without wanting Spring AI transitively on its classpath, dev.sophi.ai.api gets promoted to its own module at that point, not before. The package boundary already matches where the module boundary would go, so the extraction is a pom.xml change and a mvn reactor update, not a redesign.

## Designing LLMResponse as a sealed class

This sealed class was introduced conceptually in article 03. Here it is as it actually lives in sophi-ai:

```kotlin
// sophi-ai/src/main/kotlin/dev/sophi/ai/api/LLMResponse.kt
package dev.sophi.ai.api

sealed class LLMResponse {
    data class Text(val content: String) : LLMResponse()
    data class ToolUse(val calls: List<ToolCall>) : LLMResponse()
    data class Error(val cause: Throwable) : LLMResponse()
}
```

Three variants, and they map one-to-one onto the three things the loop can do on any given turn: return Text to the user and stop, execute tools for ToolUse and continue, or surface Error. There's no fourth thing the loop needs to handle, so there's no fourth variant — and if that changes, the sealed class forces the issue rather than letting it slide.

That’s the deliberate cost of a sealed class over, say, a response object with a nullable content and a nullable toolCalls field: adding a new variant is a compile-time breaking change. Every when in the codebase that matches on LLMResponse — the loop, any test fixture, any future plugin that inspects responses — has to be updated to handle it, or the build fails. That's not friction to route around; it's the mechanism that keeps a "just one more case" addition from silently going unhandled somewhere three modules away.

## The mapping layer

Spring AI’s ChatResponse doesn't look like LLMResponse, and it shouldn't have to — that's the entire reason the mapping layer exists. Two extension functions do the translation, and neither is exported outside sophi-ai:

```kotlin
// sophi-ai/src/main/kotlin/dev/sophi/ai/providers/ChatResponseMapper.kt
package dev.sophi.ai.providers

internal fun Message.toSpring(): org.springframework.ai.chat.messages.Message =
    when (this) {
        is Message.User -> UserMessage(content)
        is Message.Assistant -> AssistantMessage(content)
        is Message.ToolResult -> ToolResponseMessage(listOf(
            ToolResponse(id, name, content)
        ))
    }

internal fun ChatResponse.toLLMResponse(): LLMResponse {
    val result = this.result ?: return LLMResponse.Error(IllegalStateException("empty response"))
    val toolCalls = result.output.toolCalls
    return if (toolCalls.isNotEmpty()) {
        LLMResponse.ToolUse(toolCalls.map { it.toDomainCall() })
    } else {
        LLMResponse.Text(result.output.text.orEmpty())
    }
}

internal fun Message.toSpring(): org.springframework.ai.chat.messages.Message =
    when (this) {
        is Message.User -> UserMessage(content)
        is Message.Assistant -> AssistantMessage(content)
        is Message.ToolResult -> ToolResponseMessage(listOf(
            ToolResponse(id, name, content)
        ))
    }
```

Both ClaudeProvider and OpenAiProvider share this same mapper file. A ChatResponse from Anthropic and a ChatResponse from OpenAI, once they've come back through Spring AI's own provider-agnostic types, look identical to this mapper — which is precisely the abstraction Spring AI is good at, and precisely why article 01 didn't try to rebuild it.

What’s genuinely provider-specific is narrower than it might sound: mostly the options builder.

```kotlin
// sophi-ai/src/main/kotlin/dev/sophi/ai/providers/ClaudeProvider.kt
package dev.sophi.ai.providers

class ClaudeProvider(
    private val chatModel: AnthropicChatModel
) : LLMProvider {
    override suspend fun send(conversation: Conversation): LLMResponse {
        val prompt = Prompt(
            conversation.messages.map { it.toSpring() },
            AnthropicChatOptions.builder()
                .model(conversation.model)
                .temperature(conversation.temperature)
                .build()
        )
        return chatModel.call(prompt).toLLMResponse()
    }
}
```

Swap AnthropicChatOptions for OpenAiChatOptions and AnthropicChatModel for OpenAiChatModel, and that's the entire diff for OpenAiProvider. Bringing in a third provider — Gemini, say — means writing one new file in this shape, not touching the mapper, the sealed class, or anything in sophi-core. That's the cost this whole abstraction was built to minimize, and it comes out to about thirty lines per provider.

## Testing without a running model

None of the tests for this module talk to a real model, and none of them boot a Spring context. Both AnthropicChatModel and OpenAiChatModel are mocked directly with MockK, which means these tests run in milliseconds, need no API keys, and can't fail because of network flakiness or rate limits.

```kotlin
// sophi-ai/src/test/kotlin/dev/sophi/ai/providers/ClaudeProviderTest.kt
package dev.sophi.ai.providers

class ClaudeProviderTest {

    @Test
    fun `text response maps correctly`() = runTest {
        val model = mockk<AnthropicChatModel>()
        val promptSlot = slot<Prompt>()
        every { model.call(capture(promptSlot)) } returns stubTextResponse("hello")

        val provider = ClaudeProvider(model)
        val result = provider.send(conversationOf(userMessage("hi")))

        assertEquals(LLMResponse.Text("hello"), result)
        assertEquals("hi", promptSlot.captured.instructions.first().text)
    }
}
```

stubTextResponse() is a fifteen-line helper that builds a realistic ChatResponse — enough structure for the mapper to work against, no more. It's not a full fixture library; it's the minimum shape the mapper actually reads.

The capture(promptSlot) line is doing the real verification work here: it's not enough to check that the mapper produces the right LLMResponse on the way out, because a mapping bug could just as easily be on the way in — a message that gets mangled before it ever reaches the model. Capturing the Prompt that ClaudeProvider actually sent and asserting on its contents verifies both directions of the mapping in one test, without needing an integration test hitting a real API to catch a mistake that's really just a mapping error.

## What this buys, and what it costs

The cost is real: an interface, a sealed class, two extension functions, and a discipline about which package imports which — all for behavior that ChatClient would give you directly if sophi-core just called it. That's the extra indirection layer the narrative hook mentioned, and it's worth naming plainly rather than pretending it's free.

What it buys is equally concrete. sophi-core compiles and tests with zero knowledge that Spring AI exists. A provider swap or an addition is a new file in sophi-ai/providers, not a change anywhere else. And the contract's isolation is already organized the way a future module boundary would need it to be, so the day something other than sophi-core needs LLMProvider without Spring AI attached, the fix is a build file, not a rewrite.

## What’s next?

The next article in this series covers the session format — the JSONL structure that makes everything described here replayable after the fact.

Find the full source on [GitHub](https://github.com/shukriev/SoPhi) and connect with me on [LinkedIn](https://www.linkedin.com/in/shukri-shukriev-61b26882/).
