🔥 0
0
Lesson 4 of 8 25 min +200 XP

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.

.entity() turns text into a typed object Shopper text "Nike shoes ~3000" Prompt + schema "reply as JSON {category,maxPrice...}" Model JSON {"category":"Footwear", "maxPrice":3000...} ProductQuery Java record, typed fields You write the record. Spring AI handles the schema, the instructions, and the parsing.

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 maxPrice must 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 null when 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());
    }
}
On the job

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 ParameterizedTypeReference> so the element type survives type erasure.
  • Extract intent (natural language to ProductQuery) and produce results (typed List) 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.

🧠 Quick Quiz

Test your understanding of this lesson.

1

What does chatClient...call().entity(ProductQuery.class) do?

2

How do you get a typed List<ProductRecommendation> back?