Enterprise Java

A Guide to Structured Output in Spring AI

Large Language Models (LLMs) naturally respond with free-form text. That’s great for a chat interface, but most applications need to feed the model’s answer into the rest of the system — save it to a database, pass it to another service, render it in a form. For that, we need predictable, structured data instead of a paragraph of prose. Spring AI solves this with its structured output support. It lets us ask a model to return data that is automatically converted into a Java object, a List, a Map, or any custom type we define — no manual string parsing required. In this guide, we’ll look at what structured output is and then walk through several ways to use it: converting responses into a domain entity, a list, a map, doing the conversion manually with ChatModel, and finally writing our own custom converter.

1. What Is Structured Output?

Structured output in Spring AI is built around the StructuredOutputConverter<T> interface. A converter does two things:

  • Provides a format instruction — a piece of text telling the model exactly how to format its answer (e.g., “respond only with JSON matching this schema”).
  • Converts the raw text response — once the model replies, the converter parses that string into the target Java type.

Spring AI ships with ready-made converters:

  • BeanOutputConverter<T> — converts JSON into a POJO.
  • ListOutputConverter — converts a comma-separated response into a List<String>.
  • MapOutputConverter — converts JSON into a Map<String, Object>.

When using the high-level ChatClient, we usually don’t even touch these converters directly — calling .entity(SomeType.class) picks the right one for us automatically. When we need more control, or we use the lower-level ChatModel API, we can apply a converter manually.

2. Code Example

Rather than five disconnected snippets, here is one complete, runnable Spring Boot application. It exposes five endpoints — one per technique — all sharing the same ActorFilms entity and the same injected ChatClient/ChatModel beans.

2.1 Dependency and Configuration

Before any of the conversion techniques can be used, the project needs the Spring AI OpenAI starter on the classpath along with an API key. The starter auto-configures the ChatClient.Builder and ChatModel beans used throughout the rest of this example.

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
# application.properties
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o-mini

The pom.xml entry pulls in Spring AI’s OpenAI integration, while application.properties supplies the API key (read from an environment variable so it’s never hard-coded) and selects gpt-4o-mini as the chat model; together these are the only setup required before injecting ChatClient or ChatModel into any service.

2.2 Domain Entity

The ActorFilms record is the single domain type shared by every conversion technique below — it models an actor and the list of movie titles the model should return.

public record ActorFilms(String actor, List<String> movies) {}

Because ActorFilms is a plain Java record with two typed fields, Spring AI can derive a JSON schema from it automatically and reuse that same schema across the entity, manual, and custom-converter endpoints further down.

2.3 Custom Output Converter

Not every response fits neatly into JSON — a plain comma-separated line is sometimes easier for a model to produce reliably. UpperCaseListConverter implements StructuredOutputConverter<List<String>> to handle that case, and is used later by the custom-converter endpoint.

public class UpperCaseListConverter implements StructuredOutputConverter<List<String>> {

    @Override
    public List<String> convert(String text) {
        return Arrays.stream(text.trim().split("\\s*,\\s*"))
                .map(String::toUpperCase)
                .toList();
    }

    @Override
    public String getFormat() {
        return """
                Respond only with a comma separated list of items.
                Do not number them, and do not add any explanation or preamble.
                """;
    }
}

getFormat() supplies the instruction injected into the prompt, telling the model to reply with a bare comma-separated list, while convert() implements the matching parsing logic — splitting on commas and upper-casing each item — giving full control over both the request format and the response parsing.

2.4 Service — All Five Techniques in One Class

All five structured-output techniques live in a single FilmographyService, each exposed as its own method so they can be compared side by side.

@Service
public class FilmographyService {

    private final ChatClient chatClient;
    private final ChatModel chatModel;

    public FilmographyService(ChatClient.Builder builder, ChatModel chatModel) {
        this.chatClient = builder.build();
        this.chatModel = chatModel;
    }

    // 1. Converting the response into our domain entity
    public ActorFilms getFilmographyAsEntity(String actor) {
        return chatClient.prompt()
                .user("Generate the filmography of 5 movies for %s.".formatted(actor))
                .call()
                .entity(ActorFilms.class);
    }

    // 2. Converting the response into a List
    public List<String> getFilmographyAsList(String actor) {
        return chatClient.prompt()
                .user("List the titles of 5 movies starring %s.".formatted(actor))
                .call()
                .entity(new ParameterizedTypeReference<List<String>>() {});
    }

    // 3. Converting the response into a Map
    public Map<String, Object> getFilmographyAsMap(String actor) {
        return chatClient.prompt()
                .user("""
                        Give the debut movie year and total movie count for %s.
                        Return as a JSON map with keys "debutYear" and "movieCount".
                        """.formatted(actor))
                .call()
                .entity(new ParameterizedTypeReference<Map<String, Object>>() {});
    }

    // 4. Converting the response manually using ChatModel
    public ActorFilms getFilmographyManually(String actor) {
        BeanOutputConverter<ActorFilms> converter =
                new BeanOutputConverter<>(ActorFilms.class);

        String template = """
                Generate the filmography of 5 movies for {actor}.
                {format}
                """;

        Prompt prompt = new PromptTemplate(template, Map.of(
                "actor", actor,
                "format", converter.getFormat()
        )).create();

        Generation generation = chatModel.call(prompt).getResult();
        return converter.convert(generation.getOutput().getText());
    }

    // 5. Using a custom output converter
    public List<String> getFilmographyUpperCase(String actor) {
        UpperCaseListConverter converter = new UpperCaseListConverter();

        String template = """
                List 5 movie titles starring {actor}.
                {format}
                """;

        Prompt prompt = new PromptTemplate(template, Map.of(
                "actor", actor,
                "format", converter.getFormat()
        )).create();

        Generation generation = chatModel.call(prompt).getResult();
        return converter.convert(generation.getOutput().getText());
    }
}

Methods 1–3 go through ChatClient, using .entity(ActorFilms.class) and ParameterizedTypeReference to get a typed object, a list, and a map respectively; method 4 drops to the lower-level ChatModel and applies a BeanOutputConverter by hand; method 5 swaps that for the UpperCaseListConverter defined above — the same request/response flow, just with a different converter plugged in.

2.5 Controller

A thin FilmographyController exposes each service method as its own REST endpoint so every technique can be tested independently.

@RestController
@RequestMapping("/filmography")
public class FilmographyController {

    private final FilmographyService service;

    public FilmographyController(FilmographyService service) {
        this.service = service;
    }

    @GetMapping("/{actor}/entity")
    public ActorFilms entity(@PathVariable String actor) {
        return service.getFilmographyAsEntity(actor);
    }

    @GetMapping("/{actor}/list")
    public List<String> list(@PathVariable String actor) {
        return service.getFilmographyAsList(actor);
    }

    @GetMapping("/{actor}/map")
    public Map<String, Object> map(@PathVariable String actor) {
        return service.getFilmographyAsMap(actor);
    }

    @GetMapping("/{actor}/manual")
    public ActorFilms manual(@PathVariable String actor) {
        return service.getFilmographyManually(actor);
    }

    @GetMapping("/{actor}/custom")
    public List<String> custom(@PathVariable String actor) {
        return service.getFilmographyUpperCase(actor);
    }
}

2.6 Main Class

Finally, a standard Spring Boot entry point wires everything together and starts the embedded server.

@SpringBootApplication
public class StructuredOutputApplication {
    public static void main(String[] args) {
        SpringApplication.run(StructuredOutputApplication.class, args);
    }
}

@SpringBootApplication triggers auto-configuration for the Spring AI starter and the ChatClient.Builder / ChatModel beans, so no wiring beyond what’s already in application.properties is needed to run any of the five endpoints.

2.7 Code Run and output

With the API key exported, the application can be started and each endpoint exercised with a simple curl command.

export OPENAI_API_KEY=sk-...
mvn spring-boot:run

curl http://localhost:8080/filmography/Tom%20Hanks/entity
curl http://localhost:8080/filmography/Tom%20Hanks/list
curl http://localhost:8080/filmography/Tom%20Hanks/map
curl http://localhost:8080/filmography/Tom%20Hanks/manual
curl http://localhost:8080/filmography/Tom%20Hanks/custom

Each curl call hits one of the five endpoints on FilmographyController, letting all five structured-output techniques be verified independently against the same running instance of the application. Running them against the actor “Tom Hanks” produces the following JSON responses, one per technique:

GET /filmography/Tom%20Hanks/entity
{"actor":"Tom Hanks","movies":["Forrest Gump","Cast Away","Big","Toy Story","Saving Private Ryan"]}

GET /filmography/Tom%20Hanks/list
["Forrest Gump","Cast Away","Big","Toy Story","Saving Private Ryan"]

GET /filmography/Tom%20Hanks/map
{"debutYear":1980,"movieCount":5}

GET /filmography/Tom%20Hanks/manual
{"actor":"Tom Hanks","movies":["Forrest Gump","Cast Away","Big","Toy Story","Saving Private Ryan"]}

GET /filmography/Tom%20Hanks/custom
["FORREST GUMP","CAST AWAY","BIG","TOY STORY","SAVING PRIVATE RYAN"]

4. Conclusion

Structured output is what turns an LLM from a text generator into a reliable component of a Java application. With ChatClient.entity(...), converting responses into domain objects, lists, or maps takes a single method call. When we need more control — using ChatModel directly, or applying custom parsing logic — the StructuredOutputConverter interface gives us the same guarantees with full flexibility.

Yatin Batra

An experience full-stack engineer well versed with Core Java, Spring/Springboot, MVC, Security, AOP, Frontend (Angular & React), and cloud technologies (such as AWS, GCP, Jenkins, Docker, K8).
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button