Structured Output: Asking the AI to Serve JSON Instead of Raw Text
The Problem We Are Solving
Lisa, the Hiring Manager, receives 50 resumes a week. Each one is in a different format — some are PDFs converted to text, some are copy-pasted from LinkedIn. She asks Dev:
"Can the AI read a resume and give me the key info in a consistent format? I want to plug it straight into our HR system — not parse a paragraph of text."
This is structured output. Instead of returning a paragraph of text, the AI returns a JSON object — and Spring AI's BeanOutputConverter deserialises that JSON directly into a typed Java record.
- Input to the AI — the raw resume text, plus a JSON schema describing the fields to extract
- Output from the AI — a JSON string matching that schema, ready to map to a Java object
What You Will Learn
- Why
.content()is not always enough - How
BeanOutputConverterworks - How to return typed Java records from AI calls
- How to handle parsing failures gracefully
- How to build a resume parsing endpoint
The Problem with Raw Text Output
Without structured output, you get a paragraph:
"The candidate's name is Priya Sharma. She has 5 years of experience in Java
and Spring Boot. She studied Computer Science at IIT Delhi..."
You still have to parse that string. Structured output gives you:
{
"name": "Priya Sharma",
"email": "priya@example.com",
"skills": ["Java", "Spring Boot", "Kubernetes"],
"yearsOfExperience": 5,
"education": "B.Tech Computer Science, IIT Delhi"
}
Which maps directly to a Java record.
How It All Fits Together
Before diving into the code, here is the full picture of what happens under the hood:
1. BeanOutputConverter generates a JSON schema from your record
Spring AI inspects ResumeProfile.class and produces a JSON schema describing all the fields and their types — name (string), skills (array), yearsOfExperience (number), etc.
2. That schema is injected into the prompt via {format}
When you call converter.getFormat(), it returns an instruction like:
Your response must be a JSON object that matches this schema:
{
"type": "object",
"properties": {
"name": { "type": "string" },
"email": { "type": "string" },
"skills": { "type": "array", "items": { "type": "string" } },
"yearsOfExperience": { "type": "integer" },
"currentRole": { "type": "string" },
"education": { "type": "string" }
}
}
So the model is being explicitly told — return JSON, not prose.
3. The model returns a JSON string
Instead of:
"The candidate's name is Priya Sharma. She has 5 years of experience..."
It returns:
{ "name": "Priya Sharma", "email": "priya@example.com", "skills": [...] }
4. converter.convert() deserialises that JSON string into the Java record
It is essentially ObjectMapper.readValue(response, ResumeProfile.class) under the hood — Jackson doing the final step.
The full chain in one view:
Java record → JSON schema (generated by BeanOutputConverter)
↓ injected into prompt as {format}
Ollama returns JSON string
↓
Jackson deserialises → ResumeProfile record
Why the try/catch? The model does not always obey the schema perfectly — sometimes it adds extra commentary before or after the JSON, which breaks deserialisation. The
422response tells the caller to try again with cleaner input.
How BeanOutputConverter Works
public record ResumeProfile(
String name,
String email,
List<String> skills,
int yearsOfExperience,
String currentRole,
String education
) {}
// Spring AI generates a JSON schema from the record and injects it into the prompt
BeanOutputConverter<ResumeProfile> converter = new BeanOutputConverter<>(ResumeProfile.class);
String prompt = """
Parse the following resume and extract the key information.
Resume:
{resumeText}
{format}
""";
// {format} is replaced with the JSON schema instruction automatically
What You Will Build — Resume Parser Endpoint
// POST /hr/resume/parse
public record ResumeParseRequest(String resumeText) {}
@PostMapping("/resume/parse")
public ResumeProfile parseResume(@RequestBody ResumeParseRequest request) {
BeanOutputConverter<ResumeProfile> converter =
new BeanOutputConverter<>(ResumeProfile.class);
String response = chatClient
.prompt()
.user(u -> u.text("""
Parse this resume and extract structured information.
Resume:
{resumeText}
{format}
""")
.param("resumeText", request.resumeText())
.param("format", converter.getFormat()))
.call()
.content();
return converter.convert(response);
}
Test it:
curl -s -X POST http://localhost:8080/hr/resume/parse \
-H "Content-Type: application/json" \
-d '{
"resumeText": "Priya Sharma | priya@example.com\n5 years Java, Spring Boot, AWS\nSenior Engineer at Infosys\nB.Tech CS IIT Delhi 2018"
}'
Handling Parse Failures
Sometimes the model does not return valid JSON. Always wrap the conversion:
try {
return converter.convert(response);
} catch (Exception e) {
throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY,
"Could not parse resume. Try providing more structured text.");
}
Summary
In this chapter you will:
- Understand why structured output matters over raw text
- Use
BeanOutputConverterto map AI responses to Java records - Build a resume parser that returns a typed
ResumeProfileobject - Handle parsing failures gracefully
What's Next
In Chapter 6, we give the SmartHR bot a memory — using InMemoryChatMemory so Raj can have a multi-turn onboarding conversation where the AI remembers what was said earlier in the session.
Code for this chapter: code/chapter-05-structured-output/