Let's get started with a Microservice Architecture with Spring Cloud:
A Guide to Structured Output in Spring AI
Last updated: September 25, 2026
1. Overview
When interacting with chatbots, we’re used to the plain text responses from Large Language Models (LLMs). However, when integrating these LLMs into our application, it becomes a problem to use these responses programmatically.
Spring AI solves this problem with its structured output support. Instead of dealing with raw strings, we can instruct the model to return data that maps directly to our Java classes, collections, and other types.
In this tutorial, we’ll explore receiving structured output from LLMs using Spring AI.
2. Setting up the Project
Before we dive into the implementation, let’s set up our project.
2.1. Configuring a Chat Model
Let’s start by adding the necessary dependency to our project’s pom.xml file:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
<version>2.0.1</version>
</dependency>
Here, we import Spring AI’s OpenAI starter dependency, which we’ll use to interact with a chat model.
Next, let’s configure our OpenAI API key and chat model in the application.yaml file:
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
model: gpt-5.6-luna
Here, we specify OpenAI’s GPT 5.6 Luna model using the gpt-5.6-luna model ID. Alternatively, we can use a different chat model, as the specific AI model or provider is irrelevant for this demonstration.
With these two properties set, Spring AI automatically creates a bean of type ChatModel, which we’ll use to build a ChatClient bean:
@Bean
ChatClient chatClient(ChatModel chatModel) {
return ChatClient
.builder(chatModel)
.build();
}
The ChatClient class acts as the main entry point for interacting with our configured chat completion model.
2.2. Defining Our Domain Entity
Next, let’s define a domain entity that we’ll convert our model’s responses into:
record Recipe(
String name,
String cuisine,
Difficulty difficulty,
int prepTimeMinutes,
List<Ingredient> ingredients,
List<String> steps
) {
record Ingredient(
String name,
String quantity
) {}
enum Difficulty { EASY, MEDIUM, HARD }
}
Here we define a Recipe record with a nested Ingredient record and a Difficulty enum.
When defining domain entities, we should pick descriptive field names as it helps the LLM to populate them correctly. For ambiguous fields, we can annotate them with @JsonPropertyDescription to give the model additional context.
3. Converting the Response Into Our Domain Entity
With our setup in place, let’s use the ChatClient bean to ask the model for a recipe and convert its response into our record:
Recipe recipe = chatClient
.prompt("Generate a recipe for a vegetarian lasagna.")
.call()
.entity(Recipe.class);
assertThat(recipe)
.hasNoNullFieldsOrProperties()
.satisfies(r -> assertThat(r.ingredients())
.hasSizeGreaterThan(1)
);
Here, we pass an instruction to generate a recipe and then invoke the entity() method with the Recipe class instead of calling content(), which would have given us a raw text response.
The entity() method hands us a fully populated Recipe instance, without us writing a single line of parsing logic. Behind the scenes, Spring AI performs the following steps:
- First, generates a JSON schema from the target type we pass in.
- Then, it appends this schema to our user prompt along with a set of format instructions.
- Finally, once the model replies, it deserializes the response text into an instance of our target type
4. Self-Correcting Structured Output
Even with a clear schema and formatting instructions in the prompt, a chat model can still return a response that doesn’t conform to it. Let’s look at some ways in which we can recover from such failures.
4.1. Client-Side Validation With validateSchema()
In the first approach, the response is validated on our side before deserialization:
Recipe recipe = chatClient
.prompt("Generate a recipe for a high protein dessert.")
.call()
.entity(Recipe.class, spec -> spec.validateSchema());
Here, we pass an additional lambda to entity() and call the validateSchema() method.
With this enabled, Spring AI validates the model’s response against the generated JSON schema before deserializing it. In case of failure, it sends the validation errors back to the model. This process repeats until the output becomes valid or the retry limit is exhausted, which defaults to three.
Alternatively, we can register the StructuredOutputValidationAdvisor while building our ChatClient bean. Using this approach, we can override the default settings and apply the client-side validation to every call:
@Bean
ChatClient validatingChatClient(ChatModel chatModel) {
return ChatClient
.builder(chatModel)
.defaultAdvisors(StructuredOutputValidationAdvisor.builder()
.maxRepeatAttempts(5)
.outputType(Recipe.class)
.jsonMapper(JsonMapper.builder().build())
.build())
.build();
}
Here, we raise the retry limit to five and declare Recipe as the default output type. Additionally, we supply a custom JsonMapper instance that performs the validation and the deserialization tasks. Here, we simply configure one with the default settings, but we can customize as per requirements.
4.2. Server-Side Validation With useProviderStructuredOutput()
Alternatively, instead of validating the response ourselves, we can delegate the job to the model provider. Most modern providers such as OpenAI, Anthropic, Google, and Mistral accept a schema as part of the API request and guarantee that the response conforms to it.
We can use this capability through another method on the same lambda:
Recipe recipe = chatClient
.prompt("Generate a recipe for a gluten-free breakfast.")
.call()
.entity(Recipe.class, spec -> spec.useProviderStructuredOutput());
With this enabled, Spring AI sends the schema to the provider as a dedicated API field instead of appending instructions to the user prompt.
However, before relying on this, we should confirm that both our model and our provider support it. We can even use it along with validateSchema() to have a more resilient setup.
5. Converting the Response Into a List
Sometimes we might want the model to return a collection of results instead of a single object:
List<Recipe> recipes = chatClient
.prompt("Generate 3 recipes for vegetarian dishes.")
.call()
.entity(new ParameterizedTypeReference<List<Recipe>>() {});
assertThat(recipes)
.hasSize(3)
.allSatisfy(recipe -> assertThat(recipe)
.hasNoNullFieldsOrProperties()
);
Here, instead of passing a class to the entity() method, we pass a ParameterizedTypeReference instance. This wrapper preserves the generic type information about our Recipe record and allows Spring AI to generate a JSON schema accordingly.
Alternatively, when we only need a list of plain strings, we can pass a ListOutputConverter instance to the entity() method:
List<String> dishes = chatClient
.prompt("List 5 popular vegetarian dishes.")
.call()
.entity(new ListOutputConverter());
assertThat(dishes)
.hasSize(5)
.allSatisfy(dish -> assertThat(dish)
.isNotBlank()
);
In this lightweight option, the converter asks the model for a simple comma separated list instead of JSON and then splits the reply into a List of String values.
6. Converting the Response Into a Map
In scenarios where we don’t know the shape of the response in advance, we can pass a MapOutputConverter instance to the entity() method:
Map<String, Object> nutritionFacts = chatClient
.prompt("Provide the nutrition facts per serving for a vegetarian lasagna.")
.call()
.entity(new MapOutputConverter());
assertThat(nutritionFacts)
.isNotEmpty()
.allSatisfy((nutrient, value) -> {
assertThat(nutrient).isNotBlank();
assertThat(value).isNotNull();
});
Here, we receive a Map with String keys and Object values. The converter instructs the model to reply with a JSON object. In the absence of a fixed schema, the model decides the keys to return.
However, this flexibility costs us type safety, so we should still prefer a dedicated domain entity whenever we know the structure we expect.
7. Converting Responses Manually Using ChatModel
Until now, we’ve let the ChatClient bean handle everything for us. However, when we work directly with the lower level ChatModel abstraction, the entity() method isn’t available to us.
In such cases, we can use the converter classes ourselves:
BeanOutputConverter<Recipe> outputConverter = new BeanOutputConverter<>(Recipe.class);
String response = chatModel
.call("Generate a recipe for a vegetarian lasagna. " + outputConverter.getFormat());
Recipe recipe = outputConverter.convert(response);
assertThat(recipe)
.hasNoNullFieldsOrProperties();
Here, we create a BeanOutputConverter instance for our Recipe record. This is the same converter Spring AI uses internally for our domain entities.
Then, we append the output of its getFormat() method to our prompt, which contains the generated JSON schema and formatting instructions. Finally, we pass the model’s response to its convert() method to obtain our Recipe instance.
This manual approach isn’t limited to domain entities. We can follow the exact same process for lists and maps as well. The only thing we need to change is the converter instance we create.
8. Creating a Custom Output Converter
So far, we’ve relied on the converters that Spring AI exposes, which address most of our requirements. However, we can implement a converter ourselves simply by implementing the StructuredOutputConverter interface:
class YamlOutputConverter<T> implements StructuredOutputConverter<T> {
private final YAMLMapper yamlMapper = YAMLMapper.builder().build();
private final Class<T> targetType;
YamlOutputConverter(Class<T> targetType) {
this.targetType = targetType;
}
@Override
public String getFormat() {
String schema = new BeanOutputConverter<>(targetType).getJsonSchema();
return """
Return a YAML response that matches this JSON schema: %s
Do not include any explanations or markdown code fences.
""".formatted(schema);
}
@Override
public T convert(String source) {
return yamlMapper.readValue(source, targetType);
}
}
Here, we create a converter to work with YAML responses.
We reuse BeanOutputConverter to generate a JSON schema for the target type and write the instructions to append to the user prompt in the getFormat() method. Then, in convert(), we deserialize the model’s response into our target type using YAMLMapper.
Now, let’s verify that our converter deserializes a response correctly:
String yamlResponse = """
name: "Mediterranean Veggie Salad"
cuisine: "Mediterranean"
difficulty: "EASY"
prepTimeMinutes: 15
ingredients:
- name: "Cucumber"
quantity: "1 medium"
- name: "Cherry tomatoes"
quantity: "1 cup"
- name: "Extra virgin olive oil"
quantity: "2 tbsp"
steps:
- "Step 1: Chop the cucumber and halve the cherry tomatoes."
- "Step 2: Drizzle with olive oil and toss everything together."
""";
Recipe recipe = new YamlOutputConverter<>(Recipe.class)
.convert(yamlResponse);
assertThat(recipe)
.hasNoNullFieldsOrProperties();
Here, we pass a sample model response to the convert() method of our converter and confirm that the Recipe record gets populated.
To use it against a live model, we can simply pass an instance of YamlOutputConverter to the entity() method.
9. Conclusion
In this article, we’ve explored the structured output support in Spring AI.
We walked through converting a chat model’s response into a custom domain entity, a list, and a map. Additionally, we discussed the self-correcting features that help us recover when a model returns a response that doesn’t match our schema. Finally, we explored implementing a custom converter.
As always, all the code examples used in this article are available over on GitHub.
















