Skip to content
Riadh Mnasri
← Back to blog
3 min read

Spring AI: bringing Claude into a Kotlin/Spring Boot backend

Spring Boot is the framework I use for every Kotlin backend that demonstrates a hexagonal architecture, MissionMatch being the reference. When a client asks to add an AI capability to that kind of backend, the question is never "is it possible", it's "does it fit cleanly into an architecture that already strictly separates domain, application and infrastructure". Spring AI answers that question better than I expected, as long as it isn't allowed to creep in where it doesn't belong.

A Claude client like any other bean#

Spring AI's principle is to treat a language model like any other external dependency injected by the Spring container: a ChatClient is configured and injected exactly like a JdbcTemplate or a RestClient.

kotlin
@Configuration
class ClaudeConfig {
    @Bean
    fun chatClient(builder: ChatClient.Builder): ChatClient =
        builder
            .defaultSystem("Respond with valid JSON only, no text outside the JSON.")
            .build()
}

Nothing in that code gives away that an LLM is involved: that's exactly the abstraction level expected from a Spring client. That's the first thing that convinced me Spring AI isn't a marketing gimmick bolted onto the Spring ecosystem, but an integration designed to respect its conventions.

Where it belongs in a hexagonal architecture#

Tip

A Spring AI ChatClient is an infrastructure dependency, exactly like a database access. It has no business in the domain layer, even when calling the API directly from a use case is tempting to move faster.

The port stays defined in the domain, as a Kotlin interface with no dependency on Spring AI:

kotlin
interface MissionScorer {
    fun score(mission: MissionOffer, stack: TechStack): ScoreResult
}

The implementation that actually calls ChatClient lives in infrastructure, like any other adapter:

kotlin
@Component
class ClaudeMissionScorer(
    private val chatClient: ChatClient,
) : MissionScorer {
    override fun score(mission: MissionOffer, stack: TechStack): ScoreResult =
        chatClient.prompt()
            .user("Stack: ${stack.summary()}\n\nOffer: ${mission.description}")
            .call()
            .entity(ScoreResult::class.java)
}

This separation isn't an exercise in architectural purity. It concretely means I can test the business logic that consumes MissionScorer with a deterministic fake scorer, never calling a real LLM in unit tests, and without the domain ever knowing Claude exists.

entity(), or finally leaving free text behind#

The thing that most changed my perception of Spring AI's maturity: entity(ScoreResult::class.java) deserializes the model's response directly into a Kotlin data class, relying on an automatically generated schema. No more hand-parsing JSON, or handling the cases where the model decides to add an explanatory sentence before the expected object.

kotlin
data class ScoreResult(
    val score: Int,
    val justification: String,
    val redFlags: List<String>,
)
Warning

A well-typed schema reduces the risk of malformed output, it doesn't eliminate it. Validating business bounds (a score between 0 and 100, a non-empty list when the context requires one) stays the calling code's responsibility, not something deserialization guarantees.

The real cost: tests, not integration#

The integration itself is quick, a few dozen lines. What takes time is deciding how to test a layer that calls a non-deterministic service. The answer I apply is the same as for any infrastructure adapter: a ChatClient stub returning fixed responses for the domain's unit tests, and a handful of real, isolated integration tests, run apart from the rest of the suite, that verify the real call actually produces output conforming to the schema.

What this generalizes to#

Spring AI doesn't change the architectural discipline a serious Spring Boot backend imposes, it simply extends it to a new kind of external dependency. The question to ask is the same as always: does the domain depend on Spring AI, or only on a port that infrastructure implements using Spring AI. Answering that question correctly, not the quality of the prompt sent to the model, is what determines whether adding an AI capability strengthens the existing architecture or starts eroding it.