Structured Output
An LLM generates text. Your service layer wants a Java object. This module is the bridge — and where it can quietly go wrong.
Learning objectives
- Beginner: A single.entity(SomeRecord.class) call for a simple extraction task, with low temperature.
- Intermediate: ParameterizedTypeReference for a list of extracted entities, with try/catch around parse failures and a retry (see Module 16's runtime validation pattern).
- Advanced: Native structured output for a provider that supports it, combined with a FactCheckingEvaluator (Module 16) so both the shape and the substance of the response are verified before it reaches a downstream system.
◆ The problem
You want to call the model and get back a MovieRecommendation Java record you can save to a database — not a paragraph of prose you now have to regex-parse.
Structured output in Spring AI has two underlying mechanisms: (1) format-instruction prompting — appending machine-readable formatting instructions (derived from your target type's shape) to the prompt, then parsing the text response against that type; and (2) native structured output — using a provider's own built-in JSON-schema-constrained generation feature, where the provider itself guarantees schema-conformant output rather than Spring AI hoping the model followed the appended instructions.
public record MovieRecommendation(String title, int year, String reason) {} MovieRecommendation movie = chatClient.prompt() .user("Recommend one great 90s sci-fi movie.") .call() .entity(MovieRecommendation.class);
◆ Under the hood — BeanOutputConverter
Calling.entity(MovieRecommendation.class) internally builds a BeanOutputConverter, which reflects over your record/class to derive a JSON schema, appends a "respond only with JSON matching this schema" instruction to your prompt behind the scenes, then parses the returned text as JSON into your type once the response comes back. Your original prompt is silently modified — worth knowing when you're debugging why the raw text response looks different than what you typed.
💻 Code example
public record MovieRecommendation(String title, int year, String reason) {} MovieRecommendation movie = chatClient.prompt() .user("Recommend one great 90s sci-fi movie.") .call() .entity(MovieRecommendation.class);
List<String> genres = chatClient.prompt() .user("List 5 movie genres.") .call() .entity(new ListOutputConverter(new DefaultConversionService())); Map<String,Object> profile = chatClient.prompt() .user("Give me a JSON object describing a fictional user: name, age, city.") .call() .entity(new MapOutputConverter());
💻 Code example
List<String> genres = chatClient.prompt() .user("List 5 movie genres.") .call() .entity(new ListOutputConverter(new DefaultConversionService())); Map<String,Object> profile = chatClient.prompt() .user("Give me a JSON object describing a fictional user: name, age, city.") .call() .entity(new MapOutputConverter());
◆ The problem
Java erases generic type parameters at runtime —.entity(List.class) isn't even valid syntax, so how do you tell Spring AI "parse this as a list of my record type," not just "a list"?
List<MovieRecommendation> movies = chatClient.prompt() .user("Recommend 3 great 90s sci-fi movies.") .call() .entity(new ParameterizedTypeReference<List<MovieRecommendation>>() {});
ParameterizedTypeReference (the same pattern Spring's RestTemplate uses for generic response bodies) captures the full generic type at compile time via an anonymous subclass, letting the converter reconstruct List despite Java's type erasure.
▲ Pitfall
With some providers, asking for multiple structured entities in one call can produce inconsistent results — e.g. the model wrapping the array in an unexpected top-level key, or only partially following the schema for later items in a long list. Test multi-entity structured output against your specific provider/model combination rather than assuming the single-entity behavior generalizes cleanly.
💻 Code example
List<MovieRecommendation> movies = chatClient.prompt() .user("Recommend 3 great 90s sci-fi movies.") .call() .entity(new ParameterizedTypeReference<List<MovieRecommendation>>() {});
Where format-instruction prompting asks the model to follow a schema (and parses hopefully-valid JSON afterward), some providers offer native structured output — the provider itself constrains token generation to only ever produce schema-valid JSON, eliminating the "model ignored the format instructions" failure mode at the source rather than catching it after the fact.
| Format-instruction prompting | Native structured output | |
|---|---|---|
| Guarantee | Best-effort — model can still deviate | Provider-enforced schema conformance |
| Portability | Works with any model, including local Ollama models | Only where the specific provider/model supports it |
| Failure mode | Occasional parse failures you must handle | Effectively eliminated for schema shape (semantic correctness is still not guaranteed) |
▲ Pitfall
Neither approach guarantees the content is correct — only that the shape is valid JSON matching your type. A structured response can still be a confidently wrong MovieRecommendation with a real-looking but incorrect year. Structural validity and factual correctness are different guarantees — see Module 16 for the latter.
✓ Quick recap
What does.entity(SomeType.class) do to your prompt behind the scenes? It silently appends schema-based format instructions before sending the request, then parses the response text against that type. Why can't you write.entity(List.class)? Java erases generic type parameters at runtime; ParameterizedTypeReference captures the full generic type via an anonymous subclass instead. Does native structured output guarantee the response is factually correct? No — only that it's schema-valid JSON; factual correctness is a separate concern requiring evaluation.
Want a visual for this concept?
Generate a diagram tailored to “Structured Output” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →