Spring AI Structured Output: Parse LLM Responses into Java Objects
Convert unpredictable LLM text into type-safe Java objects. BeanOutputConverter, ListOutputConverter, and custom converters explained.
Moshiour Rahman
The Problem: LLMs Return Unstructured Chaos
You ask an LLM to generate a product description. You get back:
Here's a great product description for you!
**Name**: SuperWidget 3000
**Price**: $49.99 (but sometimes on sale!)
This amazing widget will change your life...
How do you parse that into a Product object? You don’t. You suffer.
Spring AI’s Structured Output API solves this by forcing LLMs to return valid JSON that maps directly to your Java classes.
Quick Answer (TL;DR)
// Define your class
record Product(String name, BigDecimal price, String description) {}
// Get structured output in one line
Product product = ChatClient.create(chatModel)
.prompt()
.user("Generate a product for a tech store")
.call()
.entity(Product.class); // Returns typed Java object!
No regex. No parsing. Type-safe objects from LLM output.
How It Works
Spring AI’s StructuredOutputConverter does two things:

- Before call: Appends JSON schema instructions to your prompt
- After call: Parses the JSON response into your Java class
Available Converters
| Converter | Output Type | Use Case |
|---|---|---|
BeanOutputConverter | Single object | Product, User |
ListOutputConverter | List<String> | Names, tags |
MapOutputConverter | Map<String, Object> | Dynamic key-value |
| Custom | Any type | Complex generics |
BeanOutputConverter: Single Objects
High-Level API (Recommended)
record Character(
String name,
int age,
String race,
String characterClass,
String bio
) {}
Character character = ChatClient.create(chatModel)
.prompt()
.user("Generate a D&D character who is an elf wizard")
.call()
.entity(Character.class);
// Result:
// Character[name=Aelindra Starweave, age=342, race=Elf,
// characterClass=Wizard, bio=Born in the ancient...]
Low-Level API (More Control)
BeanOutputConverter<Character> converter = new BeanOutputConverter<>(Character.class);
String template = """
Generate a D&D character with race {race}
{format}
""";
PromptTemplate promptTemplate = new PromptTemplate(
template,
Map.of("race", "Dwarf", "format", converter.getFormat())
);
Prompt prompt = new Prompt(promptTemplate.createMessage());
Generation generation = chatModel.call(prompt).getResult();
Character character = converter.convert(generation.getOutput().getContent());
What getFormat() adds to your prompt:
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.
Here is the JSON Schema instance your output must adhere to:
{
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"race": {"type": "string"},
...
}
}
ListOutputConverter: String Lists
// High-level
List<String> names = ChatClient.create(chatModel)
.prompt()
.user("List 5 fantasy character names")
.call()
.entity(new ListOutputConverter(new DefaultConversionService()));
// Low-level
ListOutputConverter converter = new ListOutputConverter(new DefaultConversionService());
String format = converter.getFormat();
String prompt = """
List 10 popular programming languages
%s
""".formatted(format);
Generation gen = chatModel.call(new Prompt(prompt)).getResult();
List<String> languages = converter.convert(gen.getOutput().getContent());
// [Java, Python, JavaScript, TypeScript, Go, Rust, ...]
MapOutputConverter: Key-Value Pairs
Map<String, Object> characterMap = ChatClient.create(chatModel)
.prompt()
.user("Generate 3 D&D characters as key-value pairs where key is name")
.call()
.entity(new ParameterizedTypeReference<Map<String, Object>>() {});
// Result:
// {
// "Thorin Ironforge": {"race": "Dwarf", "class": "Fighter"},
// "Elara Moonwhisper": {"race": "Elf", "class": "Ranger"},
// ...
// }
Limitation: MapOutputConverter only supports Map<String, Object>. For typed values, build a custom converter.
Custom Converter: Typed Maps
Let’s build a converter for Map<String, Character>:
public class GenericMapOutputConverter<V>
implements StructuredOutputConverter<Map<String, V>> {
private final ObjectMapper objectMapper;
private final String jsonSchema;
private final TypeReference<Map<String, V>> typeRef;
public GenericMapOutputConverter(Class<V> valueType) {
this.objectMapper = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
this.typeRef = new TypeReference<>() {};
this.jsonSchema = generateJsonSchema(valueType);
}
@Override
public Map<String, V> convert(String text) {
try {
// Strip markdown code blocks if present
text = text.replaceAll("^```json\\s*", "")
.replaceAll("\\s*```$", "");
return objectMapper.readValue(text, typeRef);
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to parse JSON", e);
}
}
@Override
public String getFormat() {
return """
Your response should be in JSON format.
The data structure should be a Map where keys are strings
and values match this schema:
%s
Do not include explanations or markdown.
""".formatted(jsonSchema);
}
private String generateJsonSchema(Class<V> valueType) {
// Use jsonschema-generator library
SchemaGeneratorConfig config = new SchemaGeneratorConfigBuilder(
SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON
).build();
SchemaGenerator generator = new SchemaGenerator(config);
return generator.generateSchema(valueType).toString();
}
}
Usage:
Map<String, Character> characters = ChatClient.create(chatModel)
.prompt()
.user("Generate 3 D&D characters")
.call()
.entity(new GenericMapOutputConverter<>(Character.class));
// Fully typed! characters.get("Thorin") returns Character, not Object
Best Practices
1. Use Records for DTOs
// Good - immutable, concise
record Product(String name, BigDecimal price, List<String> tags) {}
// Avoid - mutable, boilerplate
public class Product {
private String name;
private BigDecimal price;
// getters, setters, constructors...
}
2. Add Validation
record Product(
@NotBlank String name,
@Positive BigDecimal price,
@Size(min = 1, max = 10) List<String> tags
) {}
Product product = chatClient.prompt()
.user("Generate a product")
.call()
.entity(Product.class);
// Validate after parsing
validator.validate(product);
3. Handle Parsing Failures
try {
Product product = chatClient.prompt()
.user(userInput)
.call()
.entity(Product.class);
} catch (JsonProcessingException e) {
// LLM didn't follow schema - retry or fallback
log.warn("Failed to parse LLM response: {}", e.getMessage());
return defaultProduct();
}
4. Use Specific Prompts
// Vague - LLM may not include all fields
"Generate a character"
// Specific - better results
"Generate a D&D character with: name (string), age (integer between 20-500),
race (one of: Human, Elf, Dwarf, Halfling), class (one of: Fighter, Wizard,
Rogue, Cleric), and a 2-sentence bio"
Common Pitfalls
| Issue | Cause | Fix |
|---|---|---|
JsonParseException | LLM included markdown | Strip ```json blocks |
| Missing fields | LLM skipped optional fields | Add defaults in record |
| Wrong types | LLM returned “42” instead of 42 | Use lenient ObjectMapper |
| Empty response | Token limit hit | Increase max tokens |
Complete Example: Product Catalog Generator
@Service
public class ProductCatalogService {
private final ChatClient chatClient;
public ProductCatalogService(ChatModel chatModel) {
this.chatClient = ChatClient.create(chatModel);
}
record Product(
String sku,
String name,
String description,
BigDecimal price,
String category,
List<String> tags
) {}
public List<Product> generateCatalog(String category, int count) {
String prompt = """
Generate %d products for a %s store.
Each product should have realistic:
- SKU (format: CAT-XXXXX)
- Name
- Description (1-2 sentences)
- Price (realistic for the category)
- Category: %s
- Tags (3-5 relevant tags)
""".formatted(count, category, category);
return chatClient.prompt()
.user(prompt)
.call()
.entity(new ParameterizedTypeReference<List<Product>>() {});
}
}
Code Repository
Full examples with all converter types:
GitHub: techyowls/techyowls-io-blog-public/spring-ai-structured-output
Further Reading
- Spring AI MCP Guide - External tool integration
- LangChain4j Guide - Alternative framework
- Spring AI Docs
Turn LLM chaos into type-safe Java objects. Follow TechyOwls for more practical guides.
Moshiour Rahman
Software Architect & AI Engineer
Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.
Related Articles
Spring AI + MCP: Build AI Agents That Actually Do Things
Connect your Spring Boot app to external tools, databases, and APIs using Model Context Protocol. Complete guide with working code.
Spring BootLangChain4j: Build AI Apps in Java Without Python
Complete guide to LangChain4j for Java developers. Build AI applications with RAG, memory, agents, tools, and function calling — no Python rewriting required.
Spring BootSpring Boot 3 Virtual Threads: Complete Guide to Java 21 Concurrency
Master Java 21 virtual threads in Spring Boot. Learn configuration, benchmarks, pitfalls, and scale from thousands to millions of concurrent operations reliably.
Comments
Comments are powered by GitHub Discussions.
Configure Giscus at giscus.app to enable comments.