Structured Output with Spring AI
Turn LLM responses into typed Java objects with Spring AI: BeanOutputConverter, .entity(), generic lists, enums and validation — the reliable alternative to parsing text by hand.
On this page
"Please return JSON" is not a contract. Structured output is: you declare a Java type, and Spring AI constrains the model to it and deserialises the response. This is how you turn an LLM from a text generator into a typed component you can wire into real code.
Key Takeaways
- Declare a record; call
.entity(Type.class); get a typed object. No manual parsing. - Works for records, enums, generic lists and nested structures.
- Pair with temperature 0 — extraction should be deterministic.
- Still handle the parse failure path — rare with a clear schema, but never zero.
The basic case
public record Recipe(
String name,
List<String> ingredients,
int prepMinutes,
Difficulty difficulty) {
public enum Difficulty { EASY, MEDIUM, HARD }
}
public Recipe extractRecipe(String freeText) {
return chatClient.prompt()
.user(u -> u.text("Extract a recipe from:\n{text}").param("text", freeText))
.options(ChatOptions.builder().temperature(0.0).build())
.call()
.entity(Recipe.class); // schema derived, response parsed
}Spring AI reads the record, generates a JSON schema (including the enum's permitted values), tells the model to conform, and deserialises. You wrote no parser and no schema.
Generic collections
A bare List<Recipe> loses its element type at runtime, so use a ParameterizedTypeReference:
public List<Recipe> extractAll(String menu) {
return chatClient.prompt()
.user(u -> u.text("Extract all recipes from:\n{menu}").param("menu", menu))
.call()
.entity(new ParameterizedTypeReference<List<Recipe>>() {});
}Using the converter directly
When you need the format instructions inside a larger custom prompt, use BeanOutputConverter
explicitly:
public Recipe extractWithContext(String text, String cuisine) {
var converter = new BeanOutputConverter<>(Recipe.class);
String prompt = """
You are a culinary data extractor specialising in %s cuisine.
Extract a recipe from the text below.
%s
Text:
%s
""".formatted(cuisine, converter.getFormat(), text);
String raw = chatClient.prompt().user(prompt).call().content();
return converter.convert(raw); // parse the raw response yourself
}converter.getFormat() returns the schema-and-instructions string that tells the model exactly what
shape to produce.
Enums for constrained choices
An enum return type is one of the most reliable structured outputs, because the schema enumerates the only valid answers:
public enum Intent { BILLING, TECHNICAL, SALES, OTHER }
public Intent classifyIntent(String message) {
return chatClient.prompt()
.user(u -> u.text("Classify the intent of: {msg}").param("msg", message))
.options(ChatOptions.builder().temperature(0.0).build())
.call()
.entity(Intent.class);
}The model cannot return REFUND_REQUEST because it is not in the enum — the schema constrains it to
your four values.
Validation with compact constructors
Structured output guarantees the shape, not the values. Validate the values yourself:
public record DateRange(LocalDate from, LocalDate to) {
public DateRange {
// The model produced valid dates; that does not make them sensible.
if (to.isBefore(from)) {
throw new IllegalArgumentException("end date before start date");
}
}
}Handling parse failures
Rare with a clear schema and low temperature, but not zero. Handle it:
public Optional<Recipe> extractSafely(String text) {
try {
return Optional.of(
chatClient.prompt()
.user(u -> u.text("Extract a recipe from:\n{text}").param("text", text))
.options(ChatOptions.builder().temperature(0.0).build())
.call()
.entity(Recipe.class));
} catch (Exception e) {
// Could not parse — log the raw response for debugging, return empty.
log.warn("recipe extraction failed for input length {}", text.length(), e);
return Optional.empty();
}
}Nested and complex structures
Structured output handles nesting. Keep it reasonable — very deep schemas are harder for the model to fill correctly:
public record Invoice(
String vendor,
LocalDate date,
List<LineItem> items,
Money total) {
public record LineItem(String description, int quantity, Money unitPrice) {}
public record Money(BigDecimal amount, String currency) {}
}When to use structured output
- Extraction — pulling fields out of unstructured text.
- Classification — enum return types.
- Any response your code acts on — if you
ifon it, type it.
When you want free-form prose for a human to read, plain .content() is right. Structured output is
for when the machine is the consumer.
Next
- Spring AI function calling and @Tool
- Testing AI applications — asserting on typed output is far easier than on text
- LangChain4j structured output — the same idea in the other framework
Frequently Asked Questions
How does Spring AI convert an LLM response into a Java object?
What happens if the model returns malformed JSON?
Can I get a list of objects back, not just one?
Should I use structured output or JSON mode?
Related tutorials
- Spring AI Function Calling & @ToolHow Spring AI function calling works, with complete @Tool examples: registering tools, typed parameters, error handling, the agent loop, and how to stop a tool-using model doing damage.
- Multimodal AI with Spring BootSend images and audio to vision models from Spring Boot with Spring AI: the Media API, image analysis, document extraction from scans, and handling multimodal input safely.
- Building a RAG Pipeline with Spring BootBuild a production RAG pipeline in Spring Boot: document ingestion, chunking, pgvector retrieval, the QuestionAnswerAdvisor, citations, evaluation and the failure modes nobody warns you about.
- Spring AI with Ollama (Local LLMs)Run local LLMs in Spring Boot with Spring AI and Ollama: setup, model selection, offline development, cost and privacy trade-offs, and when a local model is the right call.