# Structured Outputs in C# — Schema, Records, and the End of String-Parsing

> 🗓️ **Last updated: August 2026**

The architect inherits a brownfield .NET 10 service that classifies incoming customer-support emails into six ticket categories, extracts the order number if there is one, and routes the result to a queue. The classifier has been working for two years. It is built on top of a chat completion call that asks the model to "respond with the category and the order number." The application code parses the model's reply with a regex. The regex was six lines when the service shipped; it is now sixty-four. The product team has just added eight new email formats and two new categories. The team's senior dev has been writing new regex branches for two days and the parse function is starting to look like a small programming language of its own.

This is the recurring shape of a .NET LLM codebase that learned to talk to models before structured outputs existed. The model is asked to "respond in JSON" and the application code parses the reply on a best-effort basis. The parse function grows quietly until the day a new format silently breaks a downstream queue and someone has to read sixty-four lines of regex to find out why.

The answer is to stop asking the model to respond in JSON and start asking it to respond as a typed record. The mechanism is structured outputs — a contract negotiated between the application and the model, expressed as a JSON schema generated from a C# type, enforced by the provider at decode time, deserialised back into the same C# type at the boundary. The application code never parses a string.

## What this article is NOT

This is not a JSON tutorial. It is not a comparison of `System.Text.Json` and Newtonsoft.Json — the answer in 2026 is `System.Text.Json` with a source generator, and the discussion is closed. It is not a defence of string-parsing model responses as a stopgap; if you are still doing that in 2026, the technical debt is compounding faster than the team can pay it down.

It is an architect's read on why structured outputs are the contract surface between a .NET service and a model, and how to design that contract so it earns its place in the dependency graph.

## Thesis

A model call that returns free text is a function with a stringly-typed return value. A model call that returns a structured output is a function with a strongly-typed return value. The first is the API design that 2010-era PHP shipped on. The second is the API design that ASP.NET Core's typed minimal-API endpoints ship on. The same architectural argument that won the HTTP layer applies to the model layer. The C# version of structured outputs is a record on the consumer side, a source-generated `JsonSerializerContext` for the serialisation pass, and the `Microsoft.Extensions.AI` `GetResponseAsync<T>` extension on top of the provider's structured-output surface. Designed well, the model becomes a pluggable backend behind a typed contract. Designed poorly, the typed contract becomes a stricter version of the stringly-typed mess it replaced.

## What the classical .NET version looked like

The pre-2024 pattern was a chat completion call with a system prompt that said "respond only in JSON with the following fields," followed by a `JsonSerializer.Deserialize<T>` call that hoped for the best. The failure modes were familiar to anyone who shipped that code. The model wrapped the JSON in a markdown code fence and the deserialiser threw on the leading backticks. The model added an explanatory sentence before the JSON and the parse failed. The model returned `null` for a non-nullable property and the deserialiser threw. The model returned a number as a string. The model returned an extra field nobody asked for. Each failure mode produced a retry loop, a fallback heuristic, or a regex preprocessor that survived until the next model upgrade silently changed its output shape.

The "respond only in JSON" contract was a convention. The model was free to honour it or not. The application code carried the cost of every deviation.

OpenAI shipped strict structured outputs on August 6, 2024, with `gpt-4o-2024-08-06`. The contract changed from convention to constraint. The model now receives the JSON schema as part of the request and the provider's decode path is guaranteed to emit only tokens that conform to the schema. A response either matches the schema or the response is a programmatically detectable refusal. There is no third option. Other major providers shipped equivalent contracts over the following twelve months. As of mid-2026, structured outputs are the assumed default for any production .NET LLM service that ingests model output into a typed downstream system.

## The four moving parts

A structured-output call on .NET has four moving parts. Getting each of them right separately is what makes the whole thing a typed contract instead of a stricter version of the old mess.

**The contract type.** A C# record that names every field the application needs from the model. Records win because they are immutable by default, they generate value-based equality for free, and they read at the call-site as data rather than as classes. The record is the contract — what the model returns and what the application consumes are the same shape.

**The JSON schema.** Generated from the contract type. On .NET 9+ this is `System.Text.Json.Schema.JsonSchemaExporter` for general use, and `Microsoft.Extensions.AI.AIJsonUtilities.CreateJsonSchema` for the schemas the model layer actually needs — the latter applies the conventions the providers expect, including required-by-default property handling and provider-aware refinements. The schema is generated, never hand-written. Hand-written schemas drift from the C# type the moment the type changes.

**The response format.** The wire-level contract sent to the provider. On OpenAI's Chat Completions API the shape is `response_format: { type: "json_schema", json_schema: { name, schema, strict: true } }` — the schema and the strict flag together turn the request from a hint into a constraint. On OpenAI's Responses API the equivalent lives under `text: { format: { type: "json_schema", name, schema, strict: true } }`. On Anthropic, the same effect is achieved through the tool-use surface — define a tool whose input schema is the contract, instruct the model to call it, read the tool-call arguments as the structured output. On Microsoft.Extensions.AI, every one of these provider-specific shapes hides behind a single `ChatOptions.ResponseFormat` setting backed by `ChatResponseFormat.ForJsonSchema(schema)`.

**The deserialisation.** The decode-time round-trip from the model's JSON output back to the C# record. The choice that matters at this step is whether the deserialisation runs through reflection (the default) or through a source-generated `JsonSerializerContext` (AOT-safe, trim-safe, faster). For any .NET service that ships on Native AOT or runs under aggressive trimming — and every new .NET 10 service should at least consider both — source-generated serialisation is the only correct answer.

## The Microsoft.Extensions.AI typed surface

The convenience method that ties the four moving parts together is `GetResponseAsync<T>` — an extension method on `IChatClient` exposed by `ChatClientStructuredOutputExtensions`. The signature reads as if the model layer were just another typed RPC:

```csharp
public record OrderInfo(
    [property: Description("The numeric order ID extracted from the email, if present.")]
    string? OrderId,
    [property: Description("The category the email belongs to.")]
    TicketCategory Category,
    [property: Description("A short one-line summary of what the customer is asking for.")]
    string Summary);

public enum TicketCategory
{
    OrderStatus,
    Refund,
    ShippingDelay,
    ProductQuestion,
    AccountAccess,
    Other
}

ChatResponse<OrderInfo> response =
    await chatClient.GetResponseAsync<OrderInfo>(
        new ChatMessage(ChatRole.User, emailBody));

if (response.TryGetResult(out OrderInfo? info))
{
    await ticketQueue.EnqueueAsync(info);
}
else
{
    logger.LogWarning(
        "Model declined or returned unparseable output. Raw text: {Text}",
        response.Text);
}
```

What is happening underneath: the extension method generates a JSON schema from `OrderInfo` via `AIJsonUtilities.CreateJsonSchema`, attaches it as `ChatOptions.ResponseFormat` via `ChatResponseFormat.ForJsonSchema`, sends the request through whatever middleware the pipeline composed, receives the model's JSON output, deserialises it into `OrderInfo`, and returns a `ChatResponse<OrderInfo>` whose `TryGetResult` pattern hands the application either the typed value or a fallback path that exposes the raw text. The application code reads as if the model were a function. That is the entire architectural point.

The `[property: Description]` attributes on the record's positional parameters become part of the generated schema and are visible to the model at request time. The `property:` target is load-bearing — a bare `[Description("...")]` on a positional parameter binds to the constructor parameter, which reflection-based schema generators do not read. The `property:` target binds the attribute to the generated property, which they do. A well-described record produces a model response that matches the application's intent. A record with no descriptions produces a model response that matches the field names and hopes for the best. Descriptions are not decoration; they are the prompt's contribution to the contract.

## Source-generated JSON and the AOT question

Reflection-based serialisation works. It has worked since .NET Core 3.0. It will continue to work. But it is incompatible with Native AOT and increasingly fragile under trimming, and both deployment modes are growing as a share of new .NET 10 services. A service that ships on Native AOT cannot afford to discover at runtime that the deserialiser cannot construct a contract type without reflection. The fix is `JsonSerializerContext` and the source generator.

```csharp
[JsonSourceGenerationOptions(WriteIndented = false)]
[JsonSerializable(typeof(OrderInfo))]
[JsonSerializable(typeof(TicketCategory))]
internal partial class TicketJsonContext : JsonSerializerContext
{
}
```

The partial class is empty; the source generator emits the bodies. The application can then pass `TicketJsonContext.Default` into the `JsonSerializerOptions` it uses anywhere the typed structured-output deserialisation runs. M.E.AI's typed extensions accept a `JsonSerializerOptions` for exactly this reason; the AOT-safe path is one constructor call away. The contract type and the JSON context live next to each other in the codebase, evolve together, and the source generator catches the case where a contract field is added without the context being regenerated.

The architectural rule that earns this its place: any C# type that crosses the model boundary in either direction belongs in a `JsonSerializerContext`. The cost is one attribute. The benefit is permanent AOT-safety on that surface.

## When you need finer control than the typed extension gives

`GetResponseAsync<T>` is the right answer for the eighty-percent case. For the remaining twenty — agent-loop call-sites where the schema needs custom names, response formats where the schema description carries domain meaning the record name cannot, or scenarios where the same schema is used across multiple call-sites and the caller wants to register it once and reuse it — the lower-level surface is `ChatResponseFormat.ForJsonSchema`:

```csharp
JsonElement schema = AIJsonUtilities.CreateJsonSchema(typeof(OrderInfo));

var options = new ChatOptions
{
    ResponseFormat = ChatResponseFormat.ForJsonSchema(
        schema: schema,
        schemaName: "order_info",
        schemaDescription: "Structured extraction of order details from a support email.")
};

ChatResponse response = await chatClient.GetResponseAsync(
    new ChatMessage(ChatRole.User, emailBody),
    options);
```

The deserialisation is now the application's responsibility — `JsonSerializer.Deserialize<OrderInfo>(response.Text, TicketJsonContext.Default.OrderInfo)`. The cost is one extra line. The benefit is full control over the schema name and description the provider sees in the request. For most call-sites this is overkill; for the ones where the schema name shows up in the provider's telemetry and the team cares about the readability of that telemetry, the explicit form earns its keep.

## Refusals — when the model says no

Strict structured outputs introduced a programmatic refusal path. The model can decline to produce output when the request would violate its safety policy, when the prompt is ambiguous, or when the schema cannot be satisfied from the available context. The refusal arrives as a structured response, not as an exception, and the application is expected to detect it and handle it.

On Microsoft.Extensions.AI the refusal manifests as `TryGetResult` returning `false` while `response.Text` carries the model's refusal message. The application code's responsibility is to distinguish refusal from parse failure — both produce the same `TryGetResult` outcome — and to route each to the correct fallback. A refusal on an order-extraction call probably means the email contained content the model would not engage with, and the right answer is to escalate to a human queue. A parse failure on the same call usually means a transient provider issue, and the right answer is to retry with backoff. Conflating the two produces silent escalations on transient errors and silent retries on safety refusals — neither is acceptable in production.

The architectural pattern: every structured-output call-site has an explicit refusal path that is observable in telemetry, distinct from the success path, and routed to a queue that humans actually read. The refusal is part of the contract. Treating it as an error is a category mistake.

## The Responses API note

OpenAI introduced the Responses API as the recommended surface for new code, with the Chat Completions API entering long-term support. The wire shape moves `response_format` under `text.format`, the streaming model becomes the default, and the multi-turn state is handled server-side. None of this changes how a .NET application written against `IChatClient` looks. M.E.AI's OpenAI adapter wraps whichever underlying surface the SDK uses, and the application code remains untouched. The architect's discipline is to verify, at adapter-pinning time, that the adapter version in use targets the surface the team has chosen — Chat Completions for stability today, Responses for forward-compatibility tomorrow — and to write down which surface the service is built against in the same place the rest of the dependency choices live.

## Failure modes, severity-ordered

The patterns that turn a healthy structured-output adoption into a fragile one, worst first.

**1. Treating refusals as exceptions.** A `try`/`catch` wrapping the typed call that logs the refusal as an error and retries on backoff produces silent safety-policy bypass attempts, a corrupted telemetry signal, and eventually an account-level rate limit. Mitigation: refusal handling is explicit, structurally distinct from parse failure, and instrumented separately.

**2. Schema drift between the record and the prompt.** Adding a field to the record without updating the system prompt produces a schema-compliant response that has nothing useful in the new field. Mitigation: the system prompt references the record's purpose and lets the schema express the field-level contract; new fields land in both places in the same commit.

**3. Reflection-based serialisation on an AOT service.** Works in development, fails at first request in production. Mitigation: every contract type lives in a `JsonSerializerContext`; the project disables reflection-based serialisation in the production configuration.

**4. Over-rich contract types.** A record with thirty fields and three levels of nesting overwhelms the model's schema-adherence budget on smaller models and silently degrades extraction quality. Mitigation: contract types are flat where possible, narrow in scope, and the architect resists the temptation to overload the schema with everything the call-site might one day want.

**5. Catch-all `string` properties.** A field declared as `string?` that should be an enum, a date, or an integer pushes the parse problem from compile time to runtime. Mitigation: enums for closed sets, `DateTimeOffset` for timestamps, numeric types for numbers. The compile-time contract is the strongest contract.

**6. Schema strict mode disabled.** Disabling `strict: true` because "the model gets confused" is debugging in the wrong layer. Mitigation: keep strict on; the confusion is a prompt or contract issue, not a strictness issue.

**7. Mixing structured outputs and free-text in the same call.** A request that asks for a structured object and also a friendly summary produces neither cleanly. Mitigation: structured calls produce structured output, period. Conversational calls produce text. Separate concerns, separate call-sites.

## The architect's checklist

🗓️ Every model-to-application data hand-off is expressed as a C# record contract type with `[property: Description]` attributes on each positional parameter.

🗓️ Schemas are generated from the contract type — never hand-written — via `AIJsonUtilities.CreateJsonSchema` or the M.E.AI typed extension's built-in path.

🗓️ The application uses `chatClient.GetResponseAsync<T>` for the typed eighty-percent case, and `ChatResponseFormat.ForJsonSchema` for the explicit-schema-name twenty-percent case.

🗓️ `TryGetResult` is the access pattern; `response.Text` is the fallback path; refusal and parse failure are handled distinctly.

🗓️ Every contract type appears in a `JsonSerializerContext` partial class, with the source generator enabled.

🗓️ Reflection-based serialisation is not used at the model boundary in production.

🗓️ The system prompt references the schema's purpose and lets the schema enforce the field-level contract; the two evolve in the same commit.

🗓️ Contract types are flat, narrow, and per-call-site; no single record carries the union of every call-site's fields.

🗓️ Enums close the set wherever the model is choosing from a finite list; `string` is not used as a substitute for an enum or a typed value.

🗓️ Refusals are routed to a human queue with structured telemetry, distinct from transient-failure retries.

🗓️ The provider adapter pinning record names the API surface the service targets — Chat Completions or Responses — and the adapter version is reviewed at each major release.

🗓️ Tests at the contract boundary use the generated schema as a fixture; a schema-version change is a test-detected change, not a runtime-discovered one.

## Mental model

Structured outputs are the typed return statement of the model call. The C# record is the return type, the JSON schema is the type signature the provider checks, and the deserialiser is the implicit conversion from wire format back to the type. The application code reads as if the model were a method. The contract is the type. The type is the contract.

The architect who treats structured outputs as "JSON mode but stricter" misses the design centre. The architect who treats them as "the model's return type, expressed in C# and enforced at the provider" misses nothing.

String-parsing model responses was a workaround. Structured outputs are the design.

## What's next

Article 4 — **Hosting Long-Running Agents — IHostedService, BackgroundService, and the Worker Lifecycle** — drops one altitude lower. An agent loop that takes minutes to run, a queue of pending tool calls, a server that restarts mid-loop, a checkpoint that survives the restart, an OpenTelemetry trace that spans both halves of the loop. The .NET hosting model already has the primitives. The architect's job is to wire them to the agent's lifecycle without inventing a parallel scheduler. Subscribe at pragmaticstack.hashnode.dev for Friday's drop.

---

## Series 4 progress

| # | Title | Status |
| --- | --- | --- |
| 1 | From Semantic Kernel to Microsoft Agent Framework | Published |
| 2 | Microsoft.Extensions.AI — The Unified Client Layer | Published |
| 3 | Structured Outputs in C# | You are here |
| 4 | Hosting Long-Running Agents | Coming next |
| 5 | Streaming Chat — IAsyncEnumerable and SignalR | Coming soon |
| 6 | A Reference Architecture for the .NET Agentic Service | Coming soon |
