Technology RadarTechnology Radar

Structured Outputs with Java Records

structured-outputspring-ailangchain4j
Trial

Using Java records as the target type for LLM structured outputs gives you type-safe, immutable, concisely-declared AI response objects — replacing manual JSON parsing and string extraction with compiler-verified types.

Why It's in Trial

Java records (introduced in Java 16, now standard) are the ideal data carrier for structured LLM outputs. They are:

  • Immutable — LLM responses shouldn't mutate
  • Concise — no boilerplate getters/setters/constructors
  • JSON-friendly — Jackson serialises/deserialises records without configuration
  • Transparent — record components are part of the type definition; easy to read

Combined with Spring AI's structured output support or LangChain4j's @StructuredPrompt, this pattern eliminates a whole class of brittle string-parsing code.

Spring AI: Entity Extraction

// Define the shape of the response
record ProductAnalysis(
    String sentiment,        // "positive" | "negative" | "neutral"
    List<String> keyFeatures,
    int confidenceScore,     // 0–100
    String recommendedAction
) {}

// Ask the model to fill it
ProductAnalysis analysis = chatClient.prompt()
    .user("Analyse this product review: " + reviewText)
    .call()
    .entity(ProductAnalysis.class);

// Fully type-safe from here
if (analysis.confidenceScore() > 80 && "negative".equals(analysis.sentiment())) {
    escalateToSupportTeam(analysis.recommendedAction());
}

Spring AI generates the JSON schema from the record definition and instructs the model to respond conforming to that schema. No manual schema writing.

LangChain4j: @StructuredPrompt

@StructuredPrompt("Extract the key details from this support ticket: {{ticket}}")
record SupportTicket(
    String summary,
    String priority,        // "low" | "medium" | "high" | "critical"
    List<String> affectedSystems,
    String suggestedTeam
) {}

// In your AI service
interface TicketRouter {
    SupportTicket extract(String ticket);
}

TicketRouter router = AiServices.create(TicketRouter.class, model);
SupportTicket ticket = router.extract(rawTicketText);

Sealed Interfaces for Discriminated Unions

For AI responses that can be one of several shapes, sealed interfaces + records model the type precisely:

sealed interface ClassificationResult permits
    Approved, Rejected, NeedsReview {}

record Approved(String reason, double confidence) implements ClassificationResult {}
record Rejected(String reason, List<String> violations) implements ClassificationResult {}
record NeedsReview(String reason, String assignedTeam) implements ClassificationResult {}

Pattern matching in switch then handles each case exhaustively — the compiler enforces that you've handled every possible LLM response shape.

Validation

Pair with Bean Validation to catch model responses that are structurally valid JSON but semantically wrong:

record SentimentScore(
    @NotBlank String sentiment,
    @Min(0) @Max(100) int confidence,
    @Size(min = 1, max = 5) List<String> reasons
) {}

Spring AI validates structured outputs automatically when Bean Validation is on the classpath.

Key Characteristics

Property Value
Requires Java 16+ (records GA)
Spring AI support ChatClient.call().entity(MyRecord.class)
LangChain4j support @StructuredPrompt + AiServices
Schema generation Automatic from record definition