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

Search for a command to run...

No comments yet. Be the first to comment.
The architectural patterns of Series 1-3, rebuilt on the .NET runtime. Semantic Kernel, Microsoft.Extensions.AI, hosted agents, streaming.
You bet a year of roadmap on Semantic Kernel. Then Microsoft renamed the bet. Here is what actually changed, what did not, and why the architects who shrugged are the ones who read the contract instead of the brand.
Your senior dev has spent three days hand rolling wrapper classes for logging, caching, and provider switching around the OpenAI SDK. Microsoft already shipped that, called it boring, and put it in the box with the rest of .NET. Surprise.

You bet a year of roadmap on Semantic Kernel. Then Microsoft renamed the bet. Here is what actually changed, what did not, and why the architects who shrugged are the ones who read the contract instead of the brand.

A recursive agent spent the weekend asking itself the same question, failing the same check, and billing you for both. No error fired, because no metric you watched had the word stop in it. This article is about putting it there.

The dashboards are healthy, the error rate is zero, nothing shipped this week, and the model just told a customer something expensive and wrong. There is no commit to revert. Welcome to the incident that classical on call was never designed for.

🗓️ 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.
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.
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.
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.
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 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:
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.
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.
[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.
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:
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.
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.
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.
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.
🗓️ 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.
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.
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.
| # | 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 |