aregmi.net
Resume

Getting Java objects back from the LLM instead of parsing strings

structured-output json entity converter spring-ai

Structured Output

The problem

You ask the model for a list of movies. It gives you a nice paragraph. Great for humans, terrible for code. You need a List<String> or a POJO, not prose.

Structured output solves this — tell the model what format to return, parse the response into your target type.

Level 1: .entity() (the easy way)

ChatClient handles everything with .entity():

Transcript example

Say you have a /structured endpoint that takes a transcript and returns structured data:

@Data
public class TranscriptModel {
    private String conversationId;
    private String problem;
    private String sentiment;
    private boolean resolution;
}

TranscriptModel result = chatClient.prompt()
    .user(u -> u.text("Given this transcript, convert it into a structured format. {transcript}")
        .param("transcript", transcript))
    .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
    .call()
    .entity(new ParameterizedTypeReference<TranscriptModel>() {});

Clean — your API returns typed data directly, not a random text blob.

Same idea with a simpler example: record ActorFilms(String actor, List movies) {}

ActorFilms result = ChatClient.create(chatModel).prompt() .user("Generate the filmography of 5 movies for Tom Hanks.") .call() .entity(ActorFilms.class);

// result.actor() → "Tom Hanks" // result.movies() → ["Forrest Gump", "Cast Away", ...]


That's it. Spring AI generates a JSON schema from your record, tells the model to return JSON matching it, deserializes with Jackson. All behind the scenes.

### Collections

Need a list of objects? Use `ParameterizedTypeReference`:

```java
List<ActorFilms> results = ChatClient.create(chatModel).prompt()
    .user("Generate the filmography of 5 movies for Tom Hanks and Bill Murray.")
    .call()
    .entity(new ParameterizedTypeReference<List<ActorFilms>>() {});

Level 2: Maps and lists

Sometimes you don't need a full POJO.

Map output

Map<String, Object> result = ChatClient.create(chatModel).prompt()
    .user(u -> u.text("Provide me a List of {subject}")
                .param("subject", "an array of numbers from 1 to 9 under the key name 'numbers'"))
    .call()
    .entity(new ParameterizedTypeReference<Map<String, Object>>() {});

List output

List<String> flavors = ChatClient.create(chatModel).prompt()
    .user(u -> u.text("List five {subject}")
                .param("subject", "ice cream flavors"))
    .call()
    .entity(new ListOutputConverter(new DefaultConversionService()));

Level 3: Native structured output

Here's where it gets interesting. Modern models (GPT-4o, Claude 3.5, Gemini 1.5 Pro) support structured output natively — the model guarantees valid JSON matching your schema. No prompt-based formatting needed.

ActorFilms result = ChatClient.create(chatModel).prompt()
    .advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT)
    .user("Generate the filmography for a random actor.")
    .call()
    .entity(ActorFilms.class);

Or set it globally:

@Bean
ChatClient chatClient(ChatClient.Builder builder) {
    return builder
        .defaultAdvisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT)
        .build();
}

Why native over prompt-based?

Models that support it:

Level 4: Low-level BeanOutputConverter

If you're working at the ChatModel level (not ChatClient), use converters directly:

BeanOutputConverter<ActorFilms> converter = new BeanOutputConverter<>(ActorFilms.class);

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

Generation generation = chatModel.call(
    PromptTemplate.builder()
        .template(template)
        .variables(Map.of("actor", "Tom Hanks", "format", format))
        .build()
        .create()
).getResult();

ActorFilms result = converter.convert(generation.getOutput().getText());

More verbose, but useful when you need full control.

Property ordering

Control the order of properties in the generated JSON schema with @JsonPropertyOrder:

@JsonPropertyOrder({"actor", "movies"})
record ActorFilms(String actor, List<String> movies) {}

Getting entity AND metadata

Sometimes you need the structured entity and model metadata (token usage, etc.):

ResponseEntity<ActorFilms> responseEntity = ChatClient.create(chatModel).prompt()
    .user("Generate the filmography for a random actor.")
    .call()
    .responseEntity(ActorFilms.class);

ActorFilms films = responseEntity.getEntity();
ChatResponse chatResponse = responseEntity.getChatResponse();
// chatResponse has token counts, model info, etc.

Things to remember

  1. Use .entity() on ChatClient — simplest path from LLM output to Java objects
  2. The TranscriptModel pattern works well for multi-field responses
  3. Try native structured output first if your model supports it
  4. Records and classes both work — pick whatever fits your team
  5. Validate at API boundaries — this is best-effort from the model
  6. Don't use structured output for tool calling — tool calling already gives you structured params