Skip to main content

Spring AI: Structured Output with Java Records

Greetings!

In previous articles, we used Spring AI to generate responses in text format. While generating text is useful, enterprise applications rarely need only free form responses. Applications usually need predictable data that can be processed by application logic. Imagine we need to extract customer details using an LLM.

text
Extract customer information:

John Smith
john@example.com
+1 555-1234

The LLM may correctly return a response like this:

text
Name: John Smith
Email: john@example.com
Phone: +1 555-1234

While this looks fine to the human eye, our application cannot reliably parse this kind of response. The model may change the format.

text
Customer Name: John Smith
Email Address: john@example.com
Contact Number: +1 555-1234

Or it may return a sentence.

text
The customer's name is John Smith. His email address is
john@example.com and his phone number is +1 555-1234.

All these responses are understandable to a human, but application code needs a well defined structure. This is where structured output becomes useful.

What Is Structured Output?

Instead of working with free form text responses, we ask the model to generate output that follows a defined structure.

Spring AI can map the model response into a Java type, giving our application predictable and type safe data that is easier to process and validate.

For example, instead of parsing text, we want to work with a Java object.

java
Customer customer;

Then our application can simply access the extracted values.

java
customer.name();
customer.email();
customer.phone();

Mapping Responses to Java Records

Spring AI provides built in support for mapping model responses to Java types.

Let's define a Java record representing our customer.

java
public record Customer(
        String name,
        String email,
        String phone
) {
}

Now we can ask the model to extract the customer information and map the response directly to our Customer record.

java
Customer customer = chatClient.prompt()
        .user("""
                Extract customer information:

                John Smith
                john@example.com
                +1 555-1234
                """)
        .call()
        .entity(Customer.class);

The entity() method tells Spring AI to convert the model response into the specified Java type.

Spring AI uses the target type to describe the expected output structure to the model and converts the generated response into a Customer object. Instead of manually parsing the response, we can now work with a normal Java object.

java
customer.name();
customer.email();
customer.phone();

Java records are particularly useful for structured AI responses because they are:

  • Immutable
  • Concise
  • Easy to serialize
  • Easy to validate

Mapping Collections

Structured output also supports collections. Imagine we want the model to recommend five books for Java developers.

First, let's define a Book record.

java
public record Book(
        String title,
        String author,
        String summary
) {
}

We can map the model response directly to a List<Book>.

java
List<Book> books = chatClient.prompt()
        .user("Recommend 5 books for Java developers")
        .call()
        .entity(
                new ParameterizedTypeReference<List<Book>>() {
                }
        );

Because Java removes generic type information at runtime through type erasure, we use ParameterizedTypeReference to preserve the complete target type. Spring AI can then map the model response to a List<Book>.

Wrapper Records vs Collections

We can represent collections in two common ways.

The first approach is to wrap the collection inside another Java type.

java
public record Movies(
        List<Movie> movies
) {
}

Then we can map the response directly to Movies.

java
Movies movies = chatClient.prompt()
        .user("Recommend 5 movies")
        .call()
        .entity(Movies.class);

Alternatively, we can map the response directly to a List<Movie>.

java
List<Movie> movies = chatClient.prompt()
        .user("Recommend 5 movies")
        .call()
        .entity(
                new ParameterizedTypeReference<List<Movie>>() {
                }
        );

Both approaches are valid. A wrapper record can be useful when the response may contain additional information.

For example:

java
public record Movies(
        List<Movie> movies,
        String genre,
        int count
) {
}

A direct List<Movie> is convenient when we only need the collection.

Externalized Prompts with Structured Output

In the previous article, we learned how to externalize prompts into String Template files.

We can use the same approach with structured output.

Let's create two prompt files.

text
src/main/resources/prompts
├── system.st
└── user.st

system.st

text
You are an experienced movie recommendation assistant.

Your responsibility is to recommend movies that closely match the user's
preferences.

Follow these rules:

1. Recommend real movies only.
2. Do not invent movie titles.
3. Match the requested genre and language as closely as possible.
4. Avoid recommending the same movie more than once.
5. Write a concise summary for each movie.
6. Do not reveal plot twists, endings, or major spoilers.
7. Return exactly the number of movies requested by the user.
8. If the user's preferences are broad, provide a diverse selection.
9. Do not include additional commentary outside the requested response.

user.st

text
Recommend {count} movies based on the following preferences:

Genre: {genre}
Preferred language: {language}

For every movie, provide:
- The movie title
- Its primary genre
- Year of release
- A short, spoiler-free summary

The prompt defines what the model should recommend.

Our Java type defines the structure expected by the application.

java
public record Movie(
        String title,
        String genre,
        int year,
        String language,
        String summary
) {
}

Putting It All Together

Let's put the concepts we discussed into a complete example.

java
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.Resource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
public class OutputController {

    private final ChatClient chatClient;

    @Value("classpath:/prompts/system.st")
    private Resource systemPrompt;

    @Value("classpath:/prompts/user.st")
    private Resource userPrompt;

    public OutputController(ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    @GetMapping("/extract")
    public String extract() {

        return chatClient.prompt()
                .user("""
                        Extract customer information:

                        John Smith
                        john@example.com
                        +1 555-1234
                        """)
                .call()
                .content();
    }

    @GetMapping("/extract2")
    public Customer extract2() {

        return chatClient.prompt()
                .user("""
                        Extract customer information:

                        John Smith
                        john@example.com
                        +1 555-1234
                        """)
                .call()
                .entity(Customer.class);
    }

    @GetMapping("/movie-chat")
    public Movies movieChat(
            @RequestParam String genre,
            @RequestParam String language,
            @RequestParam(defaultValue = "5") int count) {

        return chatClient.prompt()
                .system(spec -> spec.text(systemPrompt))
                .user(spec -> spec.text(userPrompt)
                        .param("genre", genre)
                        .param("language", language)
                        .param("count", count))
                .call()
                .entity(Movies.class);
    }

    @GetMapping("/movie-chat2")
    public List<Movie> movieChat2(
            @RequestParam String genre,
            @RequestParam String language,
            @RequestParam(defaultValue = "5") int count) {

        return chatClient.prompt()
                .system(spec -> spec.text(systemPrompt))
                .user(spec -> spec.text(userPrompt)
                        .param("genre", genre)
                        .param("language", language)
                        .param("count", count))
                .call()
                .entity(
                        new ParameterizedTypeReference<List<Movie>>() {
                        }
                );
    }

    public record Customer(
            String name,
            String email,
            String phone
    ) {
    }

    public record Movies(
            List<Movie> movies
    ) {
    }

    public record Movie(
            String title,
            String genre,
            int year,
            String language,
            String summary
    ) {
    }
}

  • The /extract endpoint returns the model response as free-form text.
  • The /extract2 endpoint maps the model response directly into a Customer record.
  • The /movie-chat endpoint demonstrates how a collection can be wrapped inside another Java record.
  • The /movie-chat2 maps the response directly into a List<Movie> using ParameterizedTypeReference.

Summary

In this article, we explored how Spring AI can map model responses directly into Java types using structured output.

We learned how to convert model responses into Java records and collections and how ParameterizedTypeReference can be used for generic types such as List<Movie>.

In the next article, we will explore conversation memory and learn how Spring AI maintains context across multiple user interactions.

Comments