Spring AI Structured Output: Typed Java Records from Gemini in One Line
Spring AI's structured output API turns freeform text from an LLM into a typed Java record. No manual JSON parsing, no hand-written schemas, no retry loops for malformed responses. The whole thing fits in one method call: .entity(YourClass.class).
This is the second recipe in the Spring AI Recipes series. If you haven't called Gemini from Spring AI before, the first recipe covers the setup. This post assumes that's already working.
Quick Answer
To get a typed Java object back from a Gemini call in Spring AI:
- Define a record with the fields you want the LLM to populate (records, enums, lists, and nested records all work)
- Add
@JsonPropertyOrderto control the order fields appear in the generated schema (this affects response quality, not just serialization) - Inject
ChatClient.Builderin your service - Call
.entity(YourClass.class)at the end of the prompt chain
Spring AI derives a JSON schema from the record, embeds it in the prompt as format instructions, calls Gemini, strips any markdown fences from the response, and deserializes the JSON back into your typed object.
Working code is on GitHub: github.com/TheRavi/spring-ai-recipes.
What is structured output in Spring AI?
Structured output is the practice of getting an LLM to return data that matches a predefined schema, instead of freeform text. In Spring AI, the ChatClient.entity() method handles the entire pipeline:
- It reads your target class and generates a JSON schema using reflection
- It appends the schema to the prompt as format instructions
- It calls the model
- It strips markdown fences if the LLM wraps its response (Gemini sometimes does)
- It deserializes the JSON response into an instance of your class
Under the hood, this uses BeanOutputConverter, which you can also call directly when you want to inspect the generated schema or customize the conversion.
Why does this matter?
LLMs return text. Production systems need types.
The naive way to bridge that gap, which most tutorials still teach, looks like this:
- Write a prompt asking the model to return JSON
- Describe the schema in the prompt as best you can
- Call the model
- Parse the response and hope it's valid JSON
- Map the parsed JSON onto your application objects
- Add retry logic for markdown-wrapped JSON, trailing commas, or extra commentary
Every team building with LLMs writes some version of this glue code. Then they write tests for it. Then they discover edge cases. Then the glue grows.
Spring AI collapses all six steps into .entity(YourClass.class). For Java teams coming from Python's Pydantic-plus-retry-decorators pattern, this is one of those moments where Java's tooling story gets to feel quietly capable.
Prerequisites
- A working Spring AI + Gemini setup (covered in recipe 01)
- Java 25 (or Java 21 LTS minimum — adjust
<java.version>inpom.xml) - Maven 3.9+ (the project ships with the Maven wrapper)
- A free Gemini API key from aistudio.google.com/apikey
Step 1: Define the target record
The example for this recipe is a bug report triager. You POST in a freeform complaint from a user, and you get back a structured ticket with severity, component, suggested labels, and a one-line summary.
The target record:
@JsonPropertyOrder({"summary", "severity", "component", "suggestedLabels"})
public record TriagedReport(
String summary,
Severity severity,
String component,
List<SuggestedLabel> suggestedLabels
) {}Four field types, each doing different work:
summaryis a string. The easy case.severityis an enum (CRITICAL,HIGH,MEDIUM,LOW). Spring AI generates anenumconstraint in the schema, which keeps the LLM from inventing new severity levels.componentis a string constrained through the system prompt rather than the schema.suggestedLabelsis aList<SuggestedLabel>whereSuggestedLabelis a nested record with its own structure.
That last one is the real test. A flat schema is easy. A schema with nested objects inside a list is where most framework abstractions start leaking. Spring AI handles it without ceremony: the nested record gets its own derived schema, embedded inside the parent.
The nested record itself:
@JsonPropertyOrder({"label", "confidence"})
public record SuggestedLabel(
String label,
double confidence
) {}And the enum:
public enum Severity {
CRITICAL, HIGH, MEDIUM, LOW
}Step 2: Write the service
The service injects a ChatClient.Builder, sets a system prompt that defines the classification rules, and uses .entity() to deserialize Gemini's response into the typed record.
package com.example.triager.service;
import com.example.triager.model.TriagedReport;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
@Service
public class TriageService {
private final ChatClient chatClient;
public TriageService(ChatClient.Builder builder) {
this.chatClient = builder
.defaultSystem("""
You are a bug report triager for a software engineering team.
Given a bug report from a user or customer, classify it into a structured triage record.
Rules:
- severity: CRITICAL if data is lost or the app is unusable; HIGH for major
functionality broken; MEDIUM for degraded experience; LOW for cosmetic issues.
- component: a short lowercase identifier like "auth", "billing", "search", "ui".
- suggestedLabels: 2-4 short labels engineers would add to the ticket. Each label
has a confidence score between 0.0 and 1.0.
- summary: one sentence, present tense, plain English. No marketing speak.
""")
.build();
}
public TriagedReport triage(String bugReport) {
return chatClient.prompt()
.user(bugReport)
.call()
.entity(TriagedReport.class);
}
}Two things worth understanding about this code:
-
The
defaultSystemdoes real work. The JSON schema tells the LLM what shape to return. The system prompt tells it what the values should mean. Schemas can't express "severity is CRITICAL when data is lost", because that's semantic, not structural. The system prompt fills that gap. -
.entity()is the only line that distinguishes this from a normal Spring AI call. Everything else is the same fluent API you'd use for a freeform string response. Swapping between text and typed output is a one-method-call change.
Step 3: Write the controller
A simple POST endpoint that takes a bug report and returns the structured triage:
package com.example.triager.controller;
import com.example.triager.model.TriageRequest;
import com.example.triager.model.TriagedReport;
import com.example.triager.service.TriageService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TriageController {
private final TriageService triageService;
public TriageController(TriageService triageService) {
this.triageService = triageService;
}
@PostMapping("/triage")
public TriagedReport triage(@RequestBody TriageRequest request) {
return triageService.triage(request.bugReport());
}
}POST instead of GET, because bug reports often contain newlines and special characters that don't belong in a query string.
Step 4: Run and test
The recipe reads the Gemini API key from an environment variable. Keep it in a .env file at the project root (gitignored), and source it before starting the app:
GEMINI_API_KEY=your-key-here
set -a && source .env && set +a
./mvnw spring-boot:runThe set -a flag tells the shell to export every variable that gets assigned, so Spring Boot picks up GEMINI_API_KEY when it reads application.yml. set +a turns the behavior back off so the rest of your shell session stays clean.
Then hit the endpoint with a real-shaped bug report:
curl -X POST http://localhost:8080/triage \
-H 'Content-Type: application/json' \
-d '{
"bugReport": "When I click the export button on the analytics page nothing happens. No error, no download. Tried Chrome and Firefox. This is blocking our team from sending the monthly report to the board."
}'What comes back:
{
"summary": "Export button on analytics page does not initiate download in multiple browsers.",
"severity": "HIGH",
"component": "analytics",
"suggestedLabels": [
{ "label": "export", "confidence": 1.0 },
{ "label": "analytics", "confidence": 1.0 },
{ "label": "ui", "confidence": 0.9 },
{ "label": "browser-agnostic", "confidence": 0.8 }
]
}That's a typed Java object on the way out of the controller, serialized to JSON only for the HTTP response. Inside the application, every field has a type the compiler can check.
How does @JsonPropertyOrder affect LLM output quality?
This part isn't obvious, and it's the most useful thing in this post.
@JsonPropertyOrder({"summary", "severity", "component", "suggestedLabels"})LLMs respond more reliably when they generate reasoning fields before classification fields. If severity appears first in the schema, the model commits to a severity before it has explained why. If summary appears first, the model writes the summary, and the act of summarizing helps it choose a more accurate severity afterward.
This is the same logic behind chain-of-thought prompting, expressed through field order. Stick the explanatory fields first, the classification fields last.
You can verify it's working with a one-line endpoint:
@GetMapping("/schema")
public String schema() {
return new BeanOutputConverter<>(TriagedReport.class).getJsonSchema();
}Hit /schema and the properties keys come back in the order you annotated:
{
"type": "object",
"properties": {
"summary": { "type": "string" },
"severity": { "type": "string", "enum": ["CRITICAL", "HIGH", "MEDIUM", "LOW"] },
"component": { "type": "string" },
"suggestedLabels": { ... }
}
}The gotcha: @JsonPropertyOrder only orders the record it's annotated on. Nested records fall back to alphabetical order. Here's the actual nested-record schema for this recipe before adding the annotation:
"suggestedLabels": {
"type": "array",
"items": {
"type": "object",
"properties": {
"confidence": { "type": "number" },
"label": { "type": "string" }
}
}
}confidence before label, which is alphabetical and not declaration order. The fix is to annotate the nested record too:
@JsonPropertyOrder({"label", "confidence"})
public record SuggestedLabel(String label, double confidence) {}Now the LLM sees label first and commits to what the label is before deciding how confident it is. If you only annotate the top-level record, your nested objects are being reasoned about in alphabetical order, which is rarely what you want.
Real gotchas from building this
The code ran clean on the first try, which is becoming a pattern with Spring AI 1.1. But "works in the happy path" isn't the same as "works in production." Four things to watch for.
1. Vague input produces low-confidence garbage
Send {"bugReport": "the app is broken"} and you'll still get a TriagedReport back. The severity will be a guess. The component will probably be "general" or "unknown". The labels will be generic. The system happily fabricates structure where there's nothing to structure from.
This isn't a Spring AI problem; it's an LLM problem that structured output makes more visible. In production, you want a confidence threshold or an explicit "unsure" enum value so the model can decline to classify.
2. Confidence scores are not calibrated
Look at the actual response above: two labels at confidence: 1.0. The model is not 100% confident. It has no concept of confidence at all. It's emitting a number because the schema asked for one. The number is decorative.
If you build product logic on top of these scores ("auto-assign if confidence > 0.9"), you're building on sand. Use confidence fields for sorting and display, not for control flow. If you need a real probability, you need a real classifier, not an LLM with a schema.
3. Nested records ignore @JsonPropertyOrder from the parent
Covered above in detail. The summary: if you have a nested record and you care about field order (which you should, for chain-of-thought reasons), the nested record needs its own @JsonPropertyOrder annotation. Annotations don't cascade.
4. Schema drift between deployments
The schema lives in your Java code. Change a field name, redeploy, and any cached prompts or stored model responses in flight at the moment of deploy will deserialize differently, or fail outright. Treat the record as a versioned contract, the same way you'd treat a database table or an API DTO. Add fields, don't rename them.
Frequently asked questions
What is the Spring AI .entity() method?
.entity(YourClass.class) is a fluent-API method on Spring AI's ChatClient that returns a typed Java object from an LLM call. It generates a JSON schema from the target class, embeds it in the prompt as format instructions, and deserializes the model's response back into an instance of the class.
Does Spring AI structured output work with Gemini?
Yes. The recipe in this post uses gemini-2.5-flash via the spring-ai-starter-model-google-genai starter. The same code works with OpenAI, Anthropic, and any other model provider Spring AI supports. Only the starter dependency and configuration change.
What is BeanOutputConverter?
BeanOutputConverter is the lower-level component that .entity() uses under the hood. It generates the JSON schema from your Java class and parses the LLM response. You can use it directly via new BeanOutputConverter<>(YourClass.class) when you need to inspect the generated schema or customize the conversion.
Does @JsonPropertyOrder affect the LLM's response quality?
In practice, yes. Spring AI uses the declared field order when generating the schema embedded in the prompt. Because LLMs generate fields sequentially, putting reasoning fields (like summaries) before classification fields (like severity) tends to produce more accurate classifications. It's the same principle behind chain-of-thought prompting.
Why aren't LLM confidence scores reliable?
LLMs don't compute probabilities. When a schema asks for a confidence: number field, the model generates a plausible-looking number based on patterns in its training data, not on any actual probability calculation. Use these scores for sorting and display, not for control-flow decisions.
Can I get streaming structured output?
Not cleanly. Structured output relies on parsing a complete JSON document at the end of the response. You can stream the underlying text response and parse JSON incrementally if you really need to, but the standard .entity() API waits for the full response. For most use cases this is fine; structured outputs tend to be small.
What to build next
Recipe 03 covers tool calling: how to let Gemini invoke your Java methods as part of its reasoning. The model decides when it needs to look up a customer, query a database, or run a calculation, and Spring AI handles the dispatch back into your code. It's where structured output gets genuinely powerful, since the model can now produce typed output and gather the data it needs to produce it.
Why structured output matters for Java teams
If you're on a Java team watching the AI ecosystem from a distance, structured output is one of the strongest reasons to start building.
Python has a head start on model-side experimentation, including research code, eval harnesses, and agent frameworks. But the moment you cross into integration with a real production system (type safety, dependency injection, transactional boundaries, observability), Java's tooling is the more mature stack. Spring AI is the layer that connects the two.
.entity(YourClass.class) is a small API surface, but it's emblematic. It's what happens when a framework that has spent fifteen years thinking about types meets a technology where the lack of types is the central problem.
Working code
The complete project for this tutorial is on GitHub:
github.com/TheRavi/spring-ai-recipes — 02-structured-output/
Clone it, drop your Gemini key into .env, and run.
Further reading
- Spring AI documentation
- Spring AI structured output reference
- Spring AI Google GenAI chat reference
-
Jackson
@JsonPropertyOrderdocumentation - Get a free Gemini API key
Related posts on this blog:
- Spring AI + Gemini: Your First API Call in Java — recipe 01, the hello-world setup this post builds on
- Custom Scalar Types in GraphQL Micronaut — another working-code tutorial in the Java backend space
Hit a structured-output failure mode this post missed? Open an issue on the GitHub repo or reach out on LinkedIn. Spring AI is still maturing, and real-world reports always help me make the next recipe better.