Structured Output in Spring AI: Turn Model Text Into Typed Java Records with .entity()
▶ Watch on YouTube & subscribe to The Stack Underflow
Somewhere in your codebase there is a method that takes a REST response body and deserializes it into a DTO. You never think about it — Jackson handles it, the compiler enforces the shape, and if the payload doesn’t match, you get a clear exception at the boundary instead of a NullPointerException three services downstream. That’s table stakes for enterprise Java.
Now put a language model on the other end of that call instead of a REST API. The response comes back as a String — English prose, maybe with the right facts, maybe formatted the way you asked, maybe not. Teams that skip past this problem write a regex against the model’s output, or split on newlines, or eyeball it in a demo and ship. It works until the next prompt tweak, the next model upgrade, or the next time the model decides to add “Sure, here’s what I found:” before the data you actually wanted.
Spring AI’s .entity(...) family exists specifically to close that gap. It is the same mental move as deserializing JSON with Jackson — except the untyped blob on the way in is free-form model text, not a REST payload, and the target is still a plain Java record you already know how to write.
The one-sentence version:
.entity(Class)and.entity(ParameterizedTypeReference)turn a model’s free-form response into a typed, optionally schema-validated Java object — so structured output, not raw text, is what your enterprise code is allowed to trust.
Why regexing model output is a dead end
Ask a model to “generate the filmography for a random actor” and you’ll get something like:
Sure! Tom Hanks has starred in a lot of great films over the years,
including Forrest Gump, Cast Away, and Big, among others...
That’s a perfectly reasonable response for a chatbot. It is not a data structure. The moment you try to extract actor and movies out of that prose with Pattern.compile(...), you’ve built something that breaks the instant the model rephrases the sentence, adds a preamble, or a provider swap (see Episode 4) changes the exact wording it tends to produce. There’s no contract — just a string you’re hoping stays shaped the way it looked in your last test run.
The fix isn’t a smarter regex. It’s the same fix you already reach for with any other untyped payload: define the shape you want in Java, and let a converter do the translation.
The layered stack: your code, the Spring AI seam, the model
┌─────────────────────────────────────────────┐
│ YOUR CODE │
│ record ActorFilms(String actor, │
│ List<String> movies) {} │
└───────────────────┬──────────────────────────┘
│ .entity(ActorFilms.class)
┌────────────────────▼──────────────────────────┐
│ SPRING AI — BeanOutputConverter<T> │
│ implements StructuredOutputConverter<T> │
│ builds a JSON schema from your record, │
│ appends it as format instructions │
└────────────────────┬──────────────────────────┘
│ prompt + schema instructions
┌────────────────────▼──────────────────────────┐
│ MODEL │
│ returns JSON-shaped text matching the schema │
└─────────────────────────────────────────────────┘
BeanOutputConverter<T> (which implements StructuredOutputConverter<T>) is the seam. It does two jobs: it generates a JSON schema from your record’s shape and attaches it to the outgoing prompt as format instructions, and it parses the model’s returned text back into your typed object via convert(String text). You never touch raw JSON on either side of the call.
Single-entity extraction with .entity(Class)
Define the shape you want as a plain Java record — nothing AI-specific about it:
public record ActorFilms(String actor, List<String> movies) {}
Then call it through the ChatClient fluent chain you already know from earlier episodes, ending in .entity(ActorFilms.class) instead of .content():
ActorFilms actorFilms = chatClient.prompt()
.user("Generate the filmography for a random actor.")
.call()
.entity(ActorFilms.class);
Behind that one call, Spring AI generates a schema like the one below and rides it along with the prompt as format instructions, then parses the response straight into an ActorFilms instance:
{
"actor": "string",
"movies": ["string"]
}
ChatClient.CallResponseSpec.entity(Class<T> type) is the method doing the work here — it’s the beginner-friendly default and the one you’ll reach for most often.
Collections: why ParameterizedTypeReference instead of .class
The natural next question is “what if I want a list?” Try .entity(List<ActorFilms>.class) and you’ll hit a wall — that syntax doesn’t even compile, because Java erases generic type parameters at runtime. A bare Class token can carry List, but it can’t carry List<ActorFilms>; the JVM only sees List.
Spring AI’s answer is ChatClient.CallResponseSpec.entity(ParameterizedTypeReference<T> type), which uses an anonymous subclass to capture the full generic type at compile time:
List<ActorFilms> actorFilms = chatClient.prompt()
.user("Generate the filmography of 5 movies for Tom Hanks and Bill Murray.")
.call()
.entity(new ParameterizedTypeReference<List<ActorFilms>>() {});
Same seam, same BeanOutputConverter, one extra type token. This is the standard Spring pattern for generic type resolution — if you’ve used RestTemplate.exchange(url, new ParameterizedTypeReference<List<Foo>>(){}), this is the identical trick applied to model output instead of HTTP responses.
What’s new in 2.0.0: schema validation and native structured output
The plain .entity(Class) and .entity(ParameterizedTypeReference) overloads above are unchanged from the 1.0.x line. What’s genuinely new in Spring AI 2.0.0 is a Consumer<ChatClient.EntityParamSpec> overload that lets you opt into stricter guarantees:
ActorFilms actorFilms = chatClient.prompt()
.user("Generate the filmography for a random actor.")
.call()
.entity(ActorFilms.class, spec -> spec.validateSchema());
ChatClient.EntityParamSpec.validateSchema() validates the model’s JSON response against the entity schema. If the shape is wrong — a truncated field, a type mismatch — Spring AI automatically retries with error feedback, up to maxRepeatAttempts (default 3), instead of throwing straight into your service code on the first bad parse. Note it doesn’t support streaming responses.
There’s a second, related option: ChatClient.EntityParamSpec.useProviderStructuredOutput(). Instead of appending format instructions as prompt text, it asks the provider itself to enforce the schema as an API-level constraint. It’s off by default, and support varies by provider — the docs call out documented limitations on Ollama and OpenAI, so test it against whichever provider you’re actually deploying against before you rely on it in production.
| Call shape | Guarantee | Cost |
|---|---|---|
.entity(ActorFilms.class) | Best-effort parse; throws ✕ PARSE ERROR on a bad shape | One round trip |
.entity(ActorFilms.class, spec -> spec.validateSchema()) | Schema-validated; retries with feedback on mismatch | Up to maxRepeatAttempts (default 3) round trips |
.entity(..., spec -> spec.useProviderStructuredOutput()) | Provider enforces schema at the API level | Provider-dependent; not universally supported |
The enterprise line: typed and validated is what you’re allowed to trust
A raw String from a model is not something you write to a database, publish to a queue, or return from a REST endpoint without further processing — you have no guarantee it’s shaped the way your downstream systems expect. A .entity(ActorFilms.class, spec -> spec.validateSchema()) result is different: it’s a typed record, and it already survived a schema check before your code ever saw it. That distinction — typed plus validated versus “the model said some words” — is the actual product of this feature for a team running AI output through anything that matters.
How to apply this right now
- Add the starter.
org.springframework.ai:spring-ai-starter-model-openai:2.0.0(swap the provider suffix for Anthropic, Bedrock, Vertex AI, or Ollama as needed). This requires Java 21 to build and Spring Boot 4.0 / Spring Framework 7.0 as the platform baseline — Java 17 is only the Boot 4 runtime floor, not the build requirement. - Define the record first. Write the plain Java shape you actually want back before you touch any Spring AI API. If it’s a shape you’d deserialize from JSON with Jackson, it’s a shape
.entity()can produce. - Start with the plain overload. Use
.entity(ActorFilms.class)or.entity(new ParameterizedTypeReference<List<ActorFilms>>(){})for prototyping and low-stakes paths. - Add
validateSchema()before anything touches production. Any output written to storage, published to a queue, or returned from a public API endpoint should go through theConsumer<EntityParamSpec>overload with.validateSchema()— not the bare.entity(Class)call. - Test
useProviderStructuredOutput()against your actual provider before depending on it — support is inconsistent across providers as of 2.0.0. - Externalize your model config the current way. Keep
spring.ai.openai.api-keyand provider selection inapplication.properties/environment variables, not hardcoded — the same externalization discipline you’d apply to any other credential.
Common misconceptions
“.entity() just does string parsing under the hood, so I could write my own converter just as well.” BeanOutputConverter doesn’t parse after the fact — it generates a JSON schema from your record before the call and appends it to the prompt as format instructions, so the model is steered toward the right shape from the start. Hand-rolled parsing only reacts to whatever the model happened to produce.
“If .entity(Class) works in my demo, it’s production-ready.” The plain overload has no retry path. One malformed response and you get a ✕ PARSE ERROR exception straight into your call stack. validateSchema() exists precisely because demos and production paths need different guarantees.
“ParameterizedTypeReference is a Spring AI–specific type.” It’s org.springframework.core.ParameterizedTypeReference — the same class Spring has used for years to preserve generic type information through RestTemplate.exchange() and similar APIs. Spring AI didn’t invent a new mechanism; it reused the existing Spring pattern for the same underlying Java limitation (generic type erasure).
“useProviderStructuredOutput() is the more reliable option, so it should be the default.” It’s off by default precisely because provider support isn’t uniform — the 2.0.0 docs flag limitations on both Ollama and OpenAI. Treat it as an optimization to test against your specific provider, not a default upgrade path.
Frequently asked questions
What’s the difference between .entity(Class) and .entity(Class, Consumer<EntityParamSpec>)?
Both use the same BeanOutputConverter seam and produce the same typed object on success. The Consumer<EntityParamSpec> overload is strictly additive — it lets you opt into validateSchema() (schema-checked with automatic retry) and/or useProviderStructuredOutput() (provider-level schema enforcement). The plain overload has neither; a malformed response throws immediately.
Why can’t I just call .entity(List<ActorFilms>.class)?
That syntax isn’t valid Java — you can’t get a Class literal for a parameterized type, because the JVM erases generic type parameters at runtime. ParameterizedTypeReference works around this using an anonymous subclass whose generic superclass Spring can inspect reflectively, which is why you always instantiate it as new ParameterizedTypeReference<List<ActorFilms>>() {} with the trailing {}.
How many times does validateSchema() retry, and can I configure it?
Up to maxRepeatAttempts, which defaults to 3, with error feedback sent back to the model on each retry. As of Spring AI 2.0.0 this is a code-level default, not an externalized property — check _meta release notes if you’re reading this well after the 2.0.0 line, since it’s a likely candidate for becoming configurable.
Does validateSchema() work with streaming responses?
No — schema validation with retry is documented as unsupported for streaming calls. If you need both streaming and structured output, you’ll need to buffer the full response before validating, or accept the plain .entity() path without validation.
Is .entity() doing anything different from what BeanOutputConverter does directly?
No — .entity() on ChatClient.CallResponseSpec is exactly BeanOutputConverter wired in for you as part of the fluent chain. You can construct and use BeanOutputConverter<T>/StructuredOutputConverter<T> directly if you’re building a call path outside of ChatClient, but for the standard fluent API, .entity() is the ergonomic front door.
Which provider should I use if I want native schema enforcement via useProviderStructuredOutput()?
Support varies and is documented as limited on both Ollama and OpenAI as of 2.0.0. Don’t assume it based on provider — verify directly against the provider and version you’re deploying, and fall back to validateSchema() as the more portable guarantee if it’s unsupported.
Where this fits in the series
This is Episode 5 of Spring AI for Enterprise Java. The previous episode covered swapping model providers underneath the same ChatClient code — see Model Portability. The next episode moves from typed single-turn output to grounding answers in your own documents — see RAG and Vector Stores.
Runnable code for everything in this tutorial lives in the 05-structured-output module of the spring-ai-enterprise-examples repo.
The companion video for this episode isn’t published yet — subscribe to @TheStackUnderflow on YouTube so it lands in your feed the day it goes live, and follow the rest of the series as new episodes ship.
Found this useful? The deep version lives on YouTube — new breakdowns of how AI dev tools actually work, weekly.
Subscribe on YouTube →