ChatClient call with .content() — a string, handed back to whatever prints it. Most real code doesn’t want a string. It wants a Java record it can pass to a repository, a List it can iterate, a Map it can look a key up in. This article covers ChatClient.entity(), the API that maps a model’s raw JSON text straight into one of those — and StructuredOutputValidationAdvisor, which is what happens when the model’s JSON doesn’t actually fit the shape you asked for and something has to decide whether to give up or try again.
Nothing below calls a real LLM. Every test in this article’s companion repo drives the real entity() machinery and the real StructuredOutputValidationAdvisor against a hand-written ChatModel that returns a queued, pre-programmed response instead of calling an API — the same technique, and the same confirmed-by-javap reasoning for why it’s legitimate, that the tool-calling article in this series used.
Versions. Spring Boot 4.1.1 and Spring AI 2.0.1, on Java 25 (LTS) — the same baseline as the rest of this series.StructuredOutputValidationAdvisorlives inorg.springframework.ai.chat.client.advisor, inside the samespring-ai-client-chatartifact asToolCallingAdvisor— unlike the Tool Search Advisor pieces in this series’ tool-calling article, it needs no separate Maven Central artifact or version pin. It also confirms something worth knowing on its own: Spring AI 2.0’s JSON stack is Jackson 3 (tools.jackson.databind), not the classiccom.fasterxml.jacksonpackage every older tutorial assumes — visible directly in this advisor’s constructor and field signatures.
Turning a model’s raw text into a Java record
A chat model’s response is, underneath everything, a string.ChatClient.CallResponseSpec.entity(Class) is what turns that string into a typed Java object: it builds a JSON schema from the target type, folds instructions for producing JSON matching that schema into the prompt, and parses whatever comes back.
public record TicketTriage(String category, Priority priority, boolean requiresEscalation,
List<String> suggestedActions) {
}
public enum Priority { LOW, MEDIUM, HIGH, CRITICAL }
Source: TicketTriage.java and Priority.java. Priority being a plain Java enum matters more than it looks like it should — it’s what gives the generated schema a real, checkable constraint, not just a suggestion in a comment:
model call count: 1
mapped record: TicketTriage[category=billing, priority=HIGH, requiresEscalation=true, suggestedActions=[Refund the duplicate charge, Reply within 4 hours]]
JSON schema BeanOutputConverter generated for TicketTriage:
{
"$schema" : "https://json-schema.org/draft/2020-12/schema",
"type" : "object",
"properties" : {
"category" : {
"type" : "string"
},
"priority" : {
"type" : "string",
"enum" : [ "LOW", "MEDIUM", "HIGH", "CRITICAL" ]
},
"requiresEscalation" : {
"type" : "boolean"
},
"suggestedActions" : {
"type" : "array",
"items" : {
"type" : "string"
}
}
},
"required" : [ "category", "priority", "requiresEscalation", "suggestedActions" ],
"additionalProperties" : false
}
Output: 01-entity-record-valid.txt. Notice the schema isn’t just types — priority carries the real four-value enum, and additionalProperties: false means a field the model invents that isn’t part of the record gets rejected too, not silently ignored.
TicketTriage triage = client.prompt()
.user("Customer was charged twice for the same order and wants a refund today.")
.call()
.entity(TicketTriage.class);
Source: EntityBindingRecordTest.java.
Going deeper on this section
- Companion repo: EntityBindingRecordTest.java
- Official reference: Spring AI – Structured Output Converter
What happens when the JSON doesn’t fit
The happy path above assumes the model always returns exactly the JSON the schema describes. It doesn’t always. Send the same request and have the model reply with"priority":"URGENT" — a plausible-sounding word, and not one of Priority‘s four real values — and plain entity() doesn’t return a half-filled record or a null field. It throws, on the very first call, with no chance to recover:
model call count: 1
exception thrown to the caller (plain entity() never retries):
tools.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `com.ankurm.structuredoutput.domain.Priority` from String "URGENT": not one of the values accepted for Enum class: [HIGH, LOW, MEDIUM, CRITICAL]
at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); byte offset: #UNKNOWN] (through reference chain: com.ankurm.structuredoutput.domain.TicketTriage["priority"])
Output: 02-entity-record-invalid-no-retry.txt. This is a real Jackson 3 exception, not a Spring AI one — entity()‘s schema and format instructions are a strong hint to the model, not an enforced contract, and BeanOutputConverter.convert() is an ordinary deserialization call with no retry loop of its own. Whatever calls entity() needs to either catch this or have a way to ask the model to fix its own mistake. The rest of this article is about the second option.
The schema constrains what’s valid, not what gets sent. A JSON Schemaenumtells a well-behaved model what values are acceptable, and most of the time that’s enough to keep it in line. But nothing on the wire enforces it — the schema travels to the model as text in the prompt, and the model is still free to reply with whatever text it generates. Treatentity()‘s schema as a strong nudge, not a validation layer, and you won’t be surprised when this exception shows up in production logs.
Going deeper on this section
- Companion repo: EntityBindingRecordTest.java (see
plainEntityThrowsImmediatelyOnAnInvalidEnumValue_noRetry)
Lists and maps, the same way
entity() isn’t limited to a single record. A ParameterizedTypeReference maps a whole JSON array to a List of records in one call, and the same mechanism maps a JSON object to a plain Map<String, Object> when there’s no record worth declaring for a one-off extraction:
List<ActionItem> actionItems = client.prompt()
.user("Extract the action items from this meeting note: ...")
.call()
.entity(new ParameterizedTypeReference<List<ActionItem>>() { });
Map<String, Object> flags = client.prompt()
.user("Extract the feature flags mentioned here: ...")
.call()
.entity(new ParameterizedTypeReference<Map<String, Object>>() { });
Source: EntityBindingListTest.java and EntityBindingMapTest.java. Both captured from real, scripted runs:
mapped list (3 items):
ActionItem[owner=Priya, task=Send the revised contract, dueDate=2026-09-25]
ActionItem[owner=Marcus, task=Confirm the vendor's SLA numbers, dueDate=2026-09-26]
ActionItem[owner=Priya, task=Book the kickoff call, dueDate=2026-09-24]
mapped map: {darkModeEnabled=true, maxUploadSizeMb=25, betaFeatures=[new-dashboard, ai-search]}
Output: 03-entity-list-of-records.txt and 04-entity-map.txt. Everything the rest of this article covers about validating and retrying a single record’s JSON applies exactly the same way to a list or a map — the schema just describes an array or an open object instead of a fixed set of properties.
Going deeper on this section
- Companion repo: ActionItem.java
validateSchema(): the one flag that turns entity() into a retry loop
entity() takes an optional Consumer<EntityParamSpec>, and EntityParamSpec has exactly two methods: useProviderStructuredOutput() and validateSchema(). The second one is what closes the gap the previous section opened — disassembling DefaultChatClient$DefaultCallResponseSpec.resolveAdvisorChain shows precisely what it does:
if (spec.isValidated()) {
StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
.outputJsonSchema(converter.getJsonSchema())
.build();
advisorChain = advisorChain.mutate().push(advisor).build();
}
That’s real bytecode, transcribed rather than pseudocode — validateSchema() builds a real StructuredOutputValidationAdvisor from the exact same JSON schema string BeanOutputConverter already generated to parse the response, and pushes it onto the advisor chain for that one call only. Nothing is registered globally; a call without the flag never sees this advisor at all.
StructuredOutputValidationAdvisor allows 3 retries (4 attempts total) and registers itself at an advisorOrder of 2147481647 — 2000 less than Ordered.LOWEST_PRECEDENCE, deliberately near the very end of the advisor chain but, notably, not exactly at it. Both are confirmed from the real default constructor in Builder, not documentation.
Going deeper: wiring the advisor directly instead of through entity()
StructuredOutputValidationAdvisor.builder() is public, and nothing requires going through entity()‘s convenience flag to use it. .outputType(Class) (or .outputJsonSchema(String) directly, or a TypeReference/ParameterizedTypeReference overload for generics) plus .maxRepeatAttempts(int) builds a standalone advisor you can register with .defaultAdvisors(...) on the ChatClient builder — reused across every call the client makes — or pass per-call via .advisors(advisor) on a single request. This module’s own ValidationAdvisorExhaustsRetriesTest does exactly that, setting maxRepeatAttempts(2) explicitly so the test doesn’t depend on the advisor’s default of 3.
Going deeper on this section
- Companion repo: ValidationAdvisorRetriesOnceThenSucceedsTest.java
- Related: Tool Calling in Spring AI 2.0 — the same “built from the exact ChatModel/advisor bytecode, not the docs” verification approach applied to
ToolCallingAdvisor
Watching a real retry happen
WithvalidateSchema() turned on, the same “URGENT” mistake from earlier doesn’t throw — it triggers exactly one retry, and the second attempt succeeds:
model call count: 2
attempt 1 -- model sent priority "URGENT", not one of Priority's four enum values
attempt 2 -- model sent priority "HIGH", validation passed
final mapped record: TicketTriage[category=billing, priority=HIGH, requiresEscalation=true, suggestedActions=[Refund the duplicate charge]]
Output: 05-validation-advisor-retry-then-success.txt (trimmed — the real file also captures both prompts’ full user-message text). What’s genuinely worth seeing is how little the advisor actually changes between attempt 1 and attempt 2. The schema and format instructions are already present on the first prompt — entity() bakes those in before the advisor chain even runs:
Customer was charged twice for the same order and wants a refund today.
Your response should be in JSON format.
...
Here is the JSON Schema instance your output must adhere to:
```{
"$schema" : "https://json-schema.org/draft/2020-12/schema",
...
"priority" : {
"type" : "string",
"enum" : [ "LOW", "MEDIUM", "HIGH", "CRITICAL" ]
},
...
}```
And the advisor’s entire contribution on the retry is one appended line, from a real, scripted schema-validator error message:
Customer was charged twice for the same order and wants a refund today.
Output JSON validation failed because of: does not have a value in the enumeration ["LOW", "MEDIUM", "HIGH", "CRITICAL"]
Your response should be in JSON format.
...
Output: 05-validation-advisor-retry-then-success.txt.
Going deeper: retries don’t stack on each other
Disassembling adviseCall shows every retry augments the original request passed into the advisor, not the previous attempt’s mutated one — the bytecode reloads the method’s first parameter each time, not a loop-local variable holding the last attempt. That means a third attempt, if this example needed one, would carry the original user text plus that attempt’s own single error line, not an accumulating pile of every previous mistake. Each retry is a clean, one-shot correction, not a conversation that gets longer and messier with every failure.
Going deeper on this section
- Companion repo: ValidationAdvisorRetriesOnceThenSucceedsTest.java
What happens when every retry fails
The previous section’s model eventually got it right. Real models don’t always. Script every attempt to keep sending the same invalid"URGENT" value, with maxRepeatAttempts(2) set explicitly (1 initial attempt + 2 retries = 3 total calls), and here’s what a genuinely exhausted retry budget looks like:
model call count: 3 (1 initial attempt + maxRepeatAttempts(2) retries)
every attempt returned the same invalid "URGENT" priority value
raw content returned to the caller (still invalid -- the advisor does not throw):
{"category":"billing","priority":"URGENT","requiresEscalation":true,
"suggestedActions":["Refund the duplicate charge"]}
Output: 06-validation-advisor-exhausts-retries.txt. This is the finding worth stopping on: the advisor’s loop does not throw when it exhausts every retry. Disassembling adviseCall shows the loop condition is simply “attempts remaining, and not yet valid” — when that becomes false because attempts ran out, not because validation passed, the method falls straight through to returning the last response it got, silently. If this test had called .entity() instead of .content() on top of that exhausted advisor, the still-invalid JSON would then hit the same unguarded BeanOutputConverter.convert() the earlier “what happens when the JSON doesn’t fit” section showed throwing — just one call later than it would have without the advisor at all.
What the defaults don’t do.StructuredOutputValidationAdvisorimproves your odds; it does not guarantee a valid result. Code callingentity(Class, spec -> spec.validateSchema())still needs to handle the same deserialization exception plainentity()can throw — the advisor only makes that exception less likely, by spending up tomaxRepeatAttemptsextra model calls first. For a tool with hard latency or cost budgets, that’s a real trade-off, not a free upgrade.
Going deeper on this section
- Companion repo: ValidationAdvisorExhaustsRetriesTest.java
Should you validate every structured-output call?
Not every one. A well-behaved model against a simple, low-stakes schema will get the JSON right the overwhelming majority of the time, andvalidateSchema()‘s worst case is real cost: up to maxRepeatAttempts extra round trips to a model, every one of them billed and adding latency, before your code sees anything at all. Reach for it where a malformed response is expensive to discover downstream — writing to a database, triggering a workflow, anything a human won’t immediately notice went wrong — and skip it where a caught exception and a simple retry-the-whole-request at your own application layer is honestly just as good.
A stricter schema is often cheaper than more retries. Most of this article’s validation failures came from one enum field. That’s not incidental — a closed set of values is exactly the kind of mistake a schema can catch and a model can act on the feedback for. A schema with vaguestringfields everywhere gives the validator far less to work with, and a model far less to correct against, than one that uses enums, required fields, andadditionalProperties: falsewherever the shape of the data genuinely allows it.
Every Spring AI article on this site
| Article | Covers |
|---|---|
| Spring AI 2.0 in 10 Minutes: ChatClient on Spring Boot 4.1 | the beginner-level ChatClient build this article’s versions and setup follow |
| Build an MCP Server with Spring AI 2.0 | exposing tools over MCP instead of calling them locally through a ChatClient |
| Spring AI MCP Client: Calling External MCP Servers from ChatClient | tools that live in a separate process, registered the same way as a local @Tool method |
| Securing an MCP Server with Spring Security 7 | OAuth2 scopes per tool, JWT resource servers, and MDC audit logging |
| Tool Calling in Spring AI 2.0 | @Tool, ToolCallingAdvisor, returnDirect, ToolContext, and progressive disclosure with the Tool Search Advisor |
| Spring AI 1.x to 2.0: The Migration Guide | what breaks, and what breaks silently, upgrading an existing 1.x application |
| Production-Grade RAG with Spring AI | chunking, ingestion, retrieval, reranking, and a faithfulness check against pgvector |
| Spring AI RAG in Java: Complete Code Tour | the same RAG project, file by file |
Further reading
- Companion repository for this article: asmhatre/spring-ai, structured-output module
- Official reference: Spring AI – Structured Output Converter
- Official reference: Spring AI – Chat Client API
No Comments yet!