Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Structured Output with Spring AI

Turn LLM responses into typed Java objects with Spring AI: BeanOutputConverter, .entity(), generic lists, enums and validation — the reliable alternative to parsing text by hand.

Intermediate4 min readUpdated
On this page

"Please return JSON" is not a contract. Structured output is: you declare a Java type, and Spring AI constrains the model to it and deserialises the response. This is how you turn an LLM from a text generator into a typed component you can wire into real code.

Key Takeaways

  • Declare a record; call .entity(Type.class); get a typed object. No manual parsing.
  • Works for records, enums, generic lists and nested structures.
  • Pair with temperature 0 — extraction should be deterministic.
  • Still handle the parse failure path — rare with a clear schema, but never zero.

The basic case

Extract a structured object
public record Recipe(
        String name,
        List<String> ingredients,
        int prepMinutes,
        Difficulty difficulty) {
 
    public enum Difficulty { EASY, MEDIUM, HARD }
}
 
public Recipe extractRecipe(String freeText) {
    return chatClient.prompt()
            .user(u -> u.text("Extract a recipe from:\n{text}").param("text", freeText))
            .options(ChatOptions.builder().temperature(0.0).build())
            .call()
            .entity(Recipe.class);   // schema derived, response parsed
}

Spring AI reads the record, generates a JSON schema (including the enum's permitted values), tells the model to conform, and deserialises. You wrote no parser and no schema.

Generic collections

A bare List<Recipe> loses its element type at runtime, so use a ParameterizedTypeReference:

public List<Recipe> extractAll(String menu) {
    return chatClient.prompt()
            .user(u -> u.text("Extract all recipes from:\n{menu}").param("menu", menu))
            .call()
            .entity(new ParameterizedTypeReference<List<Recipe>>() {});
}

Using the converter directly

When you need the format instructions inside a larger custom prompt, use BeanOutputConverter explicitly:

Manual converter
public Recipe extractWithContext(String text, String cuisine) {
    var converter = new BeanOutputConverter<>(Recipe.class);
 
    String prompt = """
            You are a culinary data extractor specialising in %s cuisine.
            Extract a recipe from the text below.
 
            %s
 
            Text:
            %s
            """.formatted(cuisine, converter.getFormat(), text);
 
    String raw = chatClient.prompt().user(prompt).call().content();
    return converter.convert(raw);   // parse the raw response yourself
}

converter.getFormat() returns the schema-and-instructions string that tells the model exactly what shape to produce.

Enums for constrained choices

An enum return type is one of the most reliable structured outputs, because the schema enumerates the only valid answers:

public enum Intent { BILLING, TECHNICAL, SALES, OTHER }
 
public Intent classifyIntent(String message) {
    return chatClient.prompt()
            .user(u -> u.text("Classify the intent of: {msg}").param("msg", message))
            .options(ChatOptions.builder().temperature(0.0).build())
            .call()
            .entity(Intent.class);
}

The model cannot return REFUND_REQUEST because it is not in the enum — the schema constrains it to your four values.

Validation with compact constructors

Structured output guarantees the shape, not the values. Validate the values yourself:

public record DateRange(LocalDate from, LocalDate to) {
    public DateRange {
        // The model produced valid dates; that does not make them sensible.
        if (to.isBefore(from)) {
            throw new IllegalArgumentException("end date before start date");
        }
    }
}

Handling parse failures

Rare with a clear schema and low temperature, but not zero. Handle it:

public Optional<Recipe> extractSafely(String text) {
    try {
        return Optional.of(
                chatClient.prompt()
                        .user(u -> u.text("Extract a recipe from:\n{text}").param("text", text))
                        .options(ChatOptions.builder().temperature(0.0).build())
                        .call()
                        .entity(Recipe.class));
    } catch (Exception e) {
        // Could not parse — log the raw response for debugging, return empty.
        log.warn("recipe extraction failed for input length {}", text.length(), e);
        return Optional.empty();
    }
}

Nested and complex structures

Structured output handles nesting. Keep it reasonable — very deep schemas are harder for the model to fill correctly:

public record Invoice(
        String vendor,
        LocalDate date,
        List<LineItem> items,
        Money total) {
 
    public record LineItem(String description, int quantity, Money unitPrice) {}
    public record Money(BigDecimal amount, String currency) {}
}

When to use structured output

  • Extraction — pulling fields out of unstructured text.
  • Classification — enum return types.
  • Any response your code acts on — if you if on it, type it.

When you want free-form prose for a human to read, plain .content() is right. Structured output is for when the machine is the consumer.

Next

Frequently Asked Questions

How does Spring AI convert an LLM response into a Java object?
It derives a JSON schema from your target type, appends format instructions to the prompt telling the model to produce JSON matching that schema, then deserialises the response into your type. You call .entity(YourType.class) and receive a typed object with no manual parsing.
What happens if the model returns malformed JSON?
Deserialisation throws. In practice, modern models with a clear schema and low temperature rarely produce malformed JSON, but you should still handle the exception — retry once, fall back to a default, or surface a clear error. Never assume the parse always succeeds in a production path.
Can I get a list of objects back, not just one?
Yes. Use a ParameterizedTypeReference to preserve the element type: .entity(new ParameterizedTypeReference<List<Recipe>>() {}). Spring AI generates a schema for the list and deserialises each element into your record.
Should I use structured output or JSON mode?
Spring AI's structured output is the higher-level option and usually what you want — it handles schema generation and parsing. Provider JSON mode (where available) guarantees syntactically valid JSON at the API level and can be combined with structured output for extra reliability on models that support it.

Related tutorials