
Mastering Advanced JSON with Custom Converters and Source Generation in .NET
Author - Abdul Rahman (Bhai)
JSON
5 Articles
Table of Contents
What we gonna do?
Not all JSON is created equal. Sometimes you need dates formatted differently. Other times you need custom logic for specific types. And if you're building high-performance applications, you want to avoid reflection overhead entirely.
In this article, we'll explore advanced System.Text.Json functionality that gives you complete control over JSON serialization and deserialization. We'll cover custom converters (both basic and factory patterns) and the System.Text.Json source generator - one of the most powerful features in modern .NET for boosting JSON performance.
Think of custom converters as translators that speak your application's specific dialect of JSON. And source generators? They're your performance ace in the hole, eliminating reflection by generating serialization code at compile time.
Why we gonna do?
Why Custom Converters Matter
The default JSON serializer works great for standard scenarios. But real-world applications often deal with JSON from different sources, each with its own quirks. Maybe you're integrating with an API that uses MM/dd/yyyy date formats. Or you need to handle custom value types that the serializer doesn't understand out of the box.
Custom converters let you override the default behavior and handle these scenarios elegantly. Here are common use cases:
- Custom date formats - Support different regional date representations
- Type inference - Help the deserializer understand if "true" is a boolean or string
- Polymorphic deserialization - Handle inheritance hierarchies correctly
- Custom value types - Serialize/deserialize phone numbers, currencies, or other domain types
- Collection ordering - Fix issues like Stack<T> reversal during round-tripping
Why Source Generation Is a Game-Changer
Here's the thing: by default, System.Text.Json uses reflection to inspect your objects at runtime. It asks questions like "What type is this?" and "What properties does it have?" every single time you serialize.
Reflection is powerful, but it's slow. The System.Text.Json source generator solves this by moving that inspection work to compile time. It generates specialized serialization code that's compiled with your application.
The result? Faster startup times, better performance, and reduced memory allocations. It's especially valuable for high-throughput APIs and serverless applications where cold-start time matters.
How we gonna do?
Understanding Converter Patterns
There are two patterns for creating custom converters: basic pattern and factory pattern.
The basic pattern handles a single, specific type - like DateTime, Dictionary<int, string>, or List<DateTimeOffset>. It's straightforward and perfect when you know exactly which type needs special handling.
The factory pattern is more flexible. It handles generic types like Dictionary<TKey, TValue>, List<T>, or any Enum. The factory determines the specific type at runtime and creates the appropriate converter dynamically.
Step 1: Creating a Basic Pattern Converter
Let's create a custom converter that serializes DateTimeOffset values with only the date portion (removing the time component). This is useful when you want consistent date-only formatting.
Here's how to build it:
public class DateTimeOffsetJsonConverter : JsonConverter<DateTimeOffset>
{
public override DateTimeOffset Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
var dateString = reader.GetString();
return DateTimeOffset.Parse(dateString!);
}
public override void Write(
Utf8JsonWriter writer,
DateTimeOffset value,
JsonSerializerOptions options)
{
// Write only the date portion, removing time
writer.WriteStringValue(value.ToString("yyyy-MM-dd"));
}
}
Notice how the converter derives from JsonConverter<DateTimeOffset>. The type parameter specifies exactly what type this converter handles - that's what makes it "basic pattern."
The Read method handles deserialization (JSON to .NET object). The Write method handles serialization (.NET object to JSON).
Step 2: Registering the Converter
There are two ways to register your custom converter. The first is using JsonSerializerOptions:
var options = new JsonSerializerOptions
{
Converters = { new DateTimeOffsetJsonConverter() }
};
var json = JsonSerializer.Serialize(weatherForecast, options);
The second approach uses the [JsonConverter] attribute directly on properties or classes:
public class WeatherForecast
{
[JsonConverter(typeof(DateTimeOffsetJsonConverter))]
public DateTimeOffset Date { get; set; }
public int TemperatureCelsius { get; set; }
public string? Summary { get; set; }
}
You can also apply [JsonConverter] to the entire class:
[JsonConverter(typeof(WeatherForecastConverter))]
public class WeatherForecast
{
// Properties...
}
Both methods work perfectly. Use the attribute approach when the converter is tightly coupled to a specific property or type. Use the options approach when you want more flexibility to swap converters at runtime.
Step 3: Creating a Factory Pattern Converter
Now let's build something more sophisticated: a factory converter that handles dictionaries with Enum keys and transforms the values during serialization.
Here's the scenario: We have weather "feels like" data where the key is an Enum (Cold, Cool, Warm, Hot) and the value is a description. We want to customize how these sentences appear in JSON.
public class DictionaryTKeyEnumTValueConverter : JsonConverterFactory
{
public override bool CanConvert(Type typeToConvert)
{
// Only handle Dictionary<TKey, TValue> where TKey is an Enum
if (!typeToConvert.IsGenericType)
return false;
if (typeToConvert.GetGenericTypeDefinition() != typeof(Dictionary<,>))
return false;
// Check if the key type is an Enum
return typeToConvert.GetGenericArguments()[0].IsEnum;
}
public override JsonConverter CreateConverter(
Type typeToConvert,
JsonSerializerOptions options)
{
Type keyType = typeToConvert.GetGenericArguments()[0];
Type valueType = typeToConvert.GetGenericArguments()[1];
JsonConverter converter = (JsonConverter)Activator.CreateInstance(
typeof(DictionaryEnumConverterInner<,>)
.MakeGenericType(new Type[] { keyType, valueType }),
BindingFlags.Instance | BindingFlags.Public,
binder: null,
args: new object[] { options },
culture: null)!;
return converter;
}
private class DictionaryEnumConverterInner<TKey, TValue>
: JsonConverter<Dictionary<TKey, TValue>>
where TKey : struct, Enum
{
private readonly JsonConverter<TValue> _valueConverter;
private readonly Type _valueType;
public DictionaryEnumConverterInner(JsonSerializerOptions options)
{
_valueConverter = (JsonConverter<TValue>)options
.GetConverter(typeof(TValue));
_valueType = typeof(TValue);
}
public override Dictionary<TKey, TValue> Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartObject)
throw new JsonException();
var dictionary = new Dictionary<TKey, TValue>();
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndObject)
return dictionary;
string? keyString = reader.GetString();
TKey key = Enum.Parse<TKey>(keyString!);
reader.Read();
TValue value = _valueConverter.Read(
ref reader,
_valueType,
options)!;
dictionary.Add(key, value);
}
throw new JsonException();
}
public override void Write(
Utf8JsonWriter writer,
Dictionary<TKey, TValue> dictionary,
JsonSerializerOptions options)
{
writer.WriteStartObject();
foreach (KeyValuePair<TKey, TValue> kvp in dictionary)
{
string propertyName = kvp.Key.ToString();
writer.WritePropertyName(propertyName);
// Custom transformation: modify the sentence
if (kvp.Value is string sentence)
{
string modified = ModifySentence(propertyName, sentence);
writer.WriteStringValue(modified);
}
else
{
_valueConverter.Write(writer, kvp.Value, options);
}
}
writer.WriteEndObject();
}
private string ModifySentence(string location, string sentence)
{
// Transform "I am from Costa Rica. This guy is freezing"
// to "Costa Rica: This guy is freezing"
if (sentence.StartsWith("I am from "))
{
var parts = sentence.Split(". ");
if (parts.Length >= 2)
{
return $"{location}: {parts[1]}";
}
}
return sentence;
}
}
}
This factory converter is more complex, but here's what makes it powerful:
- The CanConvert method validates that we're dealing with a Dictionary<TKey, TValue> where TKey is an Enum
- The CreateConverter method dynamically creates the right converter based on the actual generic arguments
- The inner converter class handles the actual reading and writing, with custom logic to transform values
Here's how you'd use it:
var feelsLike = new Dictionary<Feels, string>
{
{ Feels.Cold, "I am from Costa Rica. This guy is freezing" },
{ Feels.Cool, "I am from Vermont. I'm okay with this" },
{ Feels.Warm, "I am from Canada. This is Tuesday" },
{ Feels.Hot, "I am from Siberia. Actually, this is hot" }
};
var options = new JsonSerializerOptions
{
Converters = { new DictionaryTKeyEnumTValueConverter() }
};
string json = JsonSerializer.Serialize(feelsLike, options);
// Result:
// {
// "Cold": "Cold: This guy is freezing",
// "Cool": "Cool: I'm okay with this",
// "Warm": "Warm: This is Tuesday",
// "Hot": "Hot: Actually, this is hot"
// }
Step 4: Understanding Reflection vs. Source Generation
Before we dive into source generation, let's understand the problem it solves.
When you call JsonSerializer.Serialize(weatherForecast), the serializer uses reflection to inspect the weatherForecast object at runtime:
- What type is this object? (WeatherForecast)
- What properties does it have? (Date, TemperatureCelsius, Summary)
- What are their types? (DateTimeOffset, int, string)
- Are there any attributes? (JsonPropertyName, JsonIgnore, etc.)
This inspection happens every time you serialize. It's convenient but adds overhead - especially during application startup (the "warm-up phase").
Source generators shift this work to compile time. Instead of discovering type information at runtime, the generator inspects your code during compilation and generates specialized serialization code.
Step 5: Implementing Source Generation
To use the System.Text.Json source generator, you need to create a partial class that derives from JsonSerializerContext:
[JsonSerializable(typeof(WeatherForecast))]
public partial class SourceGenerationContext : JsonSerializerContext
{
}
That's it! The [JsonSerializable] attribute tells the generator which types to create serialization code for. The generator runs during build and creates the implementation automatically.
You can add multiple types:
[JsonSerializable(typeof(WeatherForecast))]
[JsonSerializable(typeof(List<WeatherForecast>))]
[JsonSerializable(typeof(Dictionary<string, WeatherForecast>))]
public partial class SourceGenerationContext : JsonSerializerContext
{
}
Step 6: Using Source-Generated Code
There are two ways to use source generation. The first uses JsonTypeInfo<T>:
// Deserialization
string json = """{"Date":"2025-12-07","TemperatureCelsius":25,"Summary":"Warm"}""";
WeatherForecast? forecast = JsonSerializer.Deserialize(
json,
SourceGenerationContext.Default.WeatherForecast);
// Serialization
var forecast = new WeatherForecast
{
Date = DateTimeOffset.Now,
TemperatureCelsius = 25,
Summary = "Warm"
};
string json = JsonSerializer.Serialize(
forecast,
SourceGenerationContext.Default.WeatherForecast);
The second approach uses JsonSerializerContext:
// Deserialization
WeatherForecast? forecast = JsonSerializer.Deserialize<WeatherForecast>(
json,
SourceGenerationContext.Default.Options);
// Serialization
string json = JsonSerializer.Serialize(
forecast,
SourceGenerationContext.Default.Options);
Both approaches avoid reflection. The serializer uses the pre-generated code instead.
Step 7: Viewing the Generated Code
Want to see what the generator creates? In Visual Studio, expand Dependencies → Analyzers → System.Text.Json.SourceGenerator. You'll find generated files with names like SourceGenerationContext.WeatherForecast.g.cs.
The generated code contains specialized methods for reading and writing your types:
// Auto-generated code (simplified for clarity)
private static void WriteWeatherForecast(
Utf8JsonWriter writer,
WeatherForecast value)
{
writer.WriteStartObject();
writer.WritePropertyName("Date");
writer.WriteStringValue(value.Date);
writer.WritePropertyName("TemperatureCelsius");
writer.WriteNumberValue(value.TemperatureCelsius);
writer.WritePropertyName("Summary");
writer.WriteStringValue(value.Summary);
writer.WriteEndObject();
}
This code is compiled with your application, eliminating the need for runtime reflection.
Step 8: Performance Benefits
Source generation provides several advantages:
- Faster startup - No warm-up phase for reflection
- Better throughput - Direct method calls instead of reflection
- Lower memory - Reduced allocations during serialization
- AOT compatible - Works with ahead-of-time compilation and trimming
- Build-time errors - Type issues caught during compilation
For APIs processing thousands of JSON requests per second, these benefits add up significantly.
Step 9: Combining Converters and Source Generation
You can use custom converters with source generation. Just configure them in the context:
[JsonSerializable(typeof(WeatherForecast))]
[JsonSourceGenerationOptions(
WriteIndented = true,
Converters = new[] { typeof(DateTimeOffsetJsonConverter) })]
public partial class SourceGenerationContext : JsonSerializerContext
{
}
The [JsonSourceGenerationOptions] attribute lets you configure serialization behavior for all types in the context.
Summary
We've explored the advanced capabilities of System.Text.Json that give you precise control over JSON handling in .NET applications.
Custom converters let you override default serialization behavior. Use the basic pattern for specific types like DateTime or custom value types. Use the factory pattern when you need to handle generic types or create converters dynamically based on runtime type information.
Source generation eliminates reflection overhead by generating serialization code at compile time. It's perfect for high-performance scenarios, serverless functions with cold-start concerns, and applications using ahead-of-time compilation.
The combination of custom converters and source generation gives you both flexibility and performance - you can handle any JSON format while maintaining excellent runtime characteristics. Whether you're building APIs, microservices, or client applications, these advanced features help you write cleaner, faster JSON code.
In our next article, we'll wrap up the JSON series with a comprehensive summary and best practices guide. Stay tuned!