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.

DocMetadata.java: the shape DocsAssistant extracts
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 }
}
DescribedMetadata.java: descriptions travel into the schema
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) { }

Asking for the record is a one-word change to the call chain: finish with entity(Type.class) instead of content().

ExtractionService.java: entity() with a record
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);
    }
}

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()).

The format instructions Spring AI appends for DocMetadata
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.

ExtractionService.java: generics, a map and a list converter
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());
    }
}

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.

Table 1. The three built-in converters
Converter Produces Notes

BeanOutputConverter<T>

A Java class, record or generic type (constructed from a Class or a ParameterizedTypeReference)

Generates a JSON Schema (draft 2020-12) from the type and deserialises with a Jackson 3 JsonMapper. Constructors also accept a mapper and a ResponseTextCleaner

MapOutputConverter

Map<String, Object>

Asks for RFC 8259 JSON and converts it with a message converter

ListOutputConverter

List<String>

Asks for comma-separated values and converts them with a ConversionService

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).

StreamThenConvert.java: streaming has no entity(), so aggregate and convert explicitly
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.

CleaningConverters.java: choose the cleaners explicitly
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);
    }
}

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.

LenientJsonOutputConverter.java: a custom converter that keeps the schema
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.

ExtractionService.java: native structured output, alone and combined with validation
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.

flowchart TD A["entity(Type, spec.validateSchema())"] --> B["StructuredOutputValidationAdvisor
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"]
Figure 1. The self-correcting loop behind validateSchema()

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.

ExtractionService.java: a configured StructuredOutputValidationAdvisor
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.

ExtractionService.java: responseEntity() for usage next to the entity
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;
    }
}

The DocsAssistant extraction endpoint

ExtractionController.java: POST /api/extract
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());
    }
}

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.