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.


What You Will Learn


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 422 response 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:


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/