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
Advertisement
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.
Advertisement
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 - RAG, memory, agents, and tools for Java developers. Stop rewriting Python code.
Spring BootSpring Boot 3 Virtual Threads: Complete Guide to Java 21 Concurrency
Master virtual threads in Spring Boot 3. Learn configuration, performance benchmarks, when to use them, common pitfalls, and production-ready patterns for high-throughput applications.
Comments
Comments are powered by GitHub Discussions.
Configure Giscus at giscus.app to enable comments.