Sooner or later you’ll want the model’s answer as data, not prose.
A code review that your CI can act on needs a verdict field, not a paragraph that says “overall this
looks fine, but…”. So you add “reply in JSON” to the prompt, parse the string with Jackson, and it
works. Until the day the model invents a severity called CRITICAL, drops a field, or wraps the
JSON in a Markdown code fence. Jackson throws, and your endpoint returns a 500 for something the model
would have got right on a second try.
Spring AI 2.0 handles all of this for you. It turns a Java record into a JSON Schema, checks the reply against it, and when the reply is wrong, sends the validation error back to the model and asks again. In this post we’ll build four endpoints that use it in four slightly different ways.
Sample code: github.com/Jitu7/Spring-AI/tree/main/structured-output
Built with Spring Boot
4.1.2, Spring AI2.0.1and Java27. This project uses the same starters, BOM andapplication.propertiesas Your First Chat Endpoint in Spring Boot 4, so I won’t repeat the setup. It also carries overDevAssistantControllerfrom Spring AI Prompt Templates unchanged. Everything new is inStructuredOutputController.
What we’re building
All four endpoints live under /api/structured and return typed JSON instead of {"content": "..."}:
POST /api/structured/code-review entity(CodeReview) + validateSchema()
POST /api/structured/commit-messages OpenAI native schema + validateSchema()
POST /api/structured/release-notes responseEntity(): the record plus token usage
POST /api/structured/code-review/strict an explicit validation advisor with 5 retries
Here’s the same map as a diagram. The top half is what makes each endpoint different. The bottom half is the retry loop they all share, which we’ll get to shortly:
Start the app now, so you can try each endpoint as we go. You need JDK 27 and an OpenAI API key:
export OPENAI_API_KEY=sk-...
./mvnw package -DskipTests
java -jar target/structured-output-0.0.1-SNAPSHOT.jar
The record is the contract
Before any endpoint, we need a shape for the answer. For a code review, that’s a summary, a verdict, and a list of findings:
enum Verdict { APPROVE, REQUEST_CHANGES, COMMENT }
enum Severity { LOW, MEDIUM, HIGH }
enum Category { BUG, SECURITY, PERFORMANCE, READABILITY, STYLE }
record Finding(
@JsonPropertyDescription("1-based line number in the submitted snippet") int line,
Severity severity,
Category category,
@JsonPropertyDescription("What is wrong, in one sentence") String issue,
@JsonPropertyDescription("Concrete fix, ideally with a code fragment") String suggestion) {}
record CodeReview(
@JsonPropertyDescription("One-sentence overall assessment") String summary,
Verdict verdict,
List<Finding> findings) {}
Spring AI turns this into a JSON Schema, which is a JSON document that describes what valid JSON looks like, a bit like an XSD for XML. Two things in the record do most of the work.
The enums become enum constraints. The schema for severity says "enum" : [ "LOW", "MEDIUM", "HIGH" ], so CRITICAL is invalid before Jackson ever sees it. That’s what makes a retry possible.
A plain String severity would accept anything.
@JsonPropertyDescription becomes a description in the schema, and the model reads it. “1-based
line number in the submitted snippet” is a prompt instruction attached to exactly the field it’s
about.
Every component also ends up in required, and the schema sets "additionalProperties" : false. You
don’t write either of those. They come from the generator.
Note: Spring Boot 4 uses Jackson 3, whose packages moved from com.fasterxml.jackson to
tools.jackson. The annotations didn’t move. @JsonPropertyDescription is still imported from
com.fasterxml.jackson.annotation, which is what the sample does.
Dial 1: entity() with validateSchema()
The first endpoint is a code reviewer. The interesting part is the last line:
@PostMapping("/code-review")
CodeReview reviewCode(@RequestBody @Valid CodeSubmission submission) {
return chatClient.prompt()
.system(REVIEWER_PERSONA)
.user(u -> u.text("Language: {language}\n\nCode:\n{code}")
.param("language", submission.language())
.param("code", submission.code()))
.call()
.entity(CodeReview.class, spec -> spec.validateSchema());
}
entity(CodeReview.class) on its own already gets you a record. Spring AI appends format
instructions and the schema to the prompt, then parses the reply with a BeanOutputConverter. What
it doesn’t do is check anything. If the reply doesn’t fit, you find out when Jackson throws.
spec -> spec.validateSchema() adds the check. Before parsing, the reply is validated against the
schema. If it fails, Spring AI calls the model again with the error appended to the user message.
Try it with a method that has an obvious bug:
curl -X POST localhost:8080/api/structured/code-review \
-H 'Content-Type: application/json' \
-d '{
"language": "java",
"code": "public String greet(String name) {\n String trimmed = name.trim();\n return \"Hello, \" + trimmed + \"!\";\n}"
}'
Here’s what I got back, with the long suggestion code fragments trimmed:
{
"summary": "Simple and clear, but it should guard against null and blank inputs and can be made static for a utility-style method.",
"verdict": "REQUEST_CHANGES",
"findings": [
{
"line": 2,
"severity": "HIGH",
"category": "BUG",
"issue": "Possible NullPointerException when name is null.",
"suggestion": "Guard against null before trimming, e.g.: ..."
},
{
"line": 3,
"severity": "MEDIUM",
"category": "BUG",
"issue": "Whitespace-only or empty names produce \"Hello, !\".",
"suggestion": "Handle empty result after trimming, e.g.: ..."
},
{
"line": 1,
"severity": "LOW",
"category": "STYLE",
"issue": "Method can be static since it uses no instance state.",
"suggestion": "Declare the method static if appropriate, e.g.: ..."
}
]
}
The controller returned a CodeReview and Spring MVC serialised it, so this JSON went through a
Java record on the way. The line numbers follow the “1-based” description: line 2 really is the
trim() call. Your findings will differ, because it’s a model.
What you’d see in the log
DEBUG logging is on for the advisor package, as in the earlier posts. This time it shows a new
advisor working:
DEBUG ... StructuredOutputValidationAdvisor : Validating JSON output against schema. Attempts left: 3
DEBUG ... StructuredOutputValidationAdvisor : JSON validation succeeded
One call, one validation, done. GPT-5 returned valid JSON on the first try in every call I made while writing this post, so the retry never ran against the real model. To see it, you need a model that gets things wrong on purpose.
Watching a retry without paying for one
The sample’s test does exactly that. ScriptedChatModel is a fake ChatModel that returns
canned replies in order and records every prompt it receives:
static final class ScriptedChatModel implements ChatModel {
final List<Prompt> prompts = new ArrayList<>();
private final Deque<String> replies;
ScriptedChatModel(String... replies) {
this.replies = new ArrayDeque<>(List.of(replies));
}
@Override
public ChatResponse call(Prompt prompt) {
prompts.add(prompt);
return new ChatResponse(List.of(new Generation(new AssistantMessage(replies.pop()))));
}
}
The first reply is broken twice over. CRITICAL isn’t a valid Severity, and verdict is
missing:
private static final String INVALID_REPLY = """
{"summary":"Possible NPE","findings":[
{"line":3,"severity":"CRITICAL","category":"BUG",
"issue":"name may be null","suggestion":"use Objects.requireNonNull"}]}
""";
The test feeds that, then a valid reply, and checks what happened:
@Test
void validateSchemaFeedsErrorBackAndRetries() {
ScriptedChatModel model = new ScriptedChatModel(INVALID_REPLY, VALID_REPLY);
CodeReview review = ChatClient.create(model).prompt()
.user("Review: String s = name.trim();")
.call()
.entity(CodeReview.class, spec -> spec.validateSchema());
// Two round trips: the first failed validation, the second passed.
assertThat(model.prompts).hasSize(2);
assertThat(review.verdict()).isEqualTo(Verdict.REQUEST_CHANGES);
// ...
// The retry carried the validation error inside the user message.
String retryUserText = model.prompts.get(1).getUserMessage().getText();
assertThat(retryUserText).contains("Output JSON validation failed because of:");
// ...
}
The full SelfCorrectingStructuredOutputTests
also has a second test showing that without validateSchema() there’s only ever one call. Both pass
with ./mvnw test, and neither needs an API key.
This is the loop the test just exercised:
I wanted to see exactly what the model gets on each retry, so I scripted three bad replies in a row:
one that isn’t JSON at all, one missing verdict, one with CRITICAL. Here’s the start of the user
message on the third call, trimmed before the format instructions:
Review
Output JSON validation failed because of: required property 'verdict' not found
Your response should be in JSON format.
Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
...
Three things in there aren’t obvious from the docs.
The error goes into the user message, not a new message. The original text stays, and one line is added after it.
Each retry carries only the latest error. The third call doesn’t mention that the first reply wasn’t JSON. The advisor rebuilds the message from the original request every time, so errors don’t pile up.
The model doesn’t see its previous reply. A comment in StructuredOutputValidationAdvisor says
this is deliberate, to keep the prompt simple. The model gets the error and has to produce a fresh
answer.
Dial 2: let OpenAI enforce the schema
So far, the schema reaches the model as text in the prompt. The model is asked to follow it, but
nothing forces it to. OpenAI can do better. Its Structured Outputs feature takes a JSON Schema as an
API parameter, response_format, and constrains generation so the reply matches it.
useProviderStructuredOutput() switches to that:
@PostMapping("/commit-messages")
CommitSuggestions suggestCommitMessages(@RequestBody @Valid CommitRequest request) {
return chatClient.prompt()
.user(u -> u.text("""
Here is a summary of the code changes I'm about to commit:
{changes}
Suggest {options} Conventional Commits messages. Keep each
subject under 72 characters and use the imperative mood.
""")
.param("changes", request.changes())
.param("options", request.options()))
.call()
.entity(CommitSuggestions.class,
spec -> spec.useProviderStructuredOutput().validateSchema());
}
Compare this with the commit endpoint in the prompt templates post. That one had to say “list one suggestion per line with no extra commentary” and hope for the best. This prompt says nothing about format at all. The record handles it.
I wanted proof that the format instructions really disappear, so I ran the app once with
OPENAI_LOG=debug, which makes the OpenAI Java SDK log each HTTP request. Here’s the body it sent,
reformatted and trimmed:
{
"messages": [{
"role": "user",
"content": "Here is a summary of the code changes I'm about to commit:\n\nAdded a refresh-token endpoint ...\n\nSuggest 3 Conventional Commits messages. ..."
}],
"model": "gpt-5",
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "json_schema",
"strict": true,
"schema": {
"type": "object",
"properties": {
"suggestions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"scope": { "type": "string", "description": "Optional scope, e.g. 'auth'; empty string if none" },
"subject": { "type": "string", "description": "Imperative subject under 72 characters" },
"type": { "type": "string", "enum": ["FEAT", "FIX", "DOCS", "STYLE", "REFACTOR", "PERF", "TEST", "BUILD", "CI", "CHORE"] }
},
"required": ["scope", "subject", "type"],
"additionalProperties": false
}
}
},
"required": ["suggestions"],
"additionalProperties": false
}
}
}
}
The user message ends where our template ends. No “Your response should be in JSON format”, no
schema pasted into the text. The schema went into response_format with "strict": true, which is
what tells OpenAI to enforce it rather than treat it as a hint. That’s also fewer prompt tokens on
every call.
Call it:
curl -X POST localhost:8080/api/structured/commit-messages \
-H 'Content-Type: application/json' \
-d '{
"changes": "Added a refresh-token endpoint to the auth service and fixed a typo in the README.",
"options": 3
}'
{
"suggestions": [
{ "type": "FEAT", "scope": "auth", "subject": "add refresh token endpoint; fix README typo" },
{ "type": "FEAT", "scope": "auth", "subject": "implement refresh token endpoint, correct README typo" },
{ "type": "FEAT", "scope": "auth", "subject": "add refresh-token endpoint and fix README typo" }
]
}
The shape is perfect, and the content is a bit lazy. All three squeeze the README fix into a feat
commit. A schema controls the structure of an answer, not its judgement. If you wanted the typo as
a separate docs commit, that’s a prompt problem.
type comes back as FEAT, because the enum constants are upper case.
CommitSuggestion has a message() method that renders feat(auth): add ..., but it doesn’t
appear in the response. It isn’t a record component, so it’s not in the schema and Jackson doesn’t
serialise it. It’s there for Java code that uses the record.
Why validate if OpenAI already enforces it?
Native enforcement isn’t the same on every provider, or for every schema feature. The Spring AI
team’s post on this feature
lists $ref, anyOf and friends, regex patterns and recursive types as often only partly supported.
validateSchema() is a check you control, and it costs nothing when the reply is already valid.
Here, it logged JSON validation succeeded and moved on.
Also notice that useProviderStructuredOutput() fails safe in one direction. Spring AI only sends the
schema natively when the model’s options implement StructuredOutputChatOptions. If they don’t, it
quietly falls back to the prompt-based format instructions. Switching providers won’t break the
call, but it might quietly take away the enforcement you thought you had. That’s one more reason to
keep validateSchema() alongside it.
Why there’s a wrapper record
The endpoint returns CommitSuggestions, not List<CommitSuggestion>:
/** Wrapper record: OpenAI native structured output rejects top-level arrays. */
record CommitSuggestions(List<CommitSuggestion> suggestions) {}
I tried it without the wrapper, asking for a
ParameterizedTypeReference<List<CommitSuggestion>> with useProviderStructuredOutput(). OpenAI
rejected the request before generating anything:
com.openai.errors.BadRequestException: 400: Invalid schema for response_format 'json_schema': schema must be a JSON Schema of 'type: "object"', got 'type: "array"'.
According to the Spring AI post, the prompt-based dial has no such restriction, so the error only shows up when you switch to native. The wrapper costs you one level of nesting in the JSON.
Dial 3: responseEntity() for the record and the bill
Every retry is another full call to the model, so sooner or later you’ll want to know what a request
actually cost. entity() throws that information away. responseEntity() keeps it:
ResponseEntity<ChatResponse, ReleaseNotes> response = chatClient.prompt()
.system(RELEASE_MANAGER_PERSONA)
.user("Version " + request.version() + " changes:\n" + changeList)
.call()
.responseEntity(ReleaseNotes.class, spec -> spec.validateSchema());
return new ReleaseNotesResult(response.entity(), TokenUsage.from(response.response()));
This ResponseEntity is Spring AI’s (org.springframework.ai.chat.client), not Spring MVC’s. If both
end up in the same class, watch your imports. entity() is the parsed ReleaseNotes, and response()
is the full ChatResponse, whose metadata includes token usage. The small
TokenUsage
record copies the three numbers out.
curl -X POST localhost:8080/api/structured/release-notes \
-H 'Content-Type: application/json' \
-d '{
"version": "2.4.0",
"changes": [
"Add CSV export to the reports page",
"Fix timezone bug in scheduled emails",
"Bump Jackson to 2.17",
"Remove deprecated /v1/users endpoint, use /v2/users"
]
}'
{
"notes": {
"headline": "Export reports to CSV and fix scheduled email timezones; /v1/users removed",
"added": ["Export reports as CSV directly from the Reports page"],
"improved": [],
"fixed": ["Scheduled emails now respect your selected timezone"],
"breakingChanges": [
"The deprecated /v1/users API endpoint has been removed. Action required: update any integrations and scripts to use /v2/users instead."
]
},
"usage": {
"promptTokens": 421,
"completionTokens": 625,
"totalTokens": 1046
}
}
The input is close to last time’s plain-text release notes, and so is the judgement: the Jackson bump is dropped, and the endpoint removal carries the action users need to take. The difference is that “Breaking Changes” is now a field your release tooling can read, not a heading it has to find.
Look at completionTokens. The reply is maybe a hundred tokens of JSON, but we were billed for 625.
The raw usage in the log shows "reasoning_tokens" : 512. GPT-5 is a reasoning model, and the
thinking it does before answering is billed as output.
The usage is also accumulated across retries. The validation advisor adds up the usage of every attempt and puts the total on the response it returns. If a request needed three tries, these numbers show all three. That’s the honest figure for cost tracking, but don’t read it as the size of one call.
Dial 4: your own validation advisor
validateSchema() gives you 3 retries. For the strict code review, the sample wants 5, and you can’t
set that through the spec. Instead, build the advisor yourself and register it on a dedicated
client:
this.strictReviewClient = builder.clone()
.defaultAdvisors(
StructuredOutputValidationAdvisor.builder()
.outputType(CodeReview.class)
.maxRepeatAttempts(STRICT_MAX_REPEAT_ATTEMPTS)
.build(),
new SimpleLoggerAdvisor())
.build();
builder.clone() matters here. Both clients in this controller come from the same injected
ChatClient.Builder. Without clone(), the second client’s default advisors would be added to the
same builder as the first’s.
The advisor is tied to CodeReview.class, because its schema is generated once, when it’s built. So
the strict client only makes sense for code reviews.
The endpoint then calls plain entity(), with no spec:
@PostMapping("/code-review/strict")
CodeReview reviewCodeStrict(@RequestBody @Valid CodeSubmission submission) {
return strictReviewClient.prompt()
.system(REVIEWER_PERSONA)
.user(u -> u.text("Language: {language}\n\nCode:\n{code}")
.param("language", submission.language())
.param("code", submission.code()))
.call()
.entity(CodeReview.class);
}
curl -X POST localhost:8080/api/structured/code-review/strict \
-H 'Content-Type: application/json' \
-d '{
"language": "java",
"code": "String sql = \"SELECT * FROM users WHERE name = '\''\" + name + \"'\''\";\nResultSet rs = stmt.executeQuery(sql);"
}'
The response has five findings, led by the one you’d hope for (trimmed to the first):
{
"summary": "The code is vulnerable to SQL injection, mishandles special characters, lacks resource and error management, and uses SELECT *; switch to PreparedStatement with try-with-resources and select only needed columns.",
"verdict": "REQUEST_CHANGES",
"findings": [
{
"line": 1,
"severity": "HIGH",
"category": "SECURITY",
"issue": "SQL injection risk due to string concatenation of untrusted input.",
"suggestion": "Use a PreparedStatement with parameter binding instead of concatenation, e.g.: ..."
}
]
}
The log proves which advisor ran. It starts at 5 attempts left, not 3:
DEBUG ... StructuredOutputValidationAdvisor : Validating JSON output against schema. Attempts left: 5
DEBUG ... StructuredOutputValidationAdvisor : JSON validation succeeded
The catches
Catch 1: “3 attempts” means 4 calls
The Spring blog post calls the default both “up to 3 attempts” and “3 retry attempts”. Those aren’t
the same thing, and the code settles it: maxRepeatAttempts counts retries after the first
call. The loop runs 1 + maxRepeatAttempts times, and the builder’s
Javadoc says zero means “the model is called exactly once”. So the default allows up to 4 paid calls,
and the strict client’s 5 allows up to 6. The scripted test confirms it: four bad replies in a row
produced exactly four calls.
Note: the “Attempts left: 3” in the log is printed on the first call, which is a little misleading. It’s counting the retries still available.
Catch 2: when retries run out, you might not get an exception
Once the budget is spent, the advisor stops and hands back the last reply, invalid or not. What happens next depends on how it’s invalid. I scripted four bad replies of each kind.
With "severity":"CRITICAL" every time, Jackson can’t map the value, and you get an exception after
four calls:
tools.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `com.example.structured_output.StructuredOutputController$Severity` from String "CRITICAL": not one of the values accepted for Enum class: [HIGH, LOW, MEDIUM]
With verdict missing every time, Jackson is perfectly happy. It sets the missing component to
null, and you get this back with no exception at all:
CodeReview[summary=x, verdict=null, findings=[]]
So validateSchema() makes valid output much more likely, but it doesn’t guarantee it. If a null
would cause damage further down, like a CI gate that treats “no verdict” as “not rejected”, check
for it yourself. The schema said the field was required. The record doesn’t enforce that.
Catch 3: registering the advisor and calling validateSchema() multiplies the calls
The Spring blog post says an explicitly registered StructuredOutputValidationAdvisor replaces the
one validateSchema() would add. A comment in the sample says the same. I checked the code: the spec
pushes a new advisor onto the chain without looking for an existing one.
So I tried it. A client with an explicit advisor (maxRepeatAttempts(5)), called with
entity(CodeReview.class, spec -> spec.validateSchema()), against a model that always replies with
invalid JSON:
BOTH calls=24
EXPLICIT_ONLY calls=6
Twenty-four calls. The two advisors nest, so the inner loop’s 6 calls run once for each of the outer loop’s 4 attempts. On a reasoning model, that’s 24 paid calls for one failed request.
Note: the sample’s strict endpoint is safe, because it calls plain entity(CodeReview.class)
and the explicit advisor is the only one. The risk comes later, when someone adds validateSchema()
to that call “to be sure”. With an explicit advisor on the client, never call validateSchema() on
the same request.
Catch 4: the logger doesn’t see the retries
SimpleLoggerAdvisor has order 0, and the validation advisor defaults to
LOWEST_PRECEDENCE - 2000. Lower runs first, so the logger wraps the whole retry loop. With a
scripted model that needed two calls, the log showed one request: line, one response: line, and
in between:
WARN StructuredOutputValidationAdvisor - JSON validation failed: SchemaValidation[success=false, errorMessage=required property 'verdict' not found]
That WARN line is your only sign that a retry happened. If you’re tracking how often your prompts
need correcting, count those lines. Don’t count SimpleLoggerAdvisor requests.
The logged request also doesn’t show the format instructions. They’re added to the user message
later, by the advisor that actually calls the model, so the log shows them separately, in the
request context under spring.ai.chat.client.output.format.
Catch 5: the error doesn’t say which field
Look at the enum error again: does not have a value in the enumeration ["LOW", "MEDIUM", "HIGH"].
It doesn’t say findings[0].severity. Because the model also doesn’t get its previous reply back, it
has to work out which field was wrong from the list of allowed values. With one Severity enum
that’s easy. With two enums sharing values, it’s a guess. Distinct enum values make retries more
likely to succeed.
Streaming is out
entity() only exists after call(), and the validation advisor’s adviseStream returns an
UnsupportedOperationException. You can’t validate a reply before it has finished arriving, so if
you stream, you parse and validate yourself. The sample doesn’t stream.
Which one should you use?
entity(Type.class, spec -> spec.validateSchema())as the default. It works with any model, costs nothing extra when the reply is valid, and turns most bad replies into a second try instead of a 500.- Add
useProviderStructuredOutput()when your provider supports it, as OpenAI does. You get enforcement at generation time and a shorter prompt. Wrap lists in a record. responseEntity()whenever you care about cost, which with a reasoning model is always.- An explicit
StructuredOutputValidationAdvisoronly when you need a different retry budget, on a client of its own for that one type, and withoutvalidateSchema()on top.
The Spring AI post also shows a custom StructuredOutputConverter for models that wrap their JSON
in Markdown code fences. GPT-5 didn’t do that in any of my calls, and the sample doesn’t need one, so
I’ve left it out.
Summary
Describe the answer as a record, with enums for anything that has a fixed set of values and
@JsonPropertyDescription wherever the model needs a hint. Call entity() with validateSchema()
by default, and add useProviderStructuredOutput() on OpenAI. Read maxRepeatAttempts as retries
after the first call, and never stack an explicit validation advisor with validateSchema(), or
one bad request can cost you 24 calls. And don’t let the retries make you complacent: when they run
out, a missing field arrives as null, so check the values your code depends on.
