Spring Boot Tutorial
Structured Output with Spring AI
Plain text answers are fine for a chatbot, but most business features need data: a list of tags, a sentiment score, an extracted invoice, a classification label. Parsing free text with string operations is fragile. Spring AI's structured output support asks the model to reply in JSON that matches a schema generated from your Java type, and converts the reply into a Java object for you.
This lesson covers mapping to records, lists and maps, using enums for classification, extracting data from unstructured text, validating the result, and the settings that make structured output reliable.
How .entity() Works
When you call .entity(MyRecord.class), Spring AI's BeanOutputConverter generates a JSON Schema from the record, appends formatting instructions to your prompt, and parses the model's JSON reply into an instance of the record using Jackson. For models and providers that support it, Spring AI can also use the provider's native structured output mode, where the model is constrained to produce valid JSON for the schema.
Designing Types for the Model
The model only sees the schema, so make it self-explanatory. Use descriptive field names, @JsonPropertyDescription to explain fields, enums for fixed sets of values, and simple types (strings, numbers, booleans, lists, nested records). Keep objects small; asking for forty fields at once increases errors.
Generic Types: Lists and Maps
Java erases generics at runtime, so List<Product>.class is not possible. Use new ParameterizedTypeReference<List<Product>>() {} instead. For ad-hoc data you can map to Map<String, Object>, but typed records are safer.
Validation and Retries
Even with a schema, a model can return values that are syntactically valid but wrong — a negative quantity, a missing required field, an invalid email. Apply Bean Validation to the result, and for important flows retry or fall back when validation fails. Spring AI 2.0 includes a StructuredOutputValidationAdvisor that checks the reply against the schema and asks the model to correct non-conforming output automatically.
Temperature and Determinism
For extraction and classification, set temperature to 0 or close to it. You want the same input to produce the same output every time, and creative variation only introduces errors.
Examples
Mapping an answer to a record
public record CourseOutline(
String title,
@JsonPropertyDescription("Target learner level: BEGINNER, INTERMEDIATE or ADVANCED") Level level,
@JsonPropertyDescription("5 to 8 lesson titles in teaching order") List<String> lessons,
@JsonPropertyDescription("Estimated total hours") int hours) {
public enum Level { BEGINNER, INTERMEDIATE, ADVANCED }
}
CourseOutline outline = chatClient.prompt()
.user("Design a short course on Spring Data JPA for developers who know SQL.")
.call()
.entity(CourseOutline.class);
System.out.println(outline.title() + " (" + outline.level() + ", " + outline.hours() + "h)");
outline.lessons().forEach(l -> System.out.println(" - " + l));
Spring Data JPA for SQL Developers (INTERMEDIATE, 6h)
- Entities and the persistence context
- Repositories and derived queries
- JPQL and native queries
- Relationships and fetching
- Transactions
- Performance and the N+1 problem
Classifying support tickets with an enum
public enum TicketCategory { BILLING, LOGIN_PROBLEM, COURSE_CONTENT, BUG_REPORT, OTHER }
public record TicketTriage(TicketCategory category,
@JsonPropertyDescription("1 = low, 5 = urgent") int priority,
String summary) {}
@Service
public class TriageService {
private final ChatClient chatClient;
public TriageService(ChatClient.Builder builder) {
this.chatClient = builder
.defaultSystem("Classify customer support tickets for an online learning platform.")
.defaultOptions(OpenAiChatOptions.builder().temperature(0.0).build())
.build();
}
public TicketTriage triage(String ticketText) {
return chatClient.prompt().user(ticketText).call().entity(TicketTriage.class);
}
}
triage("I was charged twice for the Spring Boot course and I can't find a refund option!!")
TicketTriage[category=BILLING, priority=4, summary=Customer was double-charged for the Spring Boot course and wants a refund.]
Extracting a list of objects from unstructured text
public record LineItem(String product, int quantity, BigDecimal unitPrice) {}
String email = """
Hi, please send 3 Java Core workbooks at 499 each,
one Spring Boot hoodie (1299) and 2 stickers packs for 99 each. Thanks, Ravi
""";
List<LineItem> items = chatClient.prompt()
.system("Extract ordered items. Prices are in INR.")
.user(email)
.call()
.entity(new ParameterizedTypeReference<List<LineItem>>() {});
items.forEach(System.out::println);
LineItem[product=Java Core workbook, quantity=3, unitPrice=499]
LineItem[product=Spring Boot hoodie, quantity=1, unitPrice=1299]
LineItem[product=Sticker pack, quantity=2, unitPrice=99]
Validating structured output before using it
public record Signup(@NotBlank String name, @Email String email, @Min(13) int age) {}
@Service
public class SignupExtractor {
private final ChatClient chatClient;
private final Validator validator;
public SignupExtractor(ChatClient.Builder builder, Validator validator) {
this.chatClient = builder.build();
this.validator = validator;
}
public Signup extract(String text) {
Signup signup = chatClient.prompt()
.user(u -> u.text("Extract the signup details from: {text}").param("text", text))
.call()
.entity(Signup.class);
Set<ConstraintViolation<Signup>> errors = validator.validate(signup);
if (!errors.isEmpty()) {
throw new IllegalArgumentException("AI extraction failed validation: " + errors);
}
return signup;
}
}
extract("I'm Meera, 24, reach me at meera@example.com") -> Signup[name=Meera, email=meera@example.com, age=24]
extract("Name: Tom, email: not-an-email, age 9") -> IllegalArgumentException: AI extraction failed validation: [email must be a well-formed email address, age must be greater than or equal to 13]
Common Mistakes
- Parsing JSON out of content() manually with regular expressions instead of using entity().
- Using List<MyType>.class style code (impossible) instead of ParameterizedTypeReference for generic types.
- Giving fields vague names like "value" or "data" so the model cannot infer what to put in them.
- Trusting extracted values without validation, especially numbers, dates and emails.
- Using a high temperature for extraction, producing different results for the same input.
Key Points to Remember
- .entity(Class) generates a JSON schema, instructs the model and converts the reply to your type.
- Use ParameterizedTypeReference for lists and other generic types.
- Descriptive names, @JsonPropertyDescription and enums make results far more accurate.
- Validate results with Bean Validation and use low temperature for extraction and classification.
- StructuredOutputValidationAdvisor can automatically ask the model to fix non-conforming JSON.
Practice the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.