This version is still in development and is not considered stable yet. For the latest stable version, please use Spring AI 2.0.0!

Structured Output

Large language models are text-in, text-out systems. The moment downstream code needs to route on a field, persist a value, or branch on a result, that text has to become a typed record. Structured output bridges that gap: the model is steered to produce text conforming to a schema, and the application parses it back into a typed object the rest of the codebase can treat like any other domain type.

Spring AI exposes structured output directly on the ChatClient fluent API through .entity(…​). You define a Java type for the shape you want back, and Spring AI takes care of the rest: a JSON schema is generated from your type, the model is instructed to honor it, and the response is deserialized into your type.

This page covers the high-level ChatClient path. For reliability switches and lower-level APIs, see:

Typed Response

Define a Java record for the shape you want back:

record ActorsFilms(String actor, List<String> movies) {}

Ask ChatClient to populate it. Instead of finishing the call with .content() — which returns the raw text reply — finish with .entity(…​) and pass your target type:

ActorsFilms films = chatClient.prompt()
    .user("Generate the filmography for a random actor.")
    .call()
    .entity(ActorsFilms.class);

The result is a typed ActorsFilms you can pass to the rest of your code:

films.actor();     // "Tom Hanks"
films.movies();    // ["Forrest Gump", "Cast Away", ...]

Behind the scenes, Spring AI did three things: a schema generator turned your ActorsFilms record into a JSON schema, that schema was appended to the prompt’s system context, and the model’s JSON answer was handed to a type converter that parsed it back into your record.

Structured Output Basic Flow

This works on every model Spring AI supports — there is nothing provider-specific about it.

.entity(…​) is .call()-only. Typed parsing requires the complete response, so it is not available on the streaming path (.stream() returns text chunks, not typed objects). This applies to every variant on this page — Class, ParameterizedTypeReference, custom converter, with or without the reliability switches.

The default .entity(…​) call has no guarantees. The model is asked to produce JSON matching the schema, not forced to. Most of the time it complies; sometimes it returns an extra field, omits a required one, or wraps the JSON in prose, and the parser throws. The two switches below address this.

Generic Types: Lists, Maps, and Beyond

.entity(Class) is for concrete classes. For generic types — List<ActorsFilms>, Map<String, ActorsFilms> — use ParameterizedTypeReference:

List<ActorsFilms> films = chatClient.prompt()
    .user("Generate filmographies for three random actors.")
    .call()
    .entity(new ParameterizedTypeReference<List<ActorsFilms>>() {});

Reliability Switches: EntityParamSpec

All .entity(…​) (and .responseEntity(…​)) overloads accept an optional Consumer<EntityParamSpec> that enables two independent, composable behaviors.

Don’t Fail on Malformed Output: validateSchema()

validateSchema() turns on a self-correcting retry loop: Spring AI validates the response against the entity’s schema, and if validation fails, the specific error is appended to the prompt and the call is re-issued — up to 3 attempts by default.

ActorsFilms films = chatClient.prompt()
    .user("Generate the filmography for a random actor.")
    .call()
    .entity(ActorsFilms.class, spec -> spec.validateSchema());

See Schema Validation & Self-Correction for how the retry loop works and how to customize it.

Stronger Upstream Guarantees: useProviderStructuredOutput()

useProviderStructuredOutput() sends the schema to the provider as an API-level constraint, so the provider’s runtime enforces conformance rather than relying on prompt instructions.

ActorsFilms films = chatClient.prompt()
    .user("Generate the filmography for a random actor.")
    .call()
    .entity(ActorsFilms.class, spec -> spec.useProviderStructuredOutput());

This requires the underlying model to support native structured output. See Provider-Native Structured Output for supported providers and limitations.

Combining Both

The two switches solve different problems and compose naturally:

ActorsFilms films = chatClient.prompt()
    .user("Generate the filmography for a random actor.")
    .call()
    .entity(ActorsFilms.class, spec -> spec
        .useProviderStructuredOutput()
        .validateSchema());

useProviderStructuredOutput() minimizes the chance of malformed output by constraining the model at the API level. validateSchema() catches the residual cases — provider edge cases, reasoning-model quirks — and corrects them automatically. Reach for both when downstream code cannot tolerate shape drift.

Getting the Full Response

.entity(…​) returns only the parsed object. If you also need the underlying ChatResponse — for token usage, observability metadata, or anything beyond the entity — use .responseEntity(…​):

ResponseEntity<ChatResponse, ActorsFilms> result = chatClient.prompt()
    .user("Generate the filmography for a random actor.")
    .call()
    .responseEntity(ActorsFilms.class);

ActorsFilms films = result.entity();
ChatResponse raw = result.response();
long totalTokens = raw.getMetadata().getUsage().getTotalTokens();

It has the same overload set as .entity(…​) — Class, ParameterizedTypeReference, custom StructuredOutputConverter, and the EntityParamSpec consumer all apply.

Custom and Non-JSON Output

When the built-in JSON parsing is not enough — the model wraps JSON in markdown fences, or you need a non-JSON format such as YAML or CSV — pass your own StructuredOutputConverter<T> to .entity(…​). See Output Converters.

Cheat Sheet

You need Use

Default — works on every provider

.entity(Type.class)

Generic types like List<T>, Map<K,V>

.entity(new ParameterizedTypeReference<…​>() {})

Don’t fail on malformed output

.entity(Type.class, spec → spec.validateSchema())

Stronger upstream guarantees from the provider

.entity(Type.class, spec → spec.useProviderStructuredOutput())

Both — request constraint + response retry

.entity(Type.class, spec → spec.useProviderStructuredOutput().validateSchema())

Token usage / metadata alongside the entity

.responseEntity(…​) (same overloads)

Model wraps JSON in markdown fences, or non-JSON format

Implement StructuredOutputConverter<T> and pass it to .entity(…​)

Streaming responses

Not supported — .entity(…​) is .call()-only; .stream() returns text chunks, not typed objects

Structured output is a best effort to convert the model output into a structured type. The model is not guaranteed to return the requested structure. Use validateSchema() and/or useProviderStructuredOutput() when correctness matters.
The StructuredOutputConverter is not used for LLM Tool Calling, as this feature inherently provides structured outputs by default.