Module 3 — Structured Outputs & Multimodality
1. Mapping Model Output to POJOs (Structured Outputs)
The core problem this solves: LLMs natively produce unstructured text. Any real application almost always wants a typed object back — not a string to regex-parse. Spring AI’s Structured Outputs pipeline handles the full round trip: it instructs the model (via prompt engineering and, where supported, native schema constraints) to produce JSON matching a target shape, then deserializes that JSON into your POJO.
record ArchitectureRecommendation(
String pattern,
String justification,
List<String> tradeoffs,
int confidenceScore
) {}
ArchitectureRecommendation rec = chatClient.prompt()
.user("""
Given a system handling 50k orders/day with bursty traffic,
recommend an architecture pattern.
""")
.call()
.entity(ArchitectureRecommendation.class);
System.out.println(rec.pattern()); // typed access, no manual parsing
System.out.println(rec.tradeoffs()); // a real List<String>, not a string to split
What’s actually happening under the hood — worth knowing because it explains both the convenience and the failure modes:
- Spring AI generates a JSON Schema from your target type (via reflection over the record/class).
- It injects formatting instructions (and, on providers that support it, a native structured-output/JSON-mode constraint) into the prompt so the model is steered toward emitting conforming JSON.
- The response is parsed (typically via Jackson — relevant given the Jackson 2→3 migration flagged in Module 1’s versioning section) into your target type.
Native structured output vs. prompt-based structured output — a distinction worth naming explicitly:
| Prompt-based (older/fallback approach) | Native structured output | |
|---|---|---|
| Mechanism | Instructions injected into the prompt asking the model to “respond only in JSON matching this schema” | Provider-level API constraint (e.g., OpenAI’s JSON mode/structured outputs, similar mechanisms across providers) that forces the model’s token generation to conform |
| Reliability | The model usually complies but can occasionally drift — add preamble text, miss a field, produce slightly malformed JSON | Strongly enforced at the generation level — much higher reliability, since malformed output is largely prevented at the source rather than caught after the fact |
| Provider support | Universal — works with any model that can follow instructions | Provider/model-dependent — not every provider or every model version supports native constrained generation |
Talking point: “Whenever the provider supports native structured outputs, I prefer it over prompt-based JSON instructions, because prompt-based formatting is still fundamentally probabilistic — the model is just very likely to comply, not guaranteed to. Native structured outputs push the guarantee down to the token-generation level itself, which matters a lot if a malformed response would break a downstream pipeline rather than just degrade a chat UI gracefully. For anything feeding into automated processing rather than direct human reading, I treat native structured-output support as close to a requirement, not a nice-to-have.”
A genuinely important practical caution worth raising: even with native structured outputs, deserialization can still fail — a model can produce technically valid JSON that doesn’t satisfy business-level constraints your POJO’s schema doesn’t capture (e.g., a numeric field within a valid JSON number range but outside a meaningful business range). Structured output correctness at the JSON-shape level is not the same as correctness at the business-logic level — this connects directly back to the AI Model Evaluation utilities from the table of contents’ Module 10; structured output parsing succeeding doesn’t mean the content is right, just that the shape is right.
2. Custom StructuredOutputConverter Implementations
The built-in .entity(Class) path covers the common case, but StructuredOutputConverter is the extension point when you need custom parsing logic — non-JSON output formats, legacy system integration, or post-processing beyond what reflection-based deserialization can express.
public interface StructuredOutputConverter<T> extends Converter<String, T>, FormatProvider {
// Converter<String, T>.convert(String) — parses the raw model output into T
// FormatProvider.getFormat() — returns formatting instructions injected into the prompt
}
A custom implementation — e.g., parsing a pipe-delimited legacy format some downstream system requires, rather than JSON:
public class PipeDelimitedConverter implements StructuredOutputConverter<List<String>> {
@Override
public String getFormat() {
return """
Respond with values separated by the pipe character, e.g.:
value1|value2|value3
Do not include any other text or explanation.
""";
}
@Override
public List<String> convert(String source) {
if (source == null || source.isBlank()) {
throw new IllegalArgumentException("Empty model response — cannot parse pipe-delimited output");
}
return Arrays.stream(source.trim().split("\\|"))
.map(String::trim)
.toList();
}
}
List<String> result = chatClient.prompt()
.user("List the top 3 risks of this migration plan.")
.call()
.responseEntity(new PipeDelimitedConverter())
.getEntity();
Why this extension point matters architecturally: it’s the same “interface as extension seam” pattern that runs through Spring generally — StructuredOutputConverter lets custom output handling plug into the same ChatClient fluent chain as the built-in JSON path, rather than requiring a bypass of the framework’s abstractions whenever a non-standard format shows up.
A robust custom converter should handle the failure modes the built-in JSON path handles implicitly:
- Malformed/partial output — the model truncated mid-response (common with token limits) — fail clearly rather than silently returning a partially-parsed result.
- Defensive validation, not just parsing — converting successfully doesn’t mean the content is trustworthy; a converter is a good place to enforce structural invariants (e.g., “exactly 3 items expected”) before handing control back to application code.
Talking point: “I treat a custom StructuredOutputConverter the same way I’d treat a custom Jackson deserializer — it needs to fail loudly and specifically on malformed input, not swallow an error and return a default or null that silently corrupts something three layers downstream. Given that the input here is model output rather than a well-formed system-to-system payload, I assume malformed output will happen eventually and design the converter’s error handling for that reality rather than the happy path alone.”
3. Multimodal Input/Output — Image, Audio, Document Inputs
Image input — passing visual content alongside text, for providers/models supporting vision:
var imageResource = new ClassPathResource("architecture-diagram.png");
String analysis = chatClient.prompt()
.user(u -> u.text("What architectural pattern does this diagram show? Identify any obvious issues.")
.media(MimeTypeUtils.IMAGE_PNG, imageResource))
.call()
.content();
Image output (generation) — via the ImageModel/ImageClient abstraction, parallel in structure to ChatModel/ChatClient:
ImageResponse imageResponse = imageModel.call(
new ImagePrompt("A minimalist navy and teal logo for a software architecture brand",
OpenAiImageOptions.builder().withModel("dall-e-3").withHeight(1024).withWidth(1024).build())
);
String imageUrl = imageResponse.getResult().getOutput().getUrl();
Audio — transcription (speech-to-text):
AudioTranscriptionResponse transcription = transcriptionModel.call(
new AudioTranscriptionPrompt(new ClassPathResource("sermon-recording.mp3"))
);
String transcript = transcription.getResult().getOutput();
This is directly relevant to your Tech & Faith channel’s bilingual Tamil-English content workflow — transcribing recorded sermon/teaching audio programmatically rather than manually, as a building block for generating searchable transcripts or repurposed written content from spoken material.
Audio — text-to-speech (TTS), now available through multiple provider integrations including the recently added ElevenLabs and OpenAI’s own Audio models:
SpeechResponse speech = speechModel.call(
new SpeechPrompt("Welcome back to Tech and Faith.",
OpenAiAudioSpeechOptions.builder().voice("alloy").responseFormat(Mp3).build())
);
byte[] audioBytes = speech.getResult().getOutput();
Document inputs — feeding PDFs/documents directly as model input (where the provider supports native document understanding), distinct from the ETL/RAG ingestion pipeline (Module 7) which is about indexing documents for retrieval rather than passing one directly into a single prompt:
chatClient.prompt()
.user(u -> u.text("Summarize the key risks in this contract.")
.media(MimeTypeUtils.APPLICATION_PDF, new ClassPathResource("contract.pdf")))
.call()
.content();
Talking point connecting multimodality back to the rest of the curriculum: “The same ChatClient fluent chain handles text, image, and document input — .media() is just another input attached to the user message, not a separate API to learn. That consistency is the actual point of the abstraction: a developer who’s learned the text-prompt pattern from Module 2 doesn’t need a fundamentally different mental model to add multimodal input, just an additional parameter on the same builder.”
4. Handling Provider-Specific Model Deprecations — Designing for Model Churn
The concrete example from the release history: Mistral’s Pixtral 12B model was removed, and Pixtral Large was deprecated in favor of newer Mistral vision models — a real, recent instance of the churn every team building on any of these providers will face repeatedly, not a hypothetical.
Why this is an architectural problem, not just a config update: if model names are hardcoded as string literals scattered through application code ("pixtral-12b-2409" typed directly into a dozen call sites), a provider deprecation becomes a multi-file search-and-replace under time pressure. If model selection is centralized and externalized, it becomes a one-line config change.
The pattern worth establishing from day one:
# Externalize model identifiers — never hardcode in application code
spring.ai.mistralai.chat.options.model=${MISTRAL_MODEL:mistral-large-latest}
// Or, centralized in a single configuration class rather than scattered string literals
@ConfigurationProperties(prefix = "app.ai.models")
public record ModelConfig(String visionModel, String chatModel, String embeddingModel) {}
A layered defensive strategy worth presenting as a checklist:
- Externalize all model identifiers to configuration, never inline string literals in business logic.
- Subscribe to/regularly check provider release notes and Spring AI’s own release notes — deprecation announcements are usually given a runway, but only if someone’s actually watching for them.
- Abstract model selection behind your own service layer, not just the model call — e.g., a
VisionModelSelectorthat knows “use the current recommended vision model,” so a deprecation only requires updating that one piece of central logic, regardless of how many features used vision capability. - Treat “latest” aliases carefully — providers sometimes offer a
-lateststyle alias (as in the Mistral example above) that auto-rolls to the newest recommended model; convenient for staying current automatically, but it trades determinism for currency — a prompt that worked well against one model version might behave subtly differently after a silent-latestrollover. Pin to an explicit version for anything where reproducibility matters (e.g., regression-tested prompts, compliance-sensitive output), and use-latest-style aliases only where staying current matters more than exact reproducibility. - Build an evaluation suite (Module 10 territory) that runs against model changes, not just code changes — the most dangerous version of this churn is a model swap that doesn’t break compilation but silently degrades output quality, which only a behavioral test, not a unit test, would catch.
Talking point that ties this back to your architecture background directly: “This is the same discipline as designing against any external dependency that you don’t control the lifecycle of — exactly the posture I’d take with a third-party API or a cloud-managed service version. AWS deprecates instance types and SDK versions on its own schedule too; the mitigation is identical in shape: externalize the dependency identifier, centralize the selection logic in one place, and have an automated way to detect behavioral regressions before a forced migration becomes an emergency instead of a planned upgrade.”
A closing synthesis point for the module: “Structured outputs, custom converters, and model-churn resilience are really the same underlying lesson from three angles — don’t let probabilistic model behavior leak unguarded into the rest of the system. Structured outputs constrain the shape of output; converters add a validation seam before untrusted parsed data reaches business logic; and externalized model selection means a provider’s own lifecycle decisions don’t directly destabilize application code. Each is a different layer of the same boundary-drawing discipline.”