Structured AI Responses with .NET

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.

Structured AI Responses with .NET

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

Previous Post Next Post