Spring Boot 6 min read

Spring AI Structured Output: Parse LLM Responses into Java Objects

Convert unpredictable LLM text into type-safe Java objects. BeanOutputConverter, ListOutputConverter, and custom converters explained.

MR

Moshiour Rahman

Advertisement

The Problem: LLMs Return Unstructured Chaos

You ask an LLM to generate a product description. You get back:

Here's a great product description for you!

**Name**: SuperWidget 3000
**Price**: $49.99 (but sometimes on sale!)

This amazing widget will change your life...

How do you parse that into a Product object? You don’t. You suffer.

Spring AI’s Structured Output API solves this by forcing LLMs to return valid JSON that maps directly to your Java classes.

Quick Answer (TL;DR)

// Define your class
record Product(String name, BigDecimal price, String description) {}

// Get structured output in one line
Product product = ChatClient.create(chatModel)
    .prompt()
    .user("Generate a product for a tech store")
    .call()
    .entity(Product.class);  // Returns typed Java object!

No regex. No parsing. Type-safe objects from LLM output.


How It Works

Spring AI’s StructuredOutputConverter does two things:

Spring AI Structured Output Flow

  1. Before call: Appends JSON schema instructions to your prompt
  2. After call: Parses the JSON response into your Java class

Available Converters

ConverterOutput TypeUse Case
BeanOutputConverterSingle objectProduct, User
ListOutputConverterList<String>Names, tags
MapOutputConverterMap<String, Object>Dynamic key-value
CustomAny typeComplex generics

BeanOutputConverter: Single Objects

record Character(
    String name,
    int age,
    String race,
    String characterClass,
    String bio
) {}

Character character = ChatClient.create(chatModel)
    .prompt()
    .user("Generate a D&D character who is an elf wizard")
    .call()
    .entity(Character.class);

// Result:
// Character[name=Aelindra Starweave, age=342, race=Elf,
//           characterClass=Wizard, bio=Born in the ancient...]

Low-Level API (More Control)

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

String template = """
    Generate a D&D character with race {race}
    {format}
    """;

PromptTemplate promptTemplate = new PromptTemplate(
    template,
    Map.of("race", "Dwarf", "format", converter.getFormat())
);

Prompt prompt = new Prompt(promptTemplate.createMessage());
Generation generation = chatModel.call(prompt).getResult();

Character character = converter.convert(generation.getOutput().getContent());

What getFormat() adds to your prompt:

Your response should be in JSON format.
Do not include any explanations, only provide a RFC8259 compliant JSON
response following this format without deviation.
Do not include markdown code blocks in your response.
Here is the JSON Schema instance your output must adhere to:
{
  "type": "object",
  "properties": {
    "name": {"type": "string"},
    "age": {"type": "integer"},
    "race": {"type": "string"},
    ...
  }
}

ListOutputConverter: String Lists

// High-level
List<String> names = ChatClient.create(chatModel)
    .prompt()
    .user("List 5 fantasy character names")
    .call()
    .entity(new ListOutputConverter(new DefaultConversionService()));

// Low-level
ListOutputConverter converter = new ListOutputConverter(new DefaultConversionService());
String format = converter.getFormat();

String prompt = """
    List 10 popular programming languages
    %s
    """.formatted(format);

Generation gen = chatModel.call(new Prompt(prompt)).getResult();
List<String> languages = converter.convert(gen.getOutput().getContent());
// [Java, Python, JavaScript, TypeScript, Go, Rust, ...]

MapOutputConverter: Key-Value Pairs

Map<String, Object> characterMap = ChatClient.create(chatModel)
    .prompt()
    .user("Generate 3 D&D characters as key-value pairs where key is name")
    .call()
    .entity(new ParameterizedTypeReference<Map<String, Object>>() {});

// Result:
// {
//   "Thorin Ironforge": {"race": "Dwarf", "class": "Fighter"},
//   "Elara Moonwhisper": {"race": "Elf", "class": "Ranger"},
//   ...
// }

Limitation: MapOutputConverter only supports Map<String, Object>. For typed values, build a custom converter.


Custom Converter: Typed Maps

Let’s build a converter for Map<String, Character>:

public class GenericMapOutputConverter<V>
    implements StructuredOutputConverter<Map<String, V>> {

    private final ObjectMapper objectMapper;
    private final String jsonSchema;
    private final TypeReference<Map<String, V>> typeRef;

    public GenericMapOutputConverter(Class<V> valueType) {
        this.objectMapper = new ObjectMapper()
            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        this.typeRef = new TypeReference<>() {};
        this.jsonSchema = generateJsonSchema(valueType);
    }

    @Override
    public Map<String, V> convert(String text) {
        try {
            // Strip markdown code blocks if present
            text = text.replaceAll("^```json\\s*", "")
                       .replaceAll("\\s*```$", "");
            return objectMapper.readValue(text, typeRef);
        } catch (JsonProcessingException e) {
            throw new RuntimeException("Failed to parse JSON", e);
        }
    }

    @Override
    public String getFormat() {
        return """
            Your response should be in JSON format.
            The data structure should be a Map where keys are strings
            and values match this schema:
            %s
            Do not include explanations or markdown.
            """.formatted(jsonSchema);
    }

    private String generateJsonSchema(Class<V> valueType) {
        // Use jsonschema-generator library
        SchemaGeneratorConfig config = new SchemaGeneratorConfigBuilder(
            SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON
        ).build();
        SchemaGenerator generator = new SchemaGenerator(config);
        return generator.generateSchema(valueType).toString();
    }
}

Usage:

Map<String, Character> characters = ChatClient.create(chatModel)
    .prompt()
    .user("Generate 3 D&D characters")
    .call()
    .entity(new GenericMapOutputConverter<>(Character.class));

// Fully typed! characters.get("Thorin") returns Character, not Object

Best Practices

1. Use Records for DTOs

// Good - immutable, concise
record Product(String name, BigDecimal price, List<String> tags) {}

// Avoid - mutable, boilerplate
public class Product {
    private String name;
    private BigDecimal price;
    // getters, setters, constructors...
}

2. Add Validation

record Product(
    @NotBlank String name,
    @Positive BigDecimal price,
    @Size(min = 1, max = 10) List<String> tags
) {}

Product product = chatClient.prompt()
    .user("Generate a product")
    .call()
    .entity(Product.class);

// Validate after parsing
validator.validate(product);

3. Handle Parsing Failures

try {
    Product product = chatClient.prompt()
        .user(userInput)
        .call()
        .entity(Product.class);
} catch (JsonProcessingException e) {
    // LLM didn't follow schema - retry or fallback
    log.warn("Failed to parse LLM response: {}", e.getMessage());
    return defaultProduct();
}

4. Use Specific Prompts

// Vague - LLM may not include all fields
"Generate a character"

// Specific - better results
"Generate a D&D character with: name (string), age (integer between 20-500),
 race (one of: Human, Elf, Dwarf, Halfling), class (one of: Fighter, Wizard,
 Rogue, Cleric), and a 2-sentence bio"

Common Pitfalls

IssueCauseFix
JsonParseExceptionLLM included markdownStrip ```json blocks
Missing fieldsLLM skipped optional fieldsAdd defaults in record
Wrong typesLLM returned “42” instead of 42Use lenient ObjectMapper
Empty responseToken limit hitIncrease max tokens

Complete Example: Product Catalog Generator

@Service
public class ProductCatalogService {

    private final ChatClient chatClient;

    public ProductCatalogService(ChatModel chatModel) {
        this.chatClient = ChatClient.create(chatModel);
    }

    record Product(
        String sku,
        String name,
        String description,
        BigDecimal price,
        String category,
        List<String> tags
    ) {}

    public List<Product> generateCatalog(String category, int count) {
        String prompt = """
            Generate %d products for a %s store.
            Each product should have realistic:
            - SKU (format: CAT-XXXXX)
            - Name
            - Description (1-2 sentences)
            - Price (realistic for the category)
            - Category: %s
            - Tags (3-5 relevant tags)
            """.formatted(count, category, category);

        return chatClient.prompt()
            .user(prompt)
            .call()
            .entity(new ParameterizedTypeReference<List<Product>>() {});
    }
}

Code Repository

Full examples with all converter types:

GitHub: techyowls/techyowls-io-blog-public/spring-ai-structured-output


Further Reading


Turn LLM chaos into type-safe Java objects. Follow TechyOwls for more practical guides.

Advertisement

MR

Moshiour Rahman

Software Architect & AI Engineer

Share:
MR

Moshiour Rahman

Software Architect & AI Engineer

Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.

Related Articles

Comments

Comments are powered by GitHub Discussions.

Configure Giscus at giscus.app to enable comments.