Structured Output
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Written against Spring AI 2.0.1, Spring Boot 4.1.x and Java 21 (verified 2026-09-29). A language model returns text; an application
wants a record. Spring AI bridges the two by generating a JSON Schema from your Java type, telling the model to follow
it, and parsing the reply back. This page adds the third DocsAssistant capability, a POST /api/extract endpoint that reads
a documentation page and returns its catalogue metadata (title, component, topics, difficulty) as a typed object. The
conceptual background (why schemas help and where they fail) is in
Structured Output & Tool Calling; the LangChain
counterpart is LangChain Structured Output. All classes here compiled against
the 2.0.1 jars, and the parsing, cleaning and retry behaviour reported below was observed by feeding canned replies to
a stub ChatModel; no model server was available, so nothing here measures how often a real model gets the format right.
The metadata record and entity()
Define the shape as a record. Field names are the JSON keys, and nested records, lists, enums and maps are supported.
@JsonPropertyOrder fixes the order of properties in the generated schema, which otherwise comes out alphabetical rather
than in declaration order, and the Jackson description annotations become description entries in the schema, which is
the cheapest way to tell the model what a field means.
package com.example.docsassistant;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
@JsonPropertyOrder({"title", "component", "topics", "difficulty"})
public record DocMetadata(String title, String component, List<String> topics, Difficulty difficulty) {
public enum Difficulty { INTRODUCTORY, INTERMEDIATE, ADVANCED }
}
Source: Spring AI: Structured Output.
package com.example.docsassistant;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonClassDescription;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
@JsonClassDescription("Catalogue metadata of one documentation page")
record DescribedMetadata(
@JsonPropertyDescription("Page title exactly as written in the heading") String title,
@JsonPropertyDescription("Sub-section directory, e.g. spring-ai or vector-rag") String component,
@JsonPropertyDescription("Between one and five lowercase topic keywords") List<String> topics) { }
Source: Spring AI: Structured Output.
Asking for the record is a one-word change to the call chain: finish with entity(Type.class) instead of content().
package com.example.docsassistant;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
@Service
class ExtractionService {
private final ChatClient chatClient;
ExtractionService(ChatClient docsChatClient) {
this.chatClient = docsChatClient;
}
/** The basic form: the record's JSON schema goes into the prompt, the reply is parsed back. */
DocMetadata extract(String pageText) {
return this.chatClient.prompt()
.system("Extract catalogue metadata from the documentation page you are given.")
.user(u -> u.text("Page:\n{page}").param("page", pageText))
.call()
.entity(DocMetadata.class);
}
}
Source: Spring AI: Structured Output.
What happens underneath is visible in the prompt. The converter appended the following text to the user message; it
is the whole mechanism (the schema was printed from a run of new BeanOutputConverter<>(DocMetadata.class).getFormat()).
Your response should be in JSON format.
Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
Do not include markdown code blocks in your response.
Remove the ```json markdown from the output.
Here is the JSON Schema instance your output must adhere to:
```{
"$schema" : "https://json-schema.org/draft/2020-12/schema",
"type" : "object",
"properties" : {
"title" : { "type" : "string" },
"component" : { "type" : "string" },
"topics" : { "type" : "array", "items" : { "type" : "string" } },
"difficulty" : { "type" : "string", "enum" : [ "INTRODUCTORY", "INTERMEDIATE", "ADVANCED" ] }
},
"required" : [ "title", "component", "topics", "difficulty" ],
"additionalProperties" : false
}```
The layout of the schema above is condensed to fewer lines for reading; the property order and content are as generated.
The default call is a best effort with no guarantee. The model is asked to comply and the reply is parsed afterwards; a
missing field, a value outside an enum or prose around the JSON makes the parser throw. In tests, an enum value
(EXPERT) outside the declared set failed with a Jackson InvalidFormatException, and a reply that began "Sure!" before
the JSON failed with a StreamReadException. The two reliability switches below are the answer to those failures. Source:
Spring AI: Structured Output.
Generics, maps and lists
entity(Class) is for concrete types. For a List or Map of your records, pass a ParameterizedTypeReference.
package com.example.docsassistant;
import java.util.List;
import java.util.Map;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.converter.ListOutputConverter;
import org.springframework.ai.converter.MapOutputConverter;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.stereotype.Service;
@Service
class CollectionExtraction {
private final ChatClient chatClient;
CollectionExtraction(ChatClient docsChatClient) {
this.chatClient = docsChatClient;
}
/** Generic types need a ParameterizedTypeReference. */
List<DocMetadata> extractAll(List<String> pages) {
return this.chatClient.prompt()
.user(u -> u.text("Extract metadata for each of these pages:\n{pages}")
.param("pages", String.join("\n---\n", pages)))
.call()
.entity(new ParameterizedTypeReference<List<DocMetadata>>() { });
}
Map<String, Object> asMap(String pageText) {
return this.chatClient.prompt()
.user(u -> u.text("Give the {page} as a map with keys title and component")
.param("page", pageText))
.call()
.entity(new ParameterizedTypeReference<Map<String, Object>>() { });
}
/** ListOutputConverter: comma-separated values instead of JSON. */
List<String> components() {
return this.chatClient.prompt()
.user("List five documentation components of the Irurueta site")
.call()
.entity(new ListOutputConverter(new DefaultConversionService()));
}
/** MapOutputConverter: an untyped JSON object as Map<String, Object>. */
Map<String, Object> numbers() {
return this.chatClient.prompt()
.user("Give me the numbers 1 to 3 under the key numbers")
.call()
.entity(new MapOutputConverter());
}
}
Source: Spring AI: Output Converters.
The converter classes
entity(…) is a front for the StructuredOutputConverter<T> abstraction, which you can also use directly against the
lower-level ChatModel API. A converter is two things at once: a FormatProvider (getFormat(), the instructions for the
prompt) and a Spring Converter<String, T> (convert(String), the parser). Since 2.0 it has a third, optional part:
getJsonSchema(), a default method that returns the schema as a string (an empty string when the converter has none).
That method is what lets a converter take part in the two reliability switches; a converter that leaves it at the default
turns both switches into no-ops.
| Converter | Produces | Notes |
|---|---|---|
|
A Java class, record or generic type (constructed from a |
Generates a JSON Schema (draft 2020-12) from the type and deserialises with a Jackson 3 |
|
|
Asks for RFC 8259 JSON and converts it with a message converter |
|
|
Asks for comma-separated values and converts them with a |
entity(Type.class) uses a BeanOutputConverter internally. Using one yourself is useful when you want the format text
in a prompt you build by hand, or when the call is a streaming one (below).
package com.example.docsassistant;
import java.util.stream.Collectors;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
@Service
class StreamThenConvert {
private final ChatClient chatClient;
StreamThenConvert(ChatClient docsChatClient) {
this.chatClient = docsChatClient;
}
/** entity() needs the whole reply, so aggregate the stream first and convert explicitly. */
DocMetadata extractFromStream(String pageText) {
var converter = new BeanOutputConverter<>(DocMetadata.class);
Flux<String> chunks = this.chatClient.prompt()
.user(u -> u.text("Extract metadata from:\n{page}\n{format}")
.param("page", pageText)
.param("format", converter.getFormat()))
.stream()
.content();
String reply = chunks.collect(Collectors.joining()).block();
return converter.convert(reply);
}
}
entity() is call()-only because typed parsing needs the complete reply. Aggregating first gives up the benefit of
streaming for that request, which is usually fine for extraction; block() must not run on a reactive event-loop thread,
so do this from a servlet thread or wrap it, as discussed in Streaming &
Reactive. Source: Spring AI: Structured Output.
Cleaning replies before parsing
Models often wrap JSON in a markdown fence or, for reasoning models, precede it with a thinking block. The Structured
Output reference page motivates a hand-written converter with the fenced case. With the 2.0.1 jar the default
BeanOutputConverter already handled both: a reply wrapped in a json fence parsed, and a reply starting with a
<think>…</think> block followed by JSON parsed. Prose in front of the JSON ("Sure! {…}") still failed, as noted above.
The mechanism is a ResponseTextCleaner, an interface with clean(String) and ready-made implementations
MarkdownCodeBlockCleaner, ThinkingTagCleaner and CompositeResponseTextCleaner. The BeanOutputConverter constructors that
take a JsonMapper also take a cleaner, so you can choose exactly what is removed.
package com.example.docsassistant;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.ai.converter.CompositeResponseTextCleaner;
import org.springframework.ai.converter.MarkdownCodeBlockCleaner;
import org.springframework.ai.converter.ThinkingTagCleaner;
import tools.jackson.databind.json.JsonMapper;
class CleaningConverters {
/** Reasoning models may prefix a think block and wrap JSON in fences; clean before parsing. */
static BeanOutputConverter<DocMetadata> forReasoningModels() {
var cleaner = CompositeResponseTextCleaner.builder()
.addCleaner(new ThinkingTagCleaner())
.addCleaner(new MarkdownCodeBlockCleaner())
.build();
return new BeanOutputConverter<>(DocMetadata.class, JsonMapper.builder().build(), cleaner);
}
}
Source: Spring AI: Output Converters.
For anything a cleaner does not cover, write your own StructuredOutputConverter<T>. Delegate getFormat() and
getJsonSchema() to a BeanOutputConverter so that the reliability switches keep working, and pre-process only the
text in convert.
package com.example.docsassistant;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.ai.converter.StructuredOutputConverter;
/** Extracts the JSON out of a fenced block, delegating schema and parsing to BeanOutputConverter. */
public class LenientJsonOutputConverter<T> implements StructuredOutputConverter<T> {
private static final Pattern FENCE = Pattern.compile("```(?:json)?\\s*([\\s\\S]*?)```");
private final BeanOutputConverter<T> delegate;
public LenientJsonOutputConverter(Class<T> targetType) {
this.delegate = new BeanOutputConverter<>(targetType);
}
@Override
public String getFormat() {
return this.delegate.getFormat();
}
@Override
public String getJsonSchema() {
return this.delegate.getJsonSchema();
}
@Override
public T convert(String source) {
Matcher matcher = FENCE.matcher(source);
String json = matcher.find() ? matcher.group(1).trim() : source.trim();
return this.delegate.convert(json);
}
}
Pass it as entity(new LenientJsonOutputConverter<>(DocMetadata.class)). For non-JSON formats such as YAML or CSV, implement
the interface from scratch with your own getFormat() and convert(…) and leave getJsonSchema() at its default; the
two switches then sit out. Source: Spring AI:
Output Converters.
Provider-native structured output
The default approach is response-side: the schema is text in the prompt and the reply is parsed afterwards. Most modern
providers also accept the schema as an API-level constraint, so the provider’s runtime enforces it. Spring AI exposes that
portably as useProviderStructuredOutput() on the entity(…) spec. The user message then no longer carries the format
text, and the request is smaller.
package com.example.docsassistant;
import org.springframework.ai.chat.client.AdvisorParams;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
@Service
class NativeExtraction {
private final ChatClient chatClient;
NativeExtraction(ChatClient docsChatClient) {
this.chatClient = docsChatClient;
}
/** Reliability switches: provider-side schema plus a self-correcting retry loop. */
DocMetadata extractReliably(String pageText) {
return this.chatClient.prompt()
.user(u -> u.text("Extract metadata:\n{page}").param("page", pageText))
.call()
.entity(DocMetadata.class, spec -> spec
.useProviderStructuredOutput()
.validateSchema());
}
/** Native structured output for every entity() call of one request. */
DocMetadata extractNative(String pageText) {
return this.chatClient.prompt()
.advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT)
.user(pageText)
.call()
.entity(DocMetadata.class);
}
}
Spring AI detects support by checking whether the model’s options class implements StructuredOutputChatOptions. If it
does not, the flag is silently ignored and the prompt-based default runs, so a wrong assumption fails soft rather than loud;
OllamaChatOptions, for instance, is one of the classes that implements it. The switch is off by default because older
and smaller models reject the request. The reference lists OpenAI, Anthropic, Google GenAI, Mistral AI and Ollama (model
dependent) as supporting it, and names the limits to keep in mind.
-
Providers accept only a subset of JSON Schema.
$ref, deeply nested arrays,allOf/anyOf/oneOf, regular expressions and recursive types are common gaps. -
OpenAI’s Structured Outputs API does not accept a top-level array, so
entity(new ParameterizedTypeReference<List<…>>() {})with the switch fails there. Wrap the list in a container record (record FilmographyList(List<…> films)) or use the prompt-based default. -
Ollama models with a thinking mode may return their reasoning trace as text instead of JSON, causing a deserialisation error. Try a different model or combine native output with
validateSchema().
Some providers additionally have their own JSON mode, configured separately and independent of the switch: OpenAI through
spring.ai.openai.chat.response-format (a JSON object or a JSON schema), and Ollama and Mistral AI through their own format
and response-format options, described on their reference pages. Source:
Spring AI: Provider-Native Structured Output.
Validation and self-correction
validateSchema() turns on a retry loop. Spring AI validates the reply against the JSON Schema of the target type; when
validation fails it appends the specific error to the user prompt and calls again: the first call plus up to three retries
by default (maxRepeatAttempts is 3), so at most four model calls. The model
sees what was wrong, so the second attempt is informed rather than blind.
auto-registered"] B --> C["Call the model
with the schema in the prompt"] C --> D{"Reply valid
against the schema?"} D -->|"yes"| E["Parse into the record
usage summed over attempts"] D -->|"no, attempts left"| F["Append the validation error
to the user message"] F --> C D -->|"no, out of attempts"| G["Fail with the last error"]
In a test with a stub model that first answered {"title":"A"} and then a complete object, the advisor made two model calls;
the second prompt’s user text contained "Output JSON validation failed because of:" followed by the missing required
properties; and the response usage reported 30 tokens, the sum of both attempts. The validator uses JSON Schema draft
2020-12. Streaming is not supported when validation is active, because the advisor needs the complete reply.
To change the number of retries, supply a pre-built schema, or use a different mapper, build the advisor yourself and
register it; an explicitly registered advisor replaces the auto-registered one. It is configured either with outputType
(the schema is derived) or with outputJsonSchema (a schema string), never both.
package com.example.docsassistant;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.StructuredOutputValidationAdvisor;
class ValidatedClients {
/** A configured advisor replaces the auto-registered one when you want more than three retries. */
ChatClient clientWithValidation(ChatClient.Builder builder) {
StructuredOutputValidationAdvisor validation = StructuredOutputValidationAdvisor.builder()
.outputType(DocMetadata.class)
.maxRepeatAttempts(5) // five retries after the first call: at most six model calls
.build();
return builder.defaultAdvisors(validation).build();
}
}
The builder also has advisorOrder(int) (default is Ordered.LOWEST_PRECEDENCE - 2000, near the model), outputType
variants for Type, TypeReference and ParameterizedTypeReference, and jsonMapper(JsonMapper). It is a recursive
advisor, discussed in Advisors. Each retry is a paid model call, so keep the attempts
small; when validation still fails after the last attempt, handle the exception like any other failed dependency instead
of retrying forever. Source:
Spring AI: Schema Validation &
Self-Correction.
The full response with the entity
entity(…) returns only the object. When the call also needs the usage or metadata, use responseEntity(…), which
has the same overloads and returns a ResponseEntity carrying both.
package com.example.docsassistant;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.ResponseEntity;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.stereotype.Service;
@Service
class ExtractionUsage {
private final ChatClient chatClient;
ExtractionUsage(ChatClient docsChatClient) {
this.chatClient = docsChatClient;
}
/** Token usage next to the entity. */
int extractAndCount(String pageText) {
ResponseEntity<ChatResponse, DocMetadata> result = this.chatClient.prompt()
.user(pageText)
.call()
.responseEntity(DocMetadata.class);
DocMetadata metadata = result.entity();
Integer total = result.response().getMetadata().getUsage().getTotalTokens();
return metadata == null || total == null ? 0 : total;
}
}
Source: Spring AI: Structured Output.
The DocsAssistant extraction endpoint
package com.example.docsassistant;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
class ExtractionController {
record ExtractRequest(String pageText) { }
private final NativeExtraction extraction;
ExtractionController(NativeExtraction extraction) {
this.extraction = extraction;
}
@PostMapping("/api/extract")
DocMetadata extract(@RequestBody ExtractRequest request) {
return this.extraction.extractReliably(request.pageText());
}
}
Source: Spring AI: Structured Output.
The page text comes from a client and is untrusted: it can contain instructions aimed at the model. The output is data, not
a decision, so treat it as untrusted too: check that component is a known sub-section before using it in a path or a
query, cap the list sizes, and never feed extracted text to a tool that acts on it without a check. The schema keeps the
reply well-formed; it does not make its content safe. That distinction is developed in
Security & Guardrails. For extraction, use a low temperature (see
Chat Options) and evaluate the endpoint on a golden set of pages, as in
Evaluation.
Where older material differs
Older books and posts use names that no longer exist in 2.0. BeanOutputParser, MapOutputParser and ListOutputParser are
BeanOutputConverter, MapOutputConverter and ListOutputConverter, and the interface is StructuredOutputConverter. The
preferred entry point is ChatClient.entity(…), which did not exist in the first releases, so many older examples build the
prompt with a {format} placeholder by hand as in the streaming snippet above. BeanOutputConverter now uses Jackson 3
(tools.jackson.*) and a JsonSchemaGenerator; the postProcessSchema(JsonNode) hook of 1.x is gone in favour of
overriding generateSchema(). Jackson’s annotation package is unchanged (com.fasterxml.jackson.annotation), which is why the
records above import it as before. The rest of the migration is in
Migrating from 1.x to 2.x.