1. Real-World Problem Scenario
A few months ago, we were tuning a high-throughput .NET API responsible for ingesting pricing events—nothing exotic, just a stream of relatively small DTOs (~200–400 bytes each). The service itself was straightforward: deserialize request → process → serialize response. On paper, this shouldn’t have been the bottleneck.
But under sustained load (~40–60k requests/sec), things started to fall apart.
CPU was high, but not alarmingly so. The real issue showed up in GC metrics—Gen 0 collections were firing constantly, and under burst traffic, we’d occasionally hit Gen 2 pauses that introduced visible latency spikes (P99 went from ~12ms to ~80ms). That’s where things got interesting.
We profiled the hot path and found something unintuitive: serialization was one of the top allocation contributors.
The code looked harmless:
var json = JsonSerializer.Serialize(order);Code language: C# (cs)This is the kind of line nobody questions in code review. But under the hood, this triggers a reflection-driven pipeline:
Order → Reflection metadata lookup → Property inspection → JsonTypeInfo creation → SerializationCode language: plaintext (plaintext)Even though System.Text.Json caches metadata, the initial graph construction, boxing, and intermediate allocations still add up—especially when multiplied by tens of thousands of requests per second.
We tried the usual fixes:
- Reusing
JsonSerializerOptions - Reducing object churn elsewhere
- Even benchmarking alternative libraries
Nothing meaningfully moved the needle.
The key realization came from allocation profiling—not CPU profiling. The problem wasn’t that serialization was slow, it was that it was allocation-heavy, which indirectly killed throughput via GC pressure.
In other words, we weren’t CPU-bound—we were memory churn bound.
That’s the moment where runtime-based serialization starts to show its limits. If the bottleneck is allocations, the only real fix is to eliminate the runtime work entirely.
Which leads us to the core idea: move serialization metadata generation from runtime → compile time.
2. Understanding the Core Concept
When people hear “zero-allocation serialization,” it’s easy to misinterpret it as some kind of magic where nothing gets allocated. That’s not the goal—and in practice, it’s not even realistic. What we’re actually doing is eliminating avoidable allocations in the serialization pipeline, particularly those caused by runtime type inspection.
The default System.Text.Json pipeline is optimized, but it’s still fundamentally reflection-driven. At some point, it has to walk your type graph, inspect properties, build metadata (JsonTypeInfo), and cache it. That work isn’t free.
With source generators, we shift that entire process to compile time.
Instead of this:
Runtime:
DTO → Reflection → Build metadata → Cache → SerializeCode language: plaintext (plaintext)We get this:
Compile-time:
DTO → Generate metadata + serializers
Runtime:
DTO → Use prebuilt metadata → Serialize directlyCode language: plaintext (plaintext)That shift is where the real gains come from.
At build time, a Roslyn-based source generator scans your DTOs and produces strongly-typed serialization code. This includes:
- Precomputed property mappings
- Optimized accessors (no reflection, no boxing)
- Prebuilt
JsonTypeInfo<T>graphs
So when you call the serializer at runtime, there’s no discovery phase—it already knows exactly how to serialize your type.
Concretely, instead of relying on:
JsonSerializer.Serialize(order);Code language: C# (cs)You use a generated context:
JsonSerializer.Serialize(order, OrderJsonContext.Default.Order);Code language: C# (cs)That second argument is the key—it tells the serializer: “Don’t figure this out. Use this precompiled contract.”
Another important detail: this integrates tightly with Utf8JsonWriter, which writes directly to buffers using Span<byte>. That means you’re not just avoiding reflection—you’re also avoiding unnecessary intermediate representations (like strings).
Where this really starts to matter:
- High-throughput APIs
- Low-latency services
- Serverless cold starts
- Native AOT builds (where reflection can break entirely)
At a high level, you can think of source generators as turning JSON serialization from a dynamic runtime system into a static, predictable pipeline.
And once you remove that dynamism, you unlock both performance and control—which we’ll dissect in detail next.
3. Internal Mechanics / How It Works
To understand why source generation helps so much, you have to stop thinking about JsonSerializer as a single method call and start thinking about it as a metadata pipeline.
In the default path, this:
var json = JsonSerializer.Serialize(order);Code language: C# (cs)is really shorthand for: determine the runtime type, inspect its shape, build or retrieve serialization metadata, resolve converters, and only then write JSON. Much of that gets cached, but the first-hit cost is real, and in large services with many DTOs, “first hit” happens more often than people think.
A simplified version of the runtime path looks like this:
public static string SerializeOrder(Order order, JsonSerializerOptions options)
{
JsonTypeInfo typeInfo = options.GetTypeInfo(typeof(Order)); // May trigger metadata creation
return JsonSerializer.Serialize(order, typeInfo);
}Code language: C# (cs)That GetTypeInfo() step is where the serializer may walk the type graph, inspect properties, evaluate attributes, resolve naming policies, and wire up converters. It is efficient by general-purpose-library standards, but it is still dynamic infrastructure, and dynamic infrastructure allocates.
Source generation removes that discovery phase by emitting a strongly typed context at build time:
using System.Text.Json.Serialization;
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(OrderLine))]
[JsonSerializable(typeof(List<Order>))]
internal partial class OrderJsonContext : JsonSerializerContext
{
}Code language: C# (cs)This is not just configuration sugar. In production, this is how you make the serializer contract explicit. The generator produces JsonTypeInfo<Order>, JsonTypeInfo<OrderLine>, and the supporting metadata graph up front, so runtime no longer needs to discover the shape of those objects.
Then you consume that prebuilt metadata directly:
var json = JsonSerializer.Serialize(order, OrderJsonContext.Default.Order);
var ordersJson = JsonSerializer.Serialize(orders, OrderJsonContext.Default.ListOrder);Code language: C# (cs)The trade-off is obvious and worth stating plainly: you lose some of the convenience of “serialize anything” APIs in exchange for predictability. In small apps, that may feel like boilerplate. In hot paths, it is exactly the kind of boilerplate you want, because it makes reflection fallback much harder to introduce by accident.
The next layer is where the allocation story gets better: writing directly to UTF-8 buffers instead of materializing JSON strings.
using System.Buffers;
using System.Text.Json;
var buffer = new ArrayBufferWriter<byte>(256);
using (var writer = new Utf8JsonWriter(buffer))
{
JsonSerializer.Serialize(writer, order, OrderJsonContext.Default.Order);
}
ReadOnlySpan<byte> payload = buffer.WrittenSpan;
// Send payload to network stream, pipe, or response bodyCode language: C# (cs)This pattern matters in production for two reasons.
First, string is often the wrong output type for service-to-service communication. If the next step is an HTTP response body, a pipe, Kafka producer, or socket write, creating a UTF-16 string only to re-encode it to UTF-8 is wasted work.
Second, Utf8JsonWriter gives you tighter control over buffering. Combined with pooled buffers or response streams, it lets you keep the hot path closer to raw bytes and farther away from avoidable heap churn.
The main trade-off here is complexity. A direct-to-buffer pipeline is less ergonomic than Serialize(obj), and you need to manage lifetimes and buffer growth carefully. But that is exactly why high-throughput services use it: fewer abstractions in the hot path usually means fewer surprises under load.
So the production pattern is not just “use source generators.” It is:
DTO contract known at compile time
↓
Generated JsonTypeInfo<T>
↓
Utf8JsonWriter writes UTF-8 directly
↓
Fewer allocations, less GC pressure, more predictable latencyCode language: C# (cs)That predictability is the real win. Once serialization stops doing runtime discovery, performance becomes much easier to reason about—which makes the implementation phase much more deliberate.
4. Step-by-Step Implementation
The easiest way to get source generation wrong is to treat it like a cosmetic optimization: add a context class, keep the rest of the code the same, and assume the runtime will somehow do the right thing. In production, that usually leads to a half-migrated setup where some DTOs use generated metadata and others quietly fall back to reflection.
The implementation needs to be deliberate.
4.1 Start with the baseline
A typical service begins here:
using System.Text.Json;
public static class OrderResponseWriter
{
public static string Write(OrderResponse response)
{
return JsonSerializer.Serialize(response);
}
}
public sealed class OrderResponse
{
public string OrderId { get; init; } = default!;
public decimal Total { get; init; }
public string Currency { get; init; } = default!;
public List<OrderLine> Lines { get; init; } = new();
}
public sealed class OrderLine
{
public string Sku { get; init; } = default!;
public int Quantity { get; init; }
public decimal UnitPrice { get; init; }
}Code language: C# (cs)This is fine for internal tools, low-volume endpoints, and prototypes. The problem is that the serialization contract is implicit. The runtime has to infer everything from the CLR type, which means metadata discovery, converter resolution, and cache population all happen dynamically.
That dynamic behavior is exactly what makes the API convenient—and exactly what makes it harder to reason about in hot paths.
4.2 Make the serialization contract explicit
The first real step is defining a context that lists every type you intend to serialize on the hot path.
using System.Text.Json.Serialization;
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
GenerationMode = JsonSourceGenerationMode.Serialization)]
[JsonSerializable(typeof(OrderResponse))]
[JsonSerializable(typeof(OrderLine))]
[JsonSerializable(typeof(List<OrderLine>))]
internal partial class OrderJsonContext : JsonSerializerContext
{
}Code language: C# (cs)A few important things are happening here.
[JsonSerializable] is the contract declaration. It tells the generator which types must have metadata generated at build time. If you forget a type, that type is not part of the generated graph, and you risk fallback behavior later.
GenerationMode = JsonSourceGenerationMode.Serialization is also worth calling out. If your service only writes JSON and never reads it back, there is no reason to generate more machinery than necessary. In production systems, being explicit about scope matters. Smaller generated output means less code, less noise, and slightly less build-time overhead.
At this point, the generated code is build output, not something you hand-maintain. That is exactly why this is production-friendly: the serializer contract becomes reviewable and intentional without forcing you to manually write serializers.
4.3 Use the generated overloads, not the convenient ones
This is the part teams often miss. Defining a context does nothing unless you actually call the serializer with it.
using System.Text.Json;
public static class OrderResponseWriter
{
public static string Write(OrderResponse response)
{
return JsonSerializer.Serialize(
response,
OrderJsonContext.Default.OrderResponse);
}
}Code language: C# (cs)This overload matters because it bypasses runtime type discovery and uses the prebuilt JsonTypeInfo<OrderResponse> directly.
That is not just a stylistic preference. In code review, this is the difference between:
- “we configured source generation”
- and
- “we are definitely using source generation on this path”
The trade-off is verbosity. JsonSerializer.Serialize(response) is cleaner to look at. But production performance work is full of cases where the shorter API is not the better API. Here, the explicit overload buys you correctness and predictability.
4.4 Stop allocating strings when you only need bytes
In real services, JSON usually does not end its life as a string. It gets written to an HTTP response, a broker, a stream, or a pipeline. Creating a string first often means:
- build UTF-16 text in memory
- re-encode it to UTF-8
- write the bytes somewhere else
That round trip is unnecessary.
A more realistic production path writes directly to a byte buffer:
using System.Buffers;
using System.Text.Json;
public static class OrderResponseBufferWriter
{
public static ReadOnlyMemory<byte> Write(OrderResponse response)
{
var buffer = new ArrayBufferWriter<byte>(512);
using (var jsonWriter = new Utf8JsonWriter(buffer))
{
JsonSerializer.Serialize(
jsonWriter,
response,
OrderJsonContext.Default.OrderResponse);
}
return buffer.WrittenMemory;
}
}Code language: C# (cs)This pattern is common in services that care about throughput because it keeps the pipeline in UTF-8 from start to finish. No intermediate string, no second encoding step, and much better alignment with how ASP.NET Core, pipelines, and network APIs already operate.
The trade-off is lifecycle management. Returning ReadOnlyMemory<byte> is safe here because ArrayBufferWriter<byte> owns the memory for the lifetime of the returned object, but if you start pooling buffers aggressively, ownership gets trickier. This is exactly why “zero-allocation” code needs discipline: once you optimize the hot path, memory lifetime becomes part of your design.
4.5 Write straight to a stream in server code
If the destination is already a stream, skip the intermediate buffer too.
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
public static class OrderResponseStreamWriter
{
public static async Task WriteAsync(
Stream output,
OrderResponse response,
CancellationToken cancellationToken = default)
{
await JsonSerializer.SerializeAsync(
output,
response,
OrderJsonContext.Default.OrderResponse,
cancellationToken);
}
}Code language: C# (cs)This is the pattern you want in HTTP handlers, background exporters, or file-based batch jobs. It minimizes intermediate allocations and lets the runtime write bytes directly to the output stream.
Why this is used in production:
- fewer copies
- better memory behavior under concurrency
- simpler integration with response bodies and file streams
The trade-off is that once you stream directly, you lose some flexibility for post-processing. If you need to inspect, mutate, or log the full payload before sending it, buffering may still be useful. That is a design choice, not a universal rule.
4.6 Integrate it properly in ASP.NET Core
If your service is ASP.NET Core-based, the cleanest way is to register the generated resolver globally so framework serialization also uses it.
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, OrderJsonContext.Default);
});
var app = builder.Build();
app.MapGet("/orders/{id}", (string id) =>
{
return new OrderResponse
{
OrderId = id,
Total = 149.95m,
Currency = "USD",
Lines =
{
new OrderLine { Sku = "KB-001", Quantity = 1, UnitPrice = 99.95m },
new OrderLine { Sku = "MS-002", Quantity = 1, UnitPrice = 50.00m }
}
};
});
app.Run();Code language: C# (cs)This is important because it removes one of the most common production mistakes: manually using source generation in one helper method while MVC/minimal APIs continue serializing other responses through the default reflection-driven configuration.
By inserting your context at the start of the resolver chain, you make the optimized path the first choice. That does not magically cover every type in the application, but it makes your intent explicit and reduces accidental regressions.
The trade-off is governance. Once you centralize the resolver, you need a process for keeping the context updated as new DTOs are introduced. In a fast-moving codebase, a stale context is a real maintenance risk. The upside is that this risk is visible. Reflection fallback is invisible until load testing or production tells you otherwise.
4.7 Cover collections and nested DTOs intentionally
Another easy mistake is assuming that declaring the root type is enough. Sometimes it is, sometimes it is not, and relying on “probably” is how performance regressions sneak in.
Be explicit with frequently used shapes:
[JsonSerializable(typeof(OrderResponse))]
[JsonSerializable(typeof(OrderLine))]
[JsonSerializable(typeof(List<OrderLine>))]
[JsonSerializable(typeof(Dictionary<string, decimal>))]
internal partial class OrderJsonContext : JsonSerializerContext
{
}Code language: C# (cs)Why this matters in production:
- list-heavy payloads are common
- dictionary-based extension fields are common
- nested DTOs often dominate real response shapes
The performance gain is not just raw speed. It is the removal of uncertainty. When a service starts pushing large nested payloads, you do not want to wonder whether a rarely used shape is going through a dynamic path.
4.8 Verify the migration instead of assuming it worked
A production rollout should always verify generated usage. One practical step is enabling generated file emission during development:
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>Code language: HTML, XML (xml)That gives you a way to inspect what was actually produced. You do not need to read every generated file line-by-line, but you should confirm:
- the expected types exist
- the context is being generated
- you are not relying on wishful thinking
That last point matters more than it sounds. Source generation is powerful, but it is not self-enforcing. The production-grade version of this pattern is not “add attributes and hope.” It is:
- define the contract explicitly
- use the generated overloads explicitly
- write to bytes or streams when the hot path justifies it
- register the context centrally where frameworks serialize on your behalf
- verify the result
Once those pieces are in place, the serializer stops being a black box and becomes part of your performance architecture—which is exactly where it should be before we move into a real-world case study.
5. Real-World Implementation Case Study
Let’s return to the kind of system where this actually matters: an event ingestion API that receives pricing updates, enriches them, and republishes them downstream. The service was not doing anything computationally expensive. The pressure came from volume. Tens of thousands of small JSON payloads per second meant serialization overhead was no longer background noise—it was part of the core latency budget.
The original write path looked like this:
public sealed class PricingEventPublisher
{
private readonly HttpClient _httpClient;
public PricingEventPublisher(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task PublishAsync(PricingEvent pricingEvent, CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(pricingEvent);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
await _httpClient.PostAsync("/events/pricing", content, cancellationToken);
}
}Code language: C# (cs)This is normal application code. It is also doing more work than it appears:
- allocate JSON as a
string - encode that string back into UTF-8
- allocate
StringContent - pay runtime serializer metadata costs
Under burst load, that combination produced steady GC churn. Nothing catastrophic in isolation, but enough to drag tail latency upward.
The migrated version moved the path to generated metadata plus byte-oriented output:
[JsonSerializable(typeof(PricingEvent))]
internal partial class PricingJsonContext : JsonSerializerContext
{
}
Code language: C# (cs)public sealed class PricingEventPublisher
{
private readonly HttpClient _httpClient;
public PricingEventPublisher(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task PublishAsync(PricingEvent pricingEvent, CancellationToken cancellationToken)
{
var buffer = new ArrayBufferWriter<byte>(256);
using (var writer = new Utf8JsonWriter(buffer))
{
JsonSerializer.Serialize(
writer,
pricingEvent,
PricingJsonContext.Default.PricingEvent);
}
using var content = new ByteArrayContent(buffer.WrittenMemory.ToArray());
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
await _httpClient.PostAsync("/events/pricing", content, cancellationToken);
}
}
Code language: C# (cs)This was not “perfect zero-allocation” because ToArray() still copies. But it removed the expensive parts first: string creation, re-encoding, and runtime metadata discovery. That is a common production trade-off—take the biggest wins without making the codebase brittle.
In the final version, we pushed one step further and wrote directly to a stream-backed content path:
using var stream = new MemoryStream();
await JsonSerializer.SerializeAsync(
stream,
pricingEvent,
PricingJsonContext.Default.PricingEvent,
cancellationToken);
stream.Position = 0;
using var content = new StreamContent(stream);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
await _httpClient.PostAsync("/events/pricing", content, cancellationToken);
Code language: C# (cs)The result was exactly what you want from a production optimization: lower allocation rate, smoother GC behavior, and a measurable drop in P99 latency—without changing the business logic at all.
6. Performance Analysis and Optimization
At this point, the question is no longer “does source generation sound faster?” but “where is the gain actually coming from?” The only useful answer is measurement.
A minimal BenchmarkDotNet setup makes the difference visible:
using BenchmarkDotNet.Attributes;
using System.Text.Json;
[MemoryDiagnoser]
public class SerializationBenchmarks
{
private readonly PricingEvent _event = new()
{
Symbol = "EURUSD",
Bid = 1.0825m,
Ask = 1.0827m,
Timestamp = DateTimeOffset.UtcNow
};
[Benchmark(Baseline = true)]
public string ReflectionPath()
=> JsonSerializer.Serialize(_event);
[Benchmark]
public string SourceGenPath()
=> JsonSerializer.Serialize(_event, PricingJsonContext.Default.PricingEvent);
}
Code language: C# (cs)This isolates the first optimization: removing runtime metadata discovery. In most real services, that reduces allocations and trims CPU, but the bigger operational win is often less GC interference, not dramatically lower per-call latency.
The next benchmark should test the more production-relevant path: avoiding the intermediate string.
using BenchmarkDotNet.Attributes;
using System.Buffers;
using System.Text.Json;
[MemoryDiagnoser]
public class Utf8Benchmarks
{
private readonly PricingEvent _event = new();
[Benchmark]
public byte[] SerializeToUtf8Buffer()
{
var buffer = new ArrayBufferWriter<byte>(256);
using var writer = new Utf8JsonWriter(buffer);
JsonSerializer.Serialize(writer, _event, PricingJsonContext.Default.PricingEvent);
return buffer.WrittenSpan.ToArray();
}
}
Code language: C# (cs)Yes, ToArray() still allocates—but this benchmark helps separate serializer cost from transport integration cost. In production, you would often write those bytes directly to a response body or pipe instead.
One more optimization worth testing is option reuse plus centralized context wiring:
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, PricingJsonContext.Default);
});
Code language: C# (cs)That does not make each individual serialization magically faster than the explicit overload, but it prevents inconsistent paths across the application. In production, consistency is its own optimization: fewer accidental fallbacks, fewer surprises under load, and much more trustworthy benchmark results.
7. Common Pitfalls and Debugging Tips
The most common production failure is not “source generators are broken.” It is thinking you’re using them when you’re not.
A classic example is declaring a context but then calling the wrong overload:
// Looks harmless, but this can use the default runtime path
var json = JsonSerializer.Serialize(order);
Code language: C# (cs)If this code sits in a hot path, your optimization is effectively gone. The fix is explicit usage:
var json = JsonSerializer.Serialize(
order,
OrderJsonContext.Default.OrderResponse);
Code language: C# (cs)That extra argument is not noise. It is the proof that the generated contract is being used.
Another common issue is forgetting nested or collection-heavy types. Teams add the root DTO and assume the graph is fully covered:
[JsonSerializable(typeof(OrderResponse))]
internal partial class OrderJsonContext : JsonSerializerContext
{
}
Code language: C# (cs)In practice, large payloads often include dictionaries, lists, or auxiliary types that deserve explicit coverage:
[JsonSerializable(typeof(OrderResponse))]
[JsonSerializable(typeof(OrderLine))]
[JsonSerializable(typeof(List<OrderLine>))]
[JsonSerializable(typeof(Dictionary<string, string>))]
internal partial class OrderJsonContext : JsonSerializerContext
{
}
Code language: C# (cs)If performance suddenly regresses after a schema change, this is one of the first places I check.
Finally, inspect generated output instead of guessing. Turn this on in your project file:
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
Code language: C# (cs)That gives you something concrete to verify during migration. In production work, debugging serializers by assumption is a bad habit. Generated code, benchmark output, and allocation traces are much more trustworthy than “it should be using the fast path.”
8. Production Best Practices
By the time source-generated serialization reaches production, the main goal is no longer “make it faster.” It is make it hard to accidentally get slower again.
First, centralize your JSON contract instead of scattering context classes across random projects:
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(OrderResponse))]
[JsonSerializable(typeof(ErrorResponse))]
[JsonSerializable(typeof(List<OrderResponse>))]
internal partial class ApiJsonContext : JsonSerializerContext
{
}
Code language: C# (cs)This makes serializer coverage reviewable. In real systems, that matters more than shaving a few nanoseconds off a benchmark.
Second, wire the context globally so framework-managed serialization does not quietly drift back to reflection:
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, ApiJsonContext.Default);
});
Code language: C# (cs)That pattern is used in production because hand-optimizing one endpoint is easy; keeping an entire service consistent is harder.
Third, keep hot paths byte-oriented:
public static Task WriteAsync(Stream output, OrderResponse response, CancellationToken ct)
{
return JsonSerializer.SerializeAsync(
output,
response,
ApiJsonContext.Default.OrderResponse,
ct);
}
Code language: C# (cs)This avoids unnecessary string creation and fits naturally with HTTP responses, pipes, and stream-based exporters.
The trade-off across all of this is maintenance. Every new DTO added to a critical path should be treated as part of the serialization contract. But that is a good trade. In production, explicit contracts, centralized configuration, and byte-first output are exactly how you keep performance gains from evaporating six months later.
