Most AI applications begin by receiving plain text:
var response = await chatClient.GetResponseAsync(
"Analyze this support ticket.");
That works well when the application only needs to display the answer to a human.
The situation changes when the AI response must be consumed by application code.
For example, suppose an AI model analyzes a support ticket and your application needs:
Category
Priority
Customer sentiment
Summary
Suggested action
A plain-text response might look like:
The issue is related to billing. It appears urgent because
the customer was charged twice. Sentiment is negative.
Recommended action: escalate to the billing team.
A human can understand it, but application code has to extract the information.
A structured response is much easier to consume:
{
"category": "Billing",
"priority": "High",
"sentiment": "Negative",
"summary": "Customer reports a duplicate charge.",
"suggestedAction": "Escalate to the billing team."
}
And in .NET, the application can go one step further and work directly with a C# type:
TicketAnalysis result
Current Microsoft.Extensions.AI provides structured-output support through ChatClientStructuredOutputExtensions, including generic GetResponseAsync<T> overloads. Microsoft also provides ChatResponseFormat.Json for general JSON output and ChatResponseFormat.ForJsonSchema<T> for schema-based JSON output. (learn.microsoft.com, learn.microsoft.com)
The overall architecture is:
.NET Application
|
v
Prompt / Context
|
v
IChatClient
|
v
Structured Output Request
|
v
AI Model
|
v
JSON / JSON Schema
|
v
ChatResponse<T>
|
v
.NET Object / DTO
|
v
Validation + Business Logic
|
v
Database / API / UI
What Is a Structured AI Response?
A structured AI response is a model response designed to conform to a predefined data shape instead of returning unrestricted natural language.
For example:
public sealed class ProductReviewAnalysis
{
public string? Summary { get; set; }
public string? Sentiment { get; set; }
public int Rating { get; set; }
}
The application expects something resembling:
{
"summary": "The product works well.",
"sentiment": "Positive",
"rating": 5
}
The important difference is:
Plain AI Response
|
v
string
versus:
Structured AI Response
|
v
JSON
|
v
C# Type
Microsoft's current .NET structured-output quickstart describes structured output as a response of a specified type rather than just plain text. It demonstrates requesting an enum or a C# record through GetResponseAsync<T>. (learn.microsoft.com)
Why Structured Output Matters
Structured responses are especially useful when AI output is consumed by software rather than only displayed to a user.
Typical scenarios include:
resume extraction
invoice extraction
document classification
sentiment analysis
ticket classification
product extraction
customer profiling
database query planning
JSON API generation
workflow routing
AI agents
tool arguments
RAG metadata
document processing
Without structured output:
AI
|
v
Free-form text
|
v
String parsing
|
+-- regex
+-- Split()
+-- substring
+-- custom parsers
|
v
Application object
With structured output:
AI
|
v
Structured JSON
|
v
Serializer
|
v
C# Object
The second architecture is easier to maintain.
Plain Text vs JSON vs JSON Schema
There are several levels of structure.
Plain Text
var response =
await chatClient.GetResponseAsync(
"Classify this support ticket.");
The result is essentially natural language.
JSON Without a Specific Schema
You can request JSON:
var options = new ChatOptions
{
ResponseFormat = ChatResponseFormat.Json
};
This requests structured JSON data, but does not define the exact properties that must appear. Microsoft documents ChatResponseFormat.Json as structured JSON without a particular schema. (learn.microsoft.com)
JSON Schema
You can define the exact shape:
category -> string
priority -> enum
summary -> string
suggestedAction -> string
and request JSON conforming to that schema.
Microsoft's current ChatResponseFormat.ForJsonSchema APIs can generate a response format from a .NET type or from an explicit JsonElement schema. (learn.microsoft.com)
Typed Structured Output
The easiest high-level API is:
var response =
await chatClient.GetResponseAsync<TicketAnalysis>(
messages);
This asks for a response matching TicketAnalysis. The current Microsoft API provides multiple GetResponseAsync<T> overloads and returns ChatResponse<T>. (learn.microsoft.com)
The Structured Output Pipeline
A production application can follow:
User Input
|
v
Prompt
|
v
C# Response Type
|
v
JSON Schema
|
v
IChatClient
|
v
AI Model
|
v
Structured JSON
|
v
Deserialization
|
v
C# Object
|
v
Business Validation
This pipeline is much safer than asking for arbitrary JSON and then trusting it.
Setting Up a .NET Project
Create a console application:
dotnet new console -n StructuredOutputDemo
cd StructuredOutputDemo
Add the Microsoft AI packages:
dotnet add package Microsoft.Extensions.AI
dotnet add package Microsoft.Extensions.AI.OpenAI
dotnet add package OpenAI
For an Azure OpenAI implementation, Microsoft also documents using Azure.AI.OpenAI, Azure.Identity, Microsoft.Extensions.AI, and Microsoft.Extensions.AI.OpenAI with an IChatClient. (learn.microsoft.com)
Creating the IChatClient
A provider-specific client can be adapted to IChatClient.
Example with OpenAI:
using Microsoft.Extensions.AI;
using OpenAI;
var apiKey =
Environment.GetEnvironmentVariable("OPENAI_API_KEY")
?? throw new InvalidOperationException(
"OPENAI_API_KEY is not configured.");
var model =
Environment.GetEnvironmentVariable("AI_MODEL")
?? throw new InvalidOperationException(
"AI_MODEL is not configured.");
IChatClient chatClient =
new OpenAIClient(apiKey)
.GetChatClient(model)
.AsIChatClient();
The application can now use:
IChatClient
instead of coupling structured-output logic directly to a provider-specific API.
Creating a C# Response Type
Suppose the application needs to classify support tickets.
Create:
public enum TicketCategory
{
Billing,
Delivery,
Account,
Technical,
Other
}
Create a priority:
public enum TicketPriority
{
Low,
Medium,
High,
Critical
}
Create the output model:
public sealed class TicketAnalysis
{
public TicketCategory Category { get; set; }
public TicketPriority Priority { get; set; }
public string? Summary { get; set; }
public string? SuggestedAction { get; set; }
}
Now the application has a clear contract:
TicketAnalysis
|
+-- Category
+-- Priority
+-- Summary
+-- SuggestedAction
Requesting a Typed Response
The simplest approach is:
var response =
await chatClient.GetResponseAsync<TicketAnalysis>(
"""
Analyze this support ticket.
Ticket:
My credit card was charged twice for the same order.
Return the appropriate category, priority, summary,
and suggested action.
""");
The result is:
TicketAnalysis result = response.Result;
For example:
Console.WriteLine(
$"Category: {result.Category}");
Console.WriteLine(
$"Priority: {result.Priority}");
Console.WriteLine(
$"Summary: {result.Summary}");
Console.WriteLine(
$"Action: {result.SuggestedAction}");
Microsoft's current structured-output quickstart demonstrates the same GetResponseAsync<T> pattern with a custom enum and record type. (learn.microsoft.com)
Understanding ChatResponse<T>
The return type is:
ChatResponse<T>
rather than simply:
T
Current ChatResponse<T> inherits from ChatResponse and exposes properties including:
Result
Text
Usage
ModelId
FinishReason
ResponseId
Messages
It also provides TryGetResult. (learn.microsoft.com)
The architecture is:
ChatResponse<T>
|
+-- Result
| |
| +-- TicketAnalysis
|
+-- Text
| |
| +-- Raw JSON text
|
+-- Usage
|
+-- ModelId
|
+-- FinishReason
Using Result
For a successful response:
var ticket = response.Result;
Console.WriteLine(ticket.Category);
This is convenient.
However, Microsoft documents that Result throws if the response does not contain JSON or if deserialization fails. For exception-free handling, Microsoft recommends TryGetResult. (learn.microsoft.com)
Using TryGetResult
For more defensive application code:
if (response.TryGetResult(out TicketAnalysis? ticket))
{
Console.WriteLine(ticket.Category);
}
else
{
Console.WriteLine(
"The AI response could not be parsed.");
}
Microsoft's API documentation states that TryGetResult returns true when the result can be produced and false otherwise. (learn.microsoft.com)
For production workflows, this is often preferable to assuming that a model will always produce a valid result.
Why a Schema Is Better Than JSON Instructions
You might write:
Return JSON.
{
"category": "...",
"priority": "...",
"summary": "...",
"suggestedAction": "..."
}
This provides guidance.
But the application still has to trust the model to follow the requested shape.
A JSON schema provides a formal response structure:
TicketAnalysis
|
+-- category
| enum
|
+-- priority
| enum
|
+-- summary
| string
|
+-- suggestedAction
string
The current .NET ChatResponseFormat.ForJsonSchema<T> API can create a JSON response format based on a .NET type. (learn.microsoft.com)
Explicit JSON Schema with ChatResponseFormat
You can manually configure a schema-based response.
using Microsoft.Extensions.AI;
using System.Text.Json;
Example schema:
var schemaJson = """
{
"type": "object",
"properties": {
"category": {
"type": "string"
},
"priority": {
"type": "string"
},
"summary": {
"type": "string"
}
},
"required": [
"category",
"priority",
"summary"
],
"additionalProperties": false
}
""";
Parse it:
using var document =
JsonDocument.Parse(schemaJson);
Create the response format:
var responseFormat =
ChatResponseFormat.ForJsonSchema(
document.RootElement,
"TicketAnalysis",
"Support ticket analysis");
Then:
var options = new ChatOptions
{
ResponseFormat = responseFormat
};
This gives you explicit control over the JSON Schema.
Generating a Schema from a C# Type
Manually maintaining JSON Schema can become tedious.
Current ChatResponseFormat.ForJsonSchema<T> can generate a schema from a .NET type:
var responseFormat =
ChatResponseFormat.ForJsonSchema<TicketAnalysis>();
Then:
var options = new ChatOptions
{
ResponseFormat = responseFormat
};
Microsoft documents overloads for ForJsonSchema(Type, ...) and ForJsonSchema<T>(...), including optional JsonSerializerOptions, schema name, and description. (learn.microsoft.com)
Using the Generated Schema Directly
You can combine it with ordinary GetResponseAsync:
var options = new ChatOptions
{
ResponseFormat =
ChatResponseFormat.ForJsonSchema<TicketAnalysis>()
};
var response =
await chatClient.GetResponseAsync(
messages,
options);
In this case, the low-level response is:
ChatResponse
and the application can deserialize the JSON itself.
This gives you more control over the pipeline.
Typed API vs Explicit ResponseFormat
There are two main patterns.
Typed Convenience API
var response =
await chatClient.GetResponseAsync<TicketAnalysis>(
messages);
Advantages:
simple
strongly typed
less code
automatic typed response wrapper
easy to read
Explicit Response Format
var options = new ChatOptions
{
ResponseFormat =
ChatResponseFormat.ForJsonSchema<TicketAnalysis>()
};
var response =
await chatClient.GetResponseAsync(
messages,
options);
Advantages:
direct control
explicit response format
useful when building generic infrastructure
easy to combine with custom response processing
Both approaches are useful.
How GetResponseAsync<T> Uses JSON Schema
The current structured-output extension includes a parameter named:
useJsonSchemaResponseFormat
When set to true, it requests a JSON Schema-based response format. The documented default is true. Microsoft notes that using a JSON schema can improve reliability when the underlying model supports native schema-based structured output, but can cause an error when the model does not support it. (learn.microsoft.com)
So this:
var response =
await chatClient.GetResponseAsync<TicketAnalysis>(
messages);
is not merely:
"Please return JSON."
The extension is designed to request a typed structured response.
Disabling JSON Schema for Compatibility
If the underlying provider does not support native JSON Schema structured output, the API allows:
var response =
await chatClient.GetResponseAsync<TicketAnalysis>(
messages,
options: null,
useJsonSchemaResponseFormat: false,
cancellationToken: cancellationToken);
This is a compatibility option.
However, reliability can be lower because the application may no longer have schema enforcement at the model-service layer.
Use this only when the selected client/provider requires it.
JSON Mode
Sometimes you want JSON but do not need a strict schema.
For example:
var options = new ChatOptions
{
ResponseFormat = ChatResponseFormat.Json
};
Then:
var response =
await chatClient.GetResponseAsync(
"""
Extract the important fields from this product review
and return them as JSON.
Review:
The product is comfortable and durable.
""",
options);
The resulting response is still JSON-oriented, but the application has not supplied an exact schema.
Microsoft describes ChatResponseFormat.Json as structured JSON without a particular schema. (learn.microsoft.com)
JSON Mode vs JSON Schema
The distinction is important.
JSON Mode
|
+-- "Return valid JSON"
|
+-- no specific schema
|
+-- flexible structure
versus:
JSON Schema
|
+-- property definitions
+-- types
+-- required fields
+-- enums
+-- structural constraints
Choose JSON mode when:
structure is flexible
you control the downstream parser
schema is intentionally dynamic
Choose JSON Schema when:
the application expects a fixed contract
types matter
fields are known
downstream code depends on the structure
Creating a Strong C# Contract
A structured response model should be designed like an API DTO.
For example:
public sealed class ResumeAnalysis
{
public string? CandidateName { get; set; }
public List<string> Skills { get; set; } = [];
public int YearsOfExperience { get; set; }
public string? CurrentRole { get; set; }
public List<string> PreviousCompanies { get; set; } = [];
}
The application can now consume:
ResumeAnalysis analysis
instead of parsing arbitrary text.
Using Records for Structured Responses
Records work well too:
public record ProductReviewAnalysis(
string Summary,
string Sentiment,
int Rating);
Then:
var response =
await chatClient.GetResponseAsync<ProductReviewAnalysis>(
prompt);
Microsoft's structured-output quickstart itself demonstrates a record type being used as the requested structured result. (learn.microsoft.com)
Using Enums
Enums are especially useful for classification.
public enum Sentiment
{
Positive,
Negative,
Neutral
}
Then:
public sealed class SentimentAnalysis
{
public Sentiment Sentiment { get; set; }
public string? Explanation { get; set; }
}
The AI should return one of the defined semantic categories.
This is usually safer than:
public string? Sentiment { get; set; }
because the application can reason about a controlled set of values.
Enum Serialization
If you need JSON enum names instead of numeric values during serialization, configure System.Text.Json appropriately.
For example:
using System.Text.Json;
using System.Text.Json.Serialization;
var serializerOptions =
new JsonSerializerOptions();
serializerOptions.Converters.Add(
new JsonStringEnumConverter());
Then you can use:
var response =
await chatClient.GetResponseAsync<SentimentAnalysis>(
prompt,
serializerOptions);
The structured-output extensions provide overloads accepting JsonSerializerOptions, so serialization and deserialization behavior can be customized. (learn.microsoft.com)
JSON Naming Policies
Suppose your C# model is:
public sealed class CustomerInfo
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
public string? CustomerId { get; set; }
}
You may prefer JSON:
{
"firstName": "Alex",
"lastName": "Smith",
"customerId": "C10045"
}
Configure:
var serializerOptions =
new JsonSerializerOptions
{
PropertyNamingPolicy =
JsonNamingPolicy.CamelCase
};
Then use those options with structured output.
Attributes on Structured Output Models
Standard System.Text.Json attributes can also be useful.
For example:
using System.Text.Json.Serialization;
public sealed class CustomerInfo
{
[JsonPropertyName("customer_id")]
public string? CustomerId { get; set; }
[JsonPropertyName("first_name")]
public string? FirstName { get; set; }
[JsonPropertyName("last_name")]
public string? LastName { get; set; }
}
Now the expected JSON property names are explicitly defined.
When generating a JSON schema from a .NET type, the JsonSerializerOptions supplied to ForJsonSchema<T> control serialization/schema generation behavior. (learn.microsoft.com)
Nested Structured Output
Structured output becomes more powerful when the response contains nested objects.
public sealed class CustomerAddress
{
public string? City { get; set; }
public string? State { get; set; }
public string? Country { get; set; }
}
public sealed class CustomerProfile
{
public string? Name { get; set; }
public CustomerAddress? Address { get; set; }
public List<string> Interests { get; set; } = [];
}
The expected response might be:
{
"name": "Alex",
"address": {
"city": "Hyderabad",
"state": "Telangana",
"country": "India"
},
"interests": [
"technology",
"programming"
]
}
The model returned to the application becomes:
CustomerProfile
with nested objects populated.
Structured Lists
You can request an object containing a list:
public sealed class ProductList
{
public List<Product> Products { get; set; } = [];
}
public sealed class Product
{
public string? Name { get; set; }
public string? Category { get; set; }
public decimal Price { get; set; }
}
This is useful for:
product extraction
invoice items
resume skills
document entities
search results
recommendation metadata
Why Wrapper Objects Matter
Some structured-output services require the top-level JSON Schema to be an object.
Microsoft's current ChatResponseFormat.ForJsonSchema documentation specifically warns that primitive types such as string, int, or bool, and types that serialize as arrays, may fail with services requiring a top-level type: object. The documentation recommends wrapping such values in a class or struct. (learn.microsoft.com)
For example, instead of:
GetResponseAsync<List<string>>
use:
public sealed class SkillResult
{
public List<string> Skills { get; set; } = [];
}
Then:
GetResponseAsync<SkillResult>
This gives:
{
"skills": [
"C#",
"ASP.NET Core",
"SQL Server"
]
}
which is an object at the top level.
Nullable Properties
AI extraction often has missing data.
For example:
public sealed class InvoiceData
{
public string? InvoiceNumber { get; set; }
public DateTime? InvoiceDate { get; set; }
public decimal? TotalAmount { get; set; }
}
Nullable properties let the application distinguish:
value exists
from:
value unavailable
This is important for document extraction.
Required Properties
You can define a DTO:
public sealed class InvoiceData
{
public required string InvoiceNumber { get; set; }
public required string SupplierName { get; set; }
public decimal? TotalAmount { get; set; }
}
The required keyword communicates a C# initialization requirement.
However, do not assume that a C# requirement alone means every provider will enforce exactly the same schema semantics. Structured-output support depends on the client/provider's capabilities and schema translation.
The generated schema and the downstream validation rules should be treated as separate layers.
Semantic Validation
Schema validation and business validation are different.
Suppose the model returns:
{
"rating": 100
}
and your type says:
public int Rating { get; set; }
The JSON is structurally valid.
But your application may require:
1 <= Rating <= 5
This is a business rule.
So:
Schema Validation
|
v
Correct data shape
|
v
Business Validation
|
v
Correct domain meaning
Data Annotation Validation
You can use standard .NET validation.
using System.ComponentModel.DataAnnotations;
public sealed class ProductReview
{
[Required]
public string? Summary { get; set; }
[Range(1, 5)]
public int Rating { get; set; }
[Required]
public string? Sentiment { get; set; }
}
Then validate:
var validationContext =
new ValidationContext(result);
var validationResults =
new List<ValidationResult>();
var valid =
Validator.TryValidateObject(
result,
validationContext,
validationResults,
validateAllProperties: true);
If validation fails:
foreach (var error in validationResults)
{
Console.WriteLine(
error.ErrorMessage);
}
This is an important production pattern.
Custom Domain Validation
Sometimes business rules are more complex.
public static class TicketAnalysisValidator
{
public static void Validate(
TicketAnalysis result)
{
if (result.Category == TicketCategory.Billing &&
string.IsNullOrWhiteSpace(
result.SuggestedAction))
{
throw new InvalidOperationException(
"Billing tickets require a suggested action.");
}
if (result.Priority == TicketPriority.Critical &&
string.IsNullOrWhiteSpace(result.Summary))
{
throw new InvalidOperationException(
"Critical tickets require a summary.");
}
}
}
This protects application logic from AI mistakes.
Structured Output Is Not a Guarantee of Correctness
This is one of the most important concepts in AI application development.
A valid JSON response can still contain incorrect information.
For example:
{
"invoiceNumber": "INV-9001",
"totalAmount": 1250000
}
The JSON may be perfectly valid.
But the actual document might say:
Total:
₹12,500
The structure is correct.
The data is wrong.
Therefore:
Structured Output
!=
Factual Accuracy
Current Microsoft documentation explicitly warns that language models are not guaranteed to honor a requested schema, and ChatResponse<T> supports TryGetResult specifically for cases where the response cannot be parsed into the expected type. (learn.microsoft.com)
Structured Output Architecture for Production
A robust application uses:
AI Model
|
v
Schema-Constrained Response
|
v
Deserialization
|
v
C# DTO
|
v
Structural Validation
|
v
Business Validation
|
v
Authorization / Policy Checks
|
v
Application Logic
Never skip directly from:
AI
to:
Database
without validation.
Example: Resume Analyzer
Create:
public sealed class ResumeAnalysis
{
public string? CandidateName { get; set; }
public string? CurrentRole { get; set; }
public int? YearsOfExperience { get; set; }
public List<string> Skills { get; set; } = [];
public List<string> Companies { get; set; } = [];
}
Prompt:
Analyze the following resume.
Extract only information explicitly stated in the resume.
Do not infer years of experience when the information is unavailable.
Do not invent skills or employment history.
Resume:
{{resume}}
Call:
var response =
await chatClient.GetResponseAsync<ResumeAnalysis>(
prompt);
if (!response.TryGetResult(
out ResumeAnalysis? analysis))
{
throw new InvalidOperationException(
"Unable to parse resume analysis.");
}
Now:
Console.WriteLine(
analysis.CandidateName);
foreach (var skill in analysis.Skills)
{
Console.WriteLine(skill);
}
Example: Invoice Extraction
Define:
public sealed class InvoiceResult
{
public string? InvoiceNumber { get; set; }
public string? SupplierName { get; set; }
public DateTime? InvoiceDate { get; set; }
public decimal? Subtotal { get; set; }
public decimal? Tax { get; set; }
public decimal? Total { get; set; }
public List<InvoiceLine> Items { get; set; } = [];
}
public sealed class InvoiceLine
{
public string? Description { get; set; }
public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal Total { get; set; }
}
Then:
var response =
await chatClient.GetResponseAsync<InvoiceResult>(
invoicePrompt);
if (!response.TryGetResult(
out InvoiceResult? invoice))
{
throw new InvalidOperationException(
"Invoice extraction failed.");
}
Business validation:
if (invoice.Total < 0)
{
throw new InvalidOperationException(
"Invoice total cannot be negative.");
}
You can also compare line totals with the stated invoice total.
Example: Sentiment Analysis
Define:
public enum Sentiment
{
Positive,
Negative,
Neutral
}
public sealed class SentimentResult
{
public Sentiment Sentiment { get; set; }
public string? Explanation { get; set; }
}
Then:
var response =
await chatClient.GetResponseAsync<SentimentResult>(
"""
Analyze the sentiment of the following customer review.
Review:
The product is comfortable and easy to use.
""");
Access:
var result =
response.Result;
Console.WriteLine(
result.Sentiment);
Microsoft's official structured-output quickstart demonstrates a similar sentiment-classification pattern using a custom enumeration. (learn.microsoft.com)
Example: Product Extraction
public sealed class ProductExtraction
{
public string? ProductName { get; set; }
public string? Brand { get; set; }
public decimal? Price { get; set; }
public List<string> Features { get; set; } = [];
}
Prompt:
Extract product information from the following description.
Return the product name, brand, price, and key features.
Description:
{{description}}
Then:
var response =
await chatClient.GetResponseAsync<ProductExtraction>(
prompt);
Example: Support Ticket Classification
public enum TicketCategory
{
Billing,
Delivery,
Account,
Technical,
Other
}
public enum TicketPriority
{
Low,
Medium,
High,
Critical
}
public sealed class TicketClassification
{
public TicketCategory Category { get; set; }
public TicketPriority Priority { get; set; }
public string? Reason { get; set; }
}
Now the AI response becomes directly usable by:
switch (classification.Category)
{
case TicketCategory.Billing:
await billingQueue.EnqueueAsync(...);
break;
case TicketCategory.Technical:
await technicalQueue.EnqueueAsync(...);
break;
case TicketCategory.Delivery:
await deliveryQueue.EnqueueAsync(...);
break;
}
This is one of the most useful applications of structured AI responses.
AI Output as Workflow Data
Structured output allows AI to become part of an application workflow.
For example:
User
|
v
AI Classification
|
+-- Billing
| |
| v
| Billing Queue
|
+-- Technical
| |
| v
| Technical Queue
|
+-- Delivery
|
v
Delivery Queue
Without structured output, the application would have to infer the classification from text.
With structured output:
TicketCategory category
can directly drive application logic.
Structured Output in ASP.NET Core
Suppose you have:
public sealed class AnalyzeRequest
{
public required string Text { get; init; }
}
Create an endpoint:
app.MapPost(
"/api/analyze",
async (
AnalyzeRequest request,
IChatClient chatClient,
CancellationToken cancellationToken) =>
{
var response =
await chatClient.GetResponseAsync<
TicketAnalysis>(
$"Analyze this ticket:\n\n{request.Text}",
cancellationToken: cancellationToken);
if (!response.TryGetResult(
out TicketAnalysis? result))
{
return Results.Problem(
"The AI response could not be parsed.");
}
return Results.Ok(result);
});
The HTTP API now returns a proper JSON object.
For example:
{
"category": "Billing",
"priority": "High",
"summary": "Duplicate charge reported.",
"suggestedAction": "Escalate to billing support."
}
Structured Output API Architecture
POST /api/analyze
|
v
AnalyzeRequest
|
v
AI Service
|
v
IChatClient
|
v
ChatResponse<T>
|
v
TryGetResult()
|
v
Domain Validation
|
v
Results.Ok(result)
This pattern is particularly clean for ASP.NET Core APIs.
Separating AI Services from Controllers
Avoid putting everything inside the controller.
Instead:
public interface ITicketAnalysisService
{
Task<TicketAnalysis> AnalyzeAsync(
string text,
CancellationToken cancellationToken = default);
}
Implementation:
public sealed class TicketAnalysisService
: ITicketAnalysisService
{
private readonly IChatClient _chatClient;
public TicketAnalysisService(
IChatClient chatClient)
{
_chatClient = chatClient;
}
public async Task<TicketAnalysis> AnalyzeAsync(
string text,
CancellationToken cancellationToken = default)
{
var prompt = $"""
Analyze the support ticket.
Return:
- category
- priority
- summary
- suggested action
Ticket:
{text}
""";
var response =
await _chatClient.GetResponseAsync<
TicketAnalysis>(
prompt,
cancellationToken: cancellationToken);
if (!response.TryGetResult(
out TicketAnalysis? result))
{
throw new InvalidOperationException(
"AI returned an invalid structured response.");
}
Validate(result);
return result;
}
private static void Validate(
TicketAnalysis result)
{
if (string.IsNullOrWhiteSpace(
result.Summary))
{
throw new InvalidOperationException(
"Summary is required.");
}
}
}
Register:
builder.Services.AddScoped<
ITicketAnalysisService,
TicketAnalysisService>();
Controller or endpoint:
app.MapPost(
"/api/tickets/analyze",
async (
AnalyzeRequest request,
ITicketAnalysisService service,
CancellationToken cancellationToken) =>
{
var result =
await service.AnalyzeAsync(
request.Text,
cancellationToken);
return Results.Ok(result);
});
Now:
API
|
v
Application Service
|
v
Structured AI Response
|
v
Validation
|
v
Domain Object
Using ChatOptions.ResponseFormat
You can explicitly define the response format.
var options = new ChatOptions
{
ResponseFormat =
ChatResponseFormat.ForJsonSchema<
TicketAnalysis>()
};
Then:
var response =
await chatClient.GetResponseAsync(
messages,
options,
cancellationToken);
This low-level approach is useful when you have an infrastructure service that needs to manage schemas independently of the output type's deserialization.
Schema Name and Description
You can provide schema metadata:
var responseFormat =
ChatResponseFormat.ForJsonSchema<
TicketAnalysis>(
schemaName: "TicketAnalysis",
schemaDescription:
"Classified support ticket information");
This can make schema intent more explicit.
Microsoft's API supports optional schema names and descriptions on the ForJsonSchema overloads. (learn.microsoft.com)
Using an Explicit JSON Schema
Sometimes the response contract comes from another system rather than a C# type.
For example:
{
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": [
"code",
"message"
],
"additionalProperties": false
}
You can parse it:
using var document =
JsonDocument.Parse(schemaJson);
var responseFormat =
ChatResponseFormat.ForJsonSchema(
document.RootElement,
"ApiResult",
"Structured API result");
This is useful when your schemas are:
stored in databases
loaded from configuration
generated dynamically
shared across services
maintained by an external contract
Structured Output and Prompt Templates
Structured output works naturally with the prompt-template architecture from the previous article.
Template:
You are a support ticket classifier.
Analyze this ticket:
{{ticket}}
Return the category, priority, summary,
and recommended action.
Template rendering:
var prompt =
promptRenderer.Render(
template,
new Dictionary<string, string>
{
["ticket"] = ticketText
});
Structured request:
var response =
await chatClient.GetResponseAsync<TicketAnalysis>(
prompt);
Architecture:
Prompt Template
|
v
Rendered Prompt
|
+
C# Output Type
|
v
JSON Schema
|
v
IChatClient
|
v
ChatResponse<T>
This is a powerful combination.
Structured Output and System Prompts
The system prompt can define the task while the structured output type defines the data contract.
System:
You are a support ticket classifier.
Classify the ticket accurately.
Do not invent facts.
Use the available categories.
User:
Ticket:
{{ticket}}
C#:
var messages = new List<ChatMessage>
{
new(
ChatRole.System,
"""
You are a support ticket classifier.
Classify the ticket accurately.
Do not invent facts.
Use the available categories.
"""),
new(
ChatRole.User,
$"Ticket:\n{ticketText}")
};
Structured call:
var response =
await chatClient.GetResponseAsync<TicketAnalysis>(
messages);
This cleanly separates:
System Prompt
=
Behavior
User Prompt
=
Input
C# Type / Schema
=
Output Contract
Structured Output and RAG
Structured output is especially useful with RAG.
Suppose a RAG application receives documents and must extract:
document title
source
confidence
answer
citations
Model:
public sealed class RagAnswer
{
public string? Answer { get; set; }
public double Confidence { get; set; }
public List<Citation> Citations { get; set; } = [];
}
public sealed class Citation
{
public string? Source { get; set; }
public string? Quote { get; set; }
}
Then:
var response =
await chatClient.GetResponseAsync<RagAnswer>(
messages);
The UI can now render citations directly.
Structured Output and Agents
Structured output is also useful when an AI agent needs to decide what should happen next.
For example:
public enum NextAction
{
Search,
AskUser,
ExecuteTool,
Finish
}
public sealed class AgentDecision
{
public NextAction Action { get; set; }
public string? Reason { get; set; }
public string? ToolName { get; set; }
}
Then:
AI
|
v
AgentDecision
|
+-- Search
|
+-- AskUser
|
+-- ExecuteTool
|
+-- Finish
The application can handle the decision explicitly:
switch (decision.Action)
{
case NextAction.Search:
break;
case NextAction.AskUser:
break;
case NextAction.ExecuteTool:
break;
case NextAction.Finish:
break;
}
This is much safer than attempting to parse natural-language instructions such as:
"I think you should search the database."
Structured Output and Database Operations
Structured AI output can serve as an intermediate representation.
For example:
public sealed class ProductSearchRequest
{
public string? Category { get; set; }
public decimal? MaximumPrice { get; set; }
public string? Keyword { get; set; }
}
The AI receives:
Find affordable laptops suitable for programming.
and returns:
{
"category": "laptop",
"maximumPrice": 100000,
"keyword": "programming"
}
Your application then uses the structured object to build a parameterized database query.
Architecture:
Natural Language
|
v
Structured AI Interpretation
|
v
ProductSearchRequest
|
v
Application Validation
|
v
Parameterized SQL / EF Core
|
v
Database
The AI should not directly generate and execute arbitrary database commands.
Structured Output for API Integration
Suppose your application must call another API.
Define:
public sealed class ShippingRequest
{
public string? OrderId { get; set; }
public string? Carrier { get; set; }
public string? ServiceLevel { get; set; }
}
The model can interpret a natural-language instruction:
Ship order 10023 using the fastest available service.
and return:
{
"orderId": "10023",
"carrier": "configured-carrier",
"serviceLevel": "Express"
}
Then application logic validates the request before calling the external API.
Structured Output as an Internal Contract
Think of the output model as an internal API contract.
For example:
AI
|
v
TicketAnalysis
|
v
TicketService
|
v
TicketRepository
This means the AI layer doesn't need to know how the rest of the application stores data.
That is a clean architectural boundary.
Structured Response DTOs vs Domain Entities
Do not automatically deserialize AI output directly into an EF Core entity.
Avoid:
var entity =
await chatClient.GetResponseAsync<CustomerEntity>(
prompt);
Prefer:
var response =
await chatClient.GetResponseAsync<CustomerExtraction>(
prompt);
Then:
AI DTO
|
v
Validation
|
v
Mapping
|
v
Domain Entity
|
v
Database
Example:
var customer =
new Customer
{
Name = extraction.Name,
Email = extraction.Email
};
This reduces the risk of AI-generated fields directly influencing persistence models.
Handling Invalid Structured Responses
A production service should distinguish:
AI request failure
Schema incompatibility
Invalid JSON
Deserialization failure
Business validation failure
Application failure
Example:
try
{
var response =
await chatClient.GetResponseAsync<TicketAnalysis>(
prompt,
cancellationToken: cancellationToken);
if (!response.TryGetResult(
out TicketAnalysis? result))
{
throw new InvalidOperationException(
"Structured response parsing failed.");
}
Validate(result);
return result;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
logger.LogError(
ex,
"Structured AI response failed.");
throw;
}
Do not silently turn AI parsing failures into empty objects.
Retry Strategies
Retries can be useful for transient infrastructure failures.
However, be careful when retrying invalid structured output.
This:
Request
|
v
Invalid response
|
v
Retry
may produce another invalid response.
A better strategy is:
Request
|
v
Failure Type?
|
+-- transient network failure
| |
| v
| retry
|
+-- invalid structured output
| |
| v
| validate / fallback
|
+-- provider doesn't support schema
|
v
configuration fix
Do not use the same retry policy for all failures.
Fallback Strategies
A structured-output service can define a fallback:
Primary Model
|
v
Structured Response
|
+-- success --> result
|
+-- failure
|
v
Fallback Strategy
Fallback options can include:
retry with corrected prompt
switch to another compatible client
use a simpler schema
send to human review
return a controlled application error
The fallback depends on the application's requirements.
Logging Structured Responses
Useful metadata includes:
Prompt name
Prompt version
Model ID
Response ID
Duration
Input token count
Output token count
Total token count
Structured type
Parse success
Validation success
ChatResponse<T> inherits usage and response metadata from ChatResponse, while the current .NET AI abstractions expose usage details for AI calls. (learn.microsoft.com)
Avoid logging sensitive extracted data unless your application's data-governance requirements permit it.
Structured Output and Observability
A useful trace might contain:
TraceId: 5AF8...
Operation: TicketAnalysis
Prompt: TicketClassification
PromptVersion: 3.1
Model: configured-model
Schema: TicketAnalysis
DurationMs: 1420
InputTokens: ...
OutputTokens: ...
ParseSuccess: true
ValidationSuccess: true
This makes failures much easier to investigate.
Structured Output and Versioning
Output types are contracts.
Suppose version 1 has:
public sealed class TicketAnalysis
{
public TicketCategory Category { get; set; }
public string? Summary { get; set; }
}
Version 2 adds:
public string? SuggestedAction { get; set; }
That is a contract change.
Treat structured-response schema changes similarly to API contract changes.
Versioning Structured Schemas
A managed system might define:
TicketAnalysis v1
TicketAnalysis v2
TicketAnalysis v3
For example:
public sealed class TicketAnalysisV1
{
public TicketCategory Category { get; set; }
public string? Summary { get; set; }
}
and:
public sealed class TicketAnalysisV2
{
public TicketCategory Category { get; set; }
public TicketPriority Priority { get; set; }
public string? Summary { get; set; }
public string? SuggestedAction { get; set; }
}
This allows controlled migration.
Schema Evolution Rules
Good schema evolution generally avoids sudden incompatible changes.
Safer:
v1
+ new optional property
More disruptive:
v1
- rename property
- change property type
- remove required field
For large systems, keep explicit schema versions.
Structured Output and Testing
Testing structured output requires more than checking that JSON can be parsed.
Test:
schema compliance
deserialization
required fields
enum values
business constraints
edge cases
missing information
ambiguous input
malicious input
large input
provider failures
Unit Testing the Output Model
You can test business validation independently of the model.
[Fact]
public void CriticalTicketRequiresSummary()
{
var result = new TicketAnalysis
{
Category = TicketCategory.Technical,
Priority = TicketPriority.Critical,
Summary = null
};
Assert.Throws<InvalidOperationException>(
() => Validate(result));
}
This provides deterministic tests.
Integration Testing with a Real AI Client
Create test cases:
"Payment was charged twice."
"The package has not arrived."
"I forgot my password."
"The application crashes at startup."
For each case:
AI
|
v
TicketAnalysis
|
v
Assert expected semantic properties
Avoid relying only on exact natural-language text matches.
Test the structured properties instead:
Assert.Equal(
TicketCategory.Billing,
result.Category);
Evaluation Testing
For AI applications, include a broader evaluation dataset.
For example:
100 support tickets
|
+-- billing
+-- delivery
+-- account
+-- technical
+-- other
Measure:
category accuracy
priority accuracy
required-field completion
invalid-output rate
validation failure rate
latency
token usage
This provides much more information than a handful of manual tests.
Structured Output and Security
Structured output does not automatically make AI data safe.
For example:
{
"customerId": "12345",
"role": "Administrator"
}
may be structurally valid.
That does not mean your application should trust the model's assertion that the person is an administrator.
Security-sensitive fields require external verification.
Use:
identity system
authorization system
database
application policy
rather than treating AI-generated claims as authoritative.
Never Trust AI Authorization Data
Avoid:
if (analysis.Role == "Administrator")
{
GrantAdminAccess();
}
Instead:
Authenticated User
|
v
Application Authorization
|
v
Allowed Action
AI
|
+-- provides interpretation only
Structured output should represent AI interpretation, not replace security systems.
Structured Output and Personally Sensitive Data
If a model extracts personal information:
name
email
phone
address
financial information
the application should apply appropriate data-minimization, access-control, retention, and logging policies.
The fact that the data is structured does not change its sensitivity.
Structured Output and PII Redaction
A useful architecture can be:
Document
|
v
AI Extraction
|
v
Structured DTO
|
v
Data Classification
|
+-- sensitive
+-- non-sensitive
|
v
Controlled Storage
This makes data governance easier to implement.
Structured Output with Custom Serializer Options
Sometimes you need custom serialization behavior.
var serializerOptions =
new JsonSerializerOptions
{
PropertyNamingPolicy =
JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
Then:
var response =
await chatClient.GetResponseAsync<TicketAnalysis>(
messages,
serializerOptions,
cancellationToken: cancellationToken);
The structured-output extension has overloads accepting JsonSerializerOptions specifically for this purpose. (learn.microsoft.com)
Structured Output and JsonSerializerContext
For high-performance or source-generated JSON serialization scenarios, .NET provides source-generation support through System.Text.Json.
For example:
[JsonSerializable(typeof(TicketAnalysis))]
public partial class AiJsonContext
: JsonSerializerContext
{
}
This can be useful in applications that need optimized serialization or trimming-friendly code.
The exact integration should be tested with the schema-generation and structured-output capabilities of the selected client because schema support and serializer behavior are related but distinct concerns.
Structured Output with ASP.NET Core Minimal APIs
A complete example:
using Microsoft.Extensions.AI;
var builder =
WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IChatClient>(
configuredChatClient);
builder.Services.AddScoped<
ITicketAnalysisService,
TicketAnalysisService>();
var app =
builder.Build();
app.MapPost(
"/api/tickets/analyze",
async (
AnalyzeRequest request,
ITicketAnalysisService service,
CancellationToken cancellationToken) =>
{
var result =
await service.AnalyzeAsync(
request.Text,
cancellationToken);
return Results.Ok(result);
});
app.Run();
The returned HTTP JSON is naturally aligned with the C# response DTO.
Structured Output with ASP.NET Core Controllers
The same architecture works with MVC:
[ApiController]
[Route("api/tickets")]
public sealed class TicketsController
: ControllerBase
{
private readonly ITicketAnalysisService _service;
public TicketsController(
ITicketAnalysisService service)
{
_service = service;
}
[HttpPost("analyze")]
public async Task<ActionResult<TicketAnalysis>>
Analyze(
AnalyzeRequest request,
CancellationToken cancellationToken)
{
var result =
await _service.AnalyzeAsync(
request.Text,
cancellationToken);
return Ok(result);
}
}
The controller remains simple because structured AI processing lives in the service layer.
Structured Output and Blazor
Structured responses are also useful on the client side.
The server can return:
{
"category": "Technical",
"priority": "High",
"summary": "The application crashes during startup.",
"suggestedAction": "Collect startup logs."
}
Blazor can deserialize the API response directly:
var analysis =
await Http.GetFromJsonAsync<TicketAnalysis>(
"/api/tickets/analyze");
This creates an end-to-end contract:
AI
|
v
TicketAnalysis
|
v
ASP.NET Core API
|
v
JSON
|
v
Blazor
|
v
TicketAnalysis
Structured Output with Entity Framework Core
Do not use AI output directly as an EF Core entity.
Instead:
AI Output DTO
|
v
Validation
|
v
Domain Model
|
v
EF Core
Example:
var ticket =
new SupportTicket
{
Category = analysis.Category,
Priority = analysis.Priority,
Summary = analysis.Summary
};
db.SupportTickets.Add(ticket);
await db.SaveChangesAsync(
cancellationToken);
This lets the application decide which fields are allowed to enter the database.
Structured Output and Dapper
The same architecture works with Dapper:
var sql = """
INSERT INTO SupportTickets
(
Category,
Priority,
Summary
)
VALUES
(
@Category,
@Priority,
@Summary
);
""";
await connection.ExecuteAsync(
sql,
new
{
Category = analysis.Category,
Priority = analysis.Priority,
Summary = analysis.Summary
});
The AI never constructs the SQL statement itself.
Structured Output and Message Queues
Structured AI results also work well with asynchronous processing.
Document Upload
|
v
Background Worker
|
v
AI Extraction
|
v
InvoiceResult
|
v
Message Queue
|
+---- Accounting Service
+---- Reporting Service
+---- Storage Service
For example:
{
"invoiceNumber": "INV-1005",
"supplierName": "Example Supplier",
"total": 12500.00
}
This can become a message contract shared by services.
Structured Output and Background Services
Example:
public sealed class DocumentProcessingWorker
: BackgroundService
{
private readonly IChatClient _chatClient;
public DocumentProcessingWorker(
IChatClient chatClient)
{
_chatClient = chatClient;
}
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
// Read next document.
var response =
await _chatClient.GetResponseAsync<
InvoiceResult>(
"Extract invoice information...",
cancellationToken:
stoppingToken);
if (response.TryGetResult(
out InvoiceResult? result))
{
await ProcessAsync(
result,
stoppingToken);
}
}
}
private static Task ProcessAsync(
InvoiceResult result,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
The structured result makes background processing deterministic at the application boundary.
Structured Output and Caching
Structured responses can be cached more easily than free-form text when the request contract is stable.
Cache key:
prompt version
+
model configuration
+
input hash
+
schema version
Example:
TicketAnalysis
Prompt v3
Schema v2
Input Hash A87F...
Then:
Cache
|
+-- hit -> TicketAnalysis
|
+-- miss -> AI Model
Be careful with sensitive data when caching.
Structured Output and Cost Tracking
Because ChatResponse<T> inherits usage information, the application can record:
InputTokens
OutputTokens
TotalTokens
alongside:
Schema
PromptVersion
ModelId
OperationName
This allows analysis such as:
TicketAnalysis
Average input tokens: ...
Average output tokens: ...
Parse failure rate: ...
Validation failure rate: ...
Structured Output and Latency
Measure separately:
model latency
deserialization time
validation time
database time
total request time
Example:
AI Model 1,430 ms
Deserialization 2 ms
Validation 1 ms
Database 12 ms
------------------------------
Total 1,445 ms
This helps identify performance bottlenecks.
Structured Output and Model Selection
Not every model or provider supports the same level of structured output.
A model may support:
plain text
another may support:
JSON mode
and another may support:
native JSON Schema structured output
The application therefore needs capability awareness.
Microsoft's current GetResponseAsync<T> documentation explicitly notes that requesting JSON Schema can cause an error if the underlying model does not support it. (learn.microsoft.com)
Capability-Based Design
A production system can maintain:
AI Client
|
+-- Supports JSON
+-- Supports JSON Schema
+-- Supports Tools
+-- Supports Streaming
+-- Supports Vision
The application can choose the appropriate strategy.
For example:
Native JSON Schema
|
+-- use GetResponseAsync<T>
JSON only
|
+-- ChatResponseFormat.Json
Plain text only
|
+-- application-level parsing fallback
The exact fallback should be deliberately designed rather than silently assumed.
Structured Output Fallback Hierarchy
A possible strategy:
1. Native JSON Schema
|
v
2. JSON mode
|
v
3. Controlled textual JSON
|
v
4. Human review / failure
Each lower level provides weaker guarantees.
Do not represent a weak fallback as equivalent to native schema-constrained output.
Complete Production-Style Service
Here is a more complete service:
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.AI;
public sealed class TicketAnalysisService
: ITicketAnalysisService
{
private readonly IChatClient _chatClient;
private readonly ILogger<TicketAnalysisService> _logger;
public TicketAnalysisService(
IChatClient chatClient,
ILogger<TicketAnalysisService> logger)
{
_chatClient = chatClient;
_logger = logger;
}
public async Task<TicketAnalysis> AnalyzeAsync(
string ticketText,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(ticketText))
{
throw new ArgumentException(
"Ticket text is required.",
nameof(ticketText));
}
var messages = new List<ChatMessage>
{
new(
ChatRole.System,
"""
You are a support ticket classifier.
Classify the ticket accurately.
Rules:
- Use only information in the ticket.
- Do not invent facts.
- Select the most appropriate category.
- Assign a priority based on the apparent urgency.
- Keep the summary concise.
"""),
new(
ChatRole.User,
$"Ticket:\n{ticketText}")
};
var response =
await _chatClient.GetResponseAsync<
TicketAnalysis>(
messages,
cancellationToken:
cancellationToken);
if (!response.TryGetResult(
out TicketAnalysis? result))
{
_logger.LogWarning(
"Structured ticket analysis parsing failed.");
throw new InvalidOperationException(
"The AI response was not a valid TicketAnalysis.");
}
Validate(result);
return result;
}
private static void Validate(
TicketAnalysis result)
{
if (string.IsNullOrWhiteSpace(
result.Summary))
{
throw new ValidationException(
"Ticket summary is required.");
}
if (result.Priority ==
TicketPriority.Critical &&
string.IsNullOrWhiteSpace(
result.SuggestedAction))
{
throw new ValidationException(
"Critical tickets require a suggested action.");
}
}
}
This is much closer to a production architecture than simply calling an LLM and parsing arbitrary text.
Complete Structured Output Model
A practical model might be:
public enum TicketCategory
{
Billing,
Delivery,
Account,
Technical,
Other
}
public enum TicketPriority
{
Low,
Medium,
High,
Critical
}
public sealed class TicketAnalysis
{
public TicketCategory Category { get; set; }
public TicketPriority Priority { get; set; }
public string? Summary { get; set; }
public string? SuggestedAction { get; set; }
}
The application now has a strongly typed contract.
Complete Minimal API Example
using Microsoft.Extensions.AI;
var builder =
WebApplication.CreateBuilder(args);
var app =
builder.Build();
app.MapPost(
"/api/tickets/analyze",
async (
AnalyzeRequest request,
IChatClient chatClient,
CancellationToken cancellationToken) =>
{
var messages = new List<ChatMessage>
{
new(
ChatRole.System,
"""
You are a support ticket classifier.
Categorize the ticket and determine its priority.
Do not invent facts.
Provide a concise summary and suggested action.
"""),
new(
ChatRole.User,
$"Ticket:\n{request.Text}")
};
var response =
await chatClient.GetResponseAsync<
TicketAnalysis>(
messages,
cancellationToken:
cancellationToken);
if (!response.TryGetResult(
out TicketAnalysis? result))
{
return Results.Problem(
"AI structured output could not be parsed.");
}
if (string.IsNullOrWhiteSpace(
result.Summary))
{
return Results.Problem(
"AI returned an invalid ticket summary.");
}
return Results.Ok(result);
});
app.Run();
public sealed class AnalyzeRequest
{
public required string Text { get; init; }
}
This produces a clean API contract.
Practical Project: Build an AI Resume Analyzer
Build an ASP.NET Core application that accepts resume text and returns:
Candidate Name
Current Role
Years of Experience
Skills
Companies
Education
Certifications
Define:
public sealed class ResumeAnalysis
{
public string? CandidateName { get; set; }
public string? CurrentRole { get; set; }
public int? YearsOfExperience { get; set; }
public List<string> Skills { get; set; } = [];
public List<string> Companies { get; set; } = [];
public List<string> Education { get; set; } = [];
public List<string> Certifications { get; set; } = [];
}
Architecture:
Resume Text
|
v
Prompt Template
|
v
System Instructions
|
v
IChatClient
|
v
Structured JSON
|
v
ResumeAnalysis
|
v
Validation
|
v
ASP.NET Core API
|
v
JSON Response
Practical Project: Build an AI Invoice Extractor
Input:
invoice text
Output:
public sealed class InvoiceResult
{
public string? InvoiceNumber { get; set; }
public string? SupplierName { get; set; }
public DateTime? InvoiceDate { get; set; }
public decimal? Subtotal { get; set; }
public decimal? Tax { get; set; }
public decimal? Total { get; set; }
public List<InvoiceLine> Items { get; set; } = [];
}
Validation:
invoice number required
supplier required
total >= 0
line quantity >= 0
unit price >= 0
Then save only validated information.
Practical Project: Build an AI Support Router
Input:
"My credit card was charged twice."
Output:
{
"category": "Billing",
"priority": "High",
"summary": "Duplicate charge reported.",
"suggestedAction": "Escalate to billing."
}
Then:
Billing
|
v
Billing Queue
or:
Technical
|
v
Technical Queue
This is a strong example of AI becoming an application component rather than merely a chatbot.
Advanced Project: Build a Structured AI Workflow
Create:
AI Workflow
|
+-- Classify
|
+-- Extract
|
+-- Validate
|
+-- Route
|
+-- Execute
|
+-- Store
For example:
Customer Message
|
v
TicketAnalysis
|
v
Business Validation
|
v
Routing Decision
|
+---- Billing
+---- Delivery
+---- Technical
+---- Account
|
v
Queue / Workflow
Each step should have an explicit typed contract.
Common Mistakes
Asking for JSON without defining a contract
Bad:
Return JSON.
Better:
Return JSON matching the application schema.
Best:
GetResponseAsync<T>()
with a suitable structured-output-capable model/client.
Parsing JSON with regular expressions
Avoid:
Regex.Match(
response.Text,
"\"category\":\"(.*?)\"");
Use JSON serialization or the typed structured-output API.
Trusting structured data without validation
A valid JSON object may still contain incorrect values.
Using AI output directly as a database entity
Use DTOs and mapping.
Using AI output as an authorization source
Authorization must come from trusted application systems.
Assuming every model supports JSON Schema
The current Microsoft API explicitly notes that JSON Schema support depends on the underlying client/model. (learn.microsoft.com)
Returning primitive top-level schemas without checking provider requirements
Some structured-output services require an object at the top level. Use a wrapper object when necessary. (learn.microsoft.com)
Using Result without considering parse failures
Microsoft documents that Result throws when JSON is missing or deserialization fails. Use TryGetResult when you need controlled failure handling. (learn.microsoft.com)
Treating schema compliance as factual correctness
Schema validation answers:
"Does the data have the expected shape?"
It does not automatically answer:
"Is the data true?"
Frequently Asked Questions
What is a structured AI response?
A structured AI response is model output designed to match a predefined schema or .NET type instead of returning unrestricted natural language.
Why use structured output?
It makes AI results easier to deserialize, validate, store, route, and process programmatically.
What is GetResponseAsync<T>?
It is a Microsoft.Extensions.AI extension method that requests a structured response matching the specified .NET type and returns ChatResponse<T>. (learn.microsoft.com)
What is ChatResponse<T>?
It is the typed response wrapper returned for structured output. It contains the typed Result, original Text, usage information, and other response metadata. (learn.microsoft.com)
What is TryGetResult?
It attempts to deserialize the response into T and returns false if that cannot be done. (learn.microsoft.com)
What is ChatResponseFormat.Json?
It represents a JSON response format without a particular schema. (learn.microsoft.com)
What is ChatResponseFormat.ForJsonSchema<T>?
It creates a schema-based structured JSON response format using a .NET type. (learn.microsoft.com)
Does GetResponseAsync<T> automatically use JSON Schema?
The current API has a useJsonSchemaResponseFormat parameter, whose documented default is true. Microsoft notes that JSON Schema can improve reliability with clients/models that support native structured output, but it may cause an error when the model does not support it. (learn.microsoft.com)
Can I disable schema-based output?
Yes:
var response =
await chatClient.GetResponseAsync<TicketAnalysis>(
messages,
options: null,
useJsonSchemaResponseFormat: false,
cancellationToken: cancellationToken);
This is primarily a compatibility option.
Can structured output return enums?
Yes. Enums are useful for controlled classification values. Microsoft's current structured-output quickstart demonstrates a custom sentiment enum. (learn.microsoft.com)
Can structured output contain nested objects?
Yes. A C# type can contain other classes and collections.
Can structured output contain arrays?
Yes, although some providers have requirements around the top-level schema. Microsoft recommends an object wrapper when an array would otherwise be the top-level schema. (learn.microsoft.com)
Can structured output contain nullable values?
Yes. Nullable C# properties are often useful when the source information may be unavailable.
Can structured output be used with RAG?
Yes. A RAG application can return a structured object containing an answer, citations, confidence metadata, extracted entities, or other application-specific data.
Can structured output be used with AI agents?
Yes. Agents can return structured decisions such as actions, tool choices, workflow states, or extracted parameters.
Does structured output guarantee correctness?
No. It primarily improves structural consistency. The application still needs validation and appropriate domain checks.
Can structured output be used with ASP.NET Core?
Yes. A typed structured response can be returned directly from a controller or minimal API after application validation.
Can structured output be stored in SQL Server?
Yes, after validation and mapping to domain/database models.
Can structured output be streamed?
The base IChatClient supports streaming, but the current typed structured-output convenience APIs documented here are GetResponseAsync<T> methods rather than typed streaming methods. For applications needing incremental UI output, consider whether the final structured object is more important than token-by-token streaming for that particular operation. (learn.microsoft.com, learn.microsoft.com)
Should I use ChatResponseFormat.Json or JSON Schema?
Use JSON mode when you need JSON but do not require one exact schema.
Use JSON Schema when the downstream application expects a defined contract.
Should I deserialize directly into EF Core entities?
No. Prefer an AI response DTO, validation, mapping, and then persistence.
Interview Questions
What is structured output in AI?
Structured output is AI-generated data constrained to a predefined JSON structure or schema instead of unrestricted natural language.
Why is structured output important in .NET?
It allows AI responses to become strongly typed application data.
What is the difference between JSON mode and JSON Schema?
JSON mode requests JSON without defining a specific structure. JSON Schema defines the expected data shape.
What is ChatResponse<T>?
It is the typed response wrapper returned by the current Microsoft structured-output extensions.
Why use TryGetResult instead of Result?
TryGetResult allows the application to handle deserialization failure without the Result property throwing. Microsoft explicitly documents this behavior. (learn.microsoft.com)
What is ChatResponseFormat.ForJsonSchema<T>?
It creates a schema-based JSON response format from a .NET type. (learn.microsoft.com)
What does useJsonSchemaResponseFormat do?
It controls whether the typed structured-output extension requests a JSON Schema response format. Its documented default is true. (learn.microsoft.com)
Why validate structured AI output after deserialization?
Because schema correctness does not guarantee domain correctness.
Why use enums in structured AI responses?
Enums limit values to a controlled set and make downstream application logic simpler.
Why use wrapper objects?
Some services require top-level JSON Schema objects. Wrapping primitive or collection results inside a class provides an object-shaped schema. (learn.microsoft.com)
Should AI output directly update a database?
No. Validate and map the structured DTO before persistence.
Can structured output drive workflows?
Yes. A typed AI result can drive routing, queueing, classification, extraction, or other business workflows.
What happens when the model doesn't support JSON Schema?
The native schema request may fail. The application needs a deliberate compatibility or fallback strategy. Microsoft explicitly documents this compatibility consideration for GetResponseAsync<T>. (learn.microsoft.com)
Is structured output the same as guaranteed JSON correctness?
No. It is a mechanism for requesting and processing structured results, not an unconditional guarantee that every model will obey the schema.
Best Practices
Define output contracts as C# types
Prefer:
TicketAnalysis
over:
Dictionary<string, object>
when the structure is known.
Use enums for controlled values
Prefer:
TicketPriority.High
over:
"maybe-high"
Use wrapper objects for top-level collections
Prefer:
public sealed class SkillsResult
{
public List<string> Skills { get; set; } = [];
}
when the provider expects an object schema.
Validate every structured result
Use:
structural validation
+
business validation
Separate AI DTOs from persistence entities
Use:
AI DTO
-> validation
-> domain model
-> database
Use TryGetResult for controlled failures
Do not assume parsing will always succeed.
Keep prompts and schemas consistent
If the prompt asks for:
category
priority
summary
the output contract should represent those same concepts.
Version output contracts
Treat breaking schema changes as contract changes.
Log schema and prompt metadata
Record:
prompt version
schema version
model ID
parse success
validation result
usage
Do not trust AI-generated security claims
Authentication and authorization remain application responsibilities.
Prefer native schema support
When the configured AI client/model supports native structured output, use it instead of relying only on textual instructions.
Keep schemas reasonably focused
A huge schema can become difficult for both the application and model to handle.
Minimize sensitive data
Structured output often encourages persistence. Make sure the extracted information actually needs to be stored.
Learning Path
The progression through structured AI development is:
1. Plain text AI responses
|
v
2. JSON responses
|
v
3. JSON mode
|
v
4. JSON Schema
|
v
5. C# response types
|
v
6. GetResponseAsync<T>
|
v
7. TryGetResult
|
v
8. Business validation
|
v
9. ASP.NET Core APIs
|
v
10. RAG structured outputs
|
v
11. Agent decisions
|
v
12. Structured AI workflows
Key Takeaways
Structured AI responses turn model output into application data.
The simplest approach is:
var response =
await chatClient.GetResponseAsync<MyType>(
prompt);
The returned object is:
ChatResponse<MyType>
and provides:
Result
Text
Usage
ModelId
FinishReason
Current Microsoft structured-output extensions also provide TryGetResult so applications can safely handle responses that cannot be parsed as the requested type. (learn.microsoft.com, learn.microsoft.com)
The three most important concepts are:
JSON
=
structured data without a fixed schema
JSON Schema
=
defined structure and constraints
C# Type
=
strongly typed application contract
A production AI pipeline should be:
Prompt
|
v
AI Model
|
v
Structured Output
|
v
Deserialization
|
v
Structural Validation
|
v
Business Validation
|
v
Domain Logic
|
v
Persistence / API / Workflow
The biggest mistake is assuming that structured output automatically means correct output.
A response can be:
valid JSON
+
valid schema
+
wrong business information
Therefore:
Structured Output
+
Validation
+
Domain Rules
+
Application Security
is the correct production mindset.
This becomes the foundation for the next topic, JSON Responses from AI Models with .NET, where the focus moves deeper into JSON formatting, JSON serialization, JSON Schema, deserialization, validation, and reliable JSON-based AI APIs.

Post a Comment