Spring AI - Structured Output & Type-Safe Responses
Description
Complete guide for handling structured outputs in Spring AI. Covers JSON Schema mode, type-safe entity deserialization, supported output formats, and integration with Spring data validation.
When to Use
- Type-safe LLM responses
- JSON Schema validation mode
- Structured data extraction
- API response parsing
- Form filling
- Data classification
- Batch processing
- Integration with Spring data entities
Topics Covered
1. Structured Output Modes
- JSON Schema Mode: Latest capability, enforced format
- Parser-based: Client-side parsing
- Converter-based: Structured conversion
- Type-safe mapping: Java entity mapping
2. Output Formats
- JSON: Primary structured format
- YAML: Alternative format
- XML: Some providers support
- CSV: Tabular data
- Protobuf: Binary structured format
- Custom formats: Provider-specific
3. Converter Types
StructuredOutputConverter
- Generic converter for any type
- JSON Schema generation
- Validation against schema
- Error recovery
BeanOutputConverter (Deprecated)
- Legacy entity conversion
- Still works but not recommended
- Use StructuredOutputConverter instead
Jackson/Gson Integration
- Direct JSON binding
- Annotation support
- Custom serialization
- Type polymorphism
4. Type Support
- Simple types: String, int, boolean, etc.
- Collections: List, Set, Map, arrays
- Nested objects: Complex hierarchies
- Generic types: Parameterized types
- Enums: Enumeration values
- Records: Java 14+ records
5. Validation
- Schema validation: JSON Schema compliance
- Bean validation: JSR-303 annotations
- Custom validators: Business logic
- Error handling: Invalid response handling
- Fallback strategies: Partial results
Code Patterns
Basic Structured Output
@Service
public class StructuredService {
@Autowired
private ChatClient chatClient;
public Person generatePerson() {
return chatClient.prompt()
.user("Generate a person with name and age")
.call()
.entity(Person.class);
}
}
@Data
class Person {
String name;
int age;
String email;
}
Type-Safe Response with Nested Objects
record Article(
String title,
String content,
Author author,
List<String> tags,
LocalDateTime publishedAt
) {}
record Author(
String name,
String email
) {}
@Service
public class ArticleService {
@Autowired
private ChatClient chatClient;
public Article generateArticle(String topic) {
return chatClient.prompt()
.user("Write an article about " + topic)
.call()
.entity(Article.class);
}
}
Structured Output with Validation
@Data
class Product {
@NotBlank
String name;
@Min(0)
BigDecimal price;
@NotEmpty
List<String> categories;
@Pattern(regexp = "^[A-Z0-9]{5,}$")
String sku;
}
@Service
public class ProductService {
@Autowired
private ChatClient chatClient;
@Autowired
private Validator validator;
public Product extractProduct(String description) {
Product product = chatClient.prompt()
.user("Extract product info: " + description)
.call()
.entity(Product.class);
// Validate
Set<ConstraintViolation<Product>> violations =
validator.validate(product);
if (!violations.isEmpty()) {
throw new ValidationException("Invalid product: " + violations);
}
return product;
}
}
Collection Output
record SearchResults(
List<Result> items,
int totalCount,
String nextPageToken
) {}
record Result(
String id,
String title,
float relevanceScore
) {}
@Service
public class SearchService {
@Autowired
private ChatClient chatClient;
public SearchResults structuredSearch(String query) {
return chatClient.prompt()
.user("Search and rank results for: " + query)
.call()
.entity(SearchResults.class);
}
}
Enum Classification
enum SentimentScore {
VERY_NEGATIVE, NEGATIVE, NEUTRAL, POSITIVE, VERY_POSITIVE
}
record SentimentAnalysis(
String text,
SentimentScore sentiment,
float confidence,
String explanation
) {}
@Service
public class SentimentService {
@Autowired
private ChatClient chatClient;
public SentimentAnalysis analyzeSentiment(String text) {
return chatClient.prompt()
.user("Analyze sentiment of: " + text)
.call()
.entity(SentimentAnalysis.class);
}
}
Batch Structured Processing
@Service
public class BatchStructuredService {
@Autowired
private ChatClient chatClient;
public List<Classification> classifyBatch(List<String> texts) {
return texts.stream()
.map(text -> chatClient.prompt()
.user("Classify: " + text)
.call()
.entity(Classification.class))
.toList();
}
}
record Classification(
String text,
String category,
float confidence
) {}
Generic Structured Output
@Service
public class GenericStructuredService {
@Autowired
private ChatClient chatClient;
public <T> T extractStructured(String prompt, Class<T> type) {
return chatClient.prompt()
.user(prompt)
.call()
.entity(type);
}
// Usage:
List<String> categories = extractStructured(
"Generate 5 categories",
List.class // Returns List<String>
);
}
Error Handling for Structured Output
@Service
public class RobustStructuredService {
@Autowired
private ChatClient chatClient;
public Person extractPersonSafely(String text) {
try {
return chatClient.prompt()
.user("Extract person: " + text)
.call()
.entity(Person.class);
} catch (StructuredOutputException e) {
logger.warn("Failed to parse structured output", e);
// Fallback: retry with different prompt
return chatClient.prompt()
.user("Parse carefully: " + text)
.call()
.entity(Person.class);
} catch (ValidationException e) {
logger.error("Invalid structured data", e);
return null; // or return default
}
}
}
Custom Output Converter
public class CustomOutputConverter<T> implements ResponseConverter<T> {
private final Class<T> type;
private final ObjectMapper mapper;
public CustomOutputConverter(Class<T> type) {
this.type = type;
this.mapper = new ObjectMapper();
}
@Override
public T convert(String response) {
try {
// Parse JSON
JsonNode root = mapper.readTree(response);
// Custom validation
validateSchema(root);
// Map to type
return mapper.treeToValue(root, type);
} catch (Exception e) {
throw new ResponseConversionException(
"Failed to convert: " + e.getMessage(), e);
}
}
private void validateSchema(JsonNode root) {
// Custom schema validation logic
}
}
Spring Data Entity Output
@Entity
@Data
public class BlogPost {
@Id
Long id;
@NotBlank
String title;
@Column(columnDefinition = "TEXT")
String content;
@ElementCollection
List<String> tags;
@CreationTimestamp
LocalDateTime createdAt;
}
@Service
public class BlogService {
@Autowired
private ChatClient chatClient;
public BlogPost generateBlogPost(String topic) {
BlogPost post = chatClient.prompt()
.user("Write blog post about: " + topic)
.call()
.entity(BlogPost.class);
return blogRepository.save(post);
}
}
Configuration
Enable Structured Output
@Configuration
public class StructuredOutputConfig {
@Bean
public ChatClient chatClient(ChatModel chatModel) {
return ChatClient.builder(chatModel)
.defaultOptions(ChatOptions.builder()
.withResponseFormat("json_object")
.build())
.build();
}
}
Custom ObjectMapper
@Configuration
public class ObjectMapperConfig {
@Bean
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
mapper.registerModule(new JavaTimeModule());
return mapper;
}
}
Properties
spring.ai.chat.options.response-format=json_object
spring.ai.structured-output.validation=true
spring.ai.structured-output.strict-schema=true
Best Practices
- Use records for immutable structured data
- Always validate structured outputs
- Implement custom validators for complex logic
- Use generic types for reusable converters
- Document schema expectations
- Implement fallback strategies
- Monitor structured output success rates
- Cache schemas when possible
Related Skills
chat-models/SKILL.md- Chat operationstools-agents/SKILL.md- Tool return typeserror-handling/SKILL.md- Error recovery
References
- API:
/pages/api/structured-output-converter.adoc - JSON Schema: OpenAI function calling docs
- Validation: Jakarta Bean Validation docs