Structured Output to Java POJOs
A shopper types: "Looking for a lightweight running shoe, budget around 3000, ideally Nike or Adidas." As a human you instantly parse three facts: category = footwear/running, max price = 3000, brands = Nike, Adidas. Your code needs those same facts as typed fields so it can run a database query or filter the catalog. Reading them out of a paragraph of English with regexes is miserable. This lesson shows the Spring AI way: ask the model for the answer and get a Java object back, not a String.
From String to type
Everything so far ended in .content(), which returns a String. Spring AI's structured output feature lets you end in .entity(SomeType.class) instead. Under the hood it:
- Derives a JSON schema from your Java type.
- Appends "respond as JSON in exactly this shape" instructions to your prompt.
- Takes the model's JSON reply and deserializes it into an instance of your type.
You write a record; you get that record back populated.
Extracting a ProductQuery
Define the shape you want. Java records are perfect — immutable, concise, and their component names become the JSON field names.
package com.shopkart.assistant;
import java.math.BigDecimal;
import java.util.List;
// The structured intent we want to pull out of free text.
public record ProductQuery(
String category, // e.g. "Footwear", or null if unspecified
BigDecimal maxPrice, // budget ceiling in Rupees, or null
List<String> brands, // preferred brands, may be empty
List<String> keywords // salient terms like "lightweight", "running"
) {}
Now the extraction. Give the model a focused instruction and end with .entity(ProductQuery.class).
@Service
public class QueryExtractionService {
private final ChatClient chatClient;
public QueryExtractionService(ChatClient shopkartChatClient) {
this.chatClient = shopkartChatClient;
}
public ProductQuery extract(String shopperMessage) {
return chatClient.prompt()
.system("""
You extract shopping intent from a shopper's message.
Fill only the fields you are confident about; use null or an
empty list when the shopper did not specify something.
Prices are in Indian Rupees.
""")
.user(shopperMessage)
.call()
.entity(ProductQuery.class);
}
}
Call it with the opening example:
ProductQuery q = extractionService.extract(
"Looking for a lightweight running shoe, budget around 3000, "
+ "ideally Nike or Adidas");
// q => ProductQuery[
// category=Footwear,
// maxPrice=3000,
// brands=[Nike, Adidas],
// keywords=[lightweight, running]
// ]
No JSON parsing, no regex. You now have typed fields you can pass straight into a repository query:
public List<Product> search(ProductQuery q) {
return productRepository.findByFilters(
q.category(),
q.maxPrice(),
q.brands());
}
This pattern — natural language in, structured query out — is the bridge between a chat box and your existing database code.
Returning a typed list of recommendations
Structured output also works in the other direction: ask the model to produce a list of objects. Suppose you feed it a shortlist of candidate products and want it to pick and rank the best ones with a reason.
public record ProductRecommendation(
String productName,
String reason, // one short sentence, shopper-facing
int matchScore // 0-100, how well it fits the request
) {}
Generic collections erase their element type at runtime, so List.class would not tell the converter what the elements are. Pass a ParameterizedTypeReference to keep the element type.
import org.springframework.core.ParameterizedTypeReference;
public List<ProductRecommendation> recommend(String shopperMessage,
List<Product> candidates) {
String catalogBlock = candidates.stream()
.map(p -> "- %s by %s, Rs %s, rated %.1f, %s".formatted(
p.name(), p.brand(), p.price(), p.rating(), p.description()))
.collect(java.util.stream.Collectors.joining("\n"));
return chatClient.prompt()
.system("""
You are a ShopKart recommendation engine. From the candidate
products, pick the best matches for the shopper. Only use
products from the list. Return at most 3, best first.
""")
.user(u -> u
.text("""
Shopper request: {request}
Candidate products:
{catalog}
""")
.param("request", shopperMessage)
.param("catalog", catalogBlock))
.call()
.entity(new ParameterizedTypeReference<List<ProductRecommendation>>() {});
}
The result is a ready-to-serialize List you can return straight from a controller as JSON. Notice we pass the candidate products in the prompt so the model recommends real ShopKart items and cannot invent one — a manual preview of the RAG pattern we automate in lesson 6.
Nested and enum types work too
The converter handles nested records and enums, which lets you model richer intent. For example, capturing an explicit sort preference:
public enum SortPreference { PRICE_LOW_TO_HIGH, RATING_HIGH_TO_LOW, RELEVANCE }
public record ShoppingIntent(
ProductQuery query,
SortPreference sortBy,
boolean wantsGiftWrap
) {}
ShoppingIntent intent = chatClient.prompt()
.user("cheapest kids water bottle, gift wrapped please")
.call()
.entity(ShoppingIntent.class);
// intent.sortBy() => PRICE_LOW_TO_HIGH, intent.wantsGiftWrap() => true
The model picks the matching enum constant by name. Keep enum names descriptive so the model maps to them reliably.
Where it can fail, and how to be safe
Structured output is powerful but it is still a model producing text. Defend against edge cases:
- The model may occasionally return malformed JSON. Spring AI parses it for you, but wrap the call in a try/catch and have a fallback (for example, re-ask, or fall back to a keyword search) rather than letting a parse failure 500 your endpoint.
- Fields you did not constrain can be wrong. If
maxPricemust be positive, validate it after you receive the object — do not trust the model to enforce business rules. - Keep the schema small. Ask only for the fields you need. Huge nested schemas are slower, cost more tokens, and are likelier to be filled incorrectly.
- Prefer nullable fields over forcing guesses. Telling the model to use
nullwhen unsure (as we did) prevents it from hallucinating a category the shopper never mentioned.
public ProductQuery extractSafely(String message) {
try {
ProductQuery q = extract(message);
// business validation is still YOUR job
if (q.maxPrice() != null && q.maxPrice().signum() < 0) {
return new ProductQuery(q.category(), null, q.brands(), q.keywords());
}
return q;
} catch (Exception e) {
// fall back to a permissive empty query rather than failing the request
return new ProductQuery(null, null, List.of(), List.of());
}
}
Structured output is the single most useful feature for backend developers because it turns a chatty model into a well-behaved function: text in, typed object out. But the object came from a probabilistic model, so treat it exactly like untrusted external input. Validate ranges, enforce business rules in code, and always have a fallback path. Records with descriptive field names and a short, targeted prompt give you the most reliable results.
Key takeaways
- End a call with
.entity(Type.class)to get a populated Java object instead of a String. - Spring AI derives a JSON schema from your type, instructs the model, and deserializes the reply for you.
- Use records with clear field names; nested records and enums are supported.
- For generic collections, pass a
ParameterizedTypeReferenceso the element type survives type erasure.- >
- Extract intent (natural language to
ProductQuery) and produce results (typedList) with the same mechanism. - The output is model-generated: wrap it in try/catch, validate business rules in code, and keep a fallback.
Next up: Embeddings & Vector Stores — teaching the assistant to find products by meaning, not keywords.