Documentation
adrs/025-system-text-json-standardization.md
ADR-025: System.Text.Json Standardization
Status
Accepted
Context
JSON serialization is fundamental to our API operations, affecting performance, compatibility, and maintainability. The .NET ecosystem has historically used Newtonsoft.Json (Json.NET), but Microsoft has developed System.Text.Json as a high-performance, built-in alternative.
Current situation:
- Mix of Newtonsoft.Json and System.Text.Json across components
- Inconsistent serialization settings
- Performance overhead from Newtonsoft.Json
- Unnecessary external dependency
Decision
We will standardize on System.Text.Json for all JSON serialization needs across Dynaplex services, completely removing Newtonsoft.Json dependencies.
Key aspects:
- Use System.Text.Json exclusively
- Leverage source generation for performance
- Standardize serialization options
- Migrate all Newtonsoft.Json usage
Consequences
Positive
- Performance: 2-3x faster than Newtonsoft.Json
- Memory Efficiency: Lower allocations and memory usage
- Source Generation: Compile-time serialization code generation
- No External Dependencies: Built into .NET
- Modern Features: Supports latest C# features
- Consistency: Single serialization approach
Negative
- Migration Effort: Must convert existing Newtonsoft code
- Feature Gaps: Some advanced Newtonsoft features unavailable
- Breaking Changes: Different default behaviors
- Learning Curve: Team must learn new APIs
Neutral
- Different Defaults: Case-sensitive, no comments, etc.
- Converter Requirements: Custom converters need rewriting
- Configuration: Different configuration approach
Implementation Notes
Standard Configuration
// Program.cs
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.SerializerOptions.PropertyNameCaseInsensitive = true;
options.SerializerOptions.WriteIndented = false;
options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
options.SerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
// Add source generation context
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
});
Source Generation Setup
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false)]
[JsonSerializable(typeof(Asset))]
[JsonSerializable(typeof(List<Asset>))]
[JsonSerializable(typeof(PagedResult<Asset>))]
[JsonSerializable(typeof(CreateAssetRequest))]
[JsonSerializable(typeof(UpdateAssetRequest))]
public partial class CatalogJsonContext : JsonSerializerContext
{
}
// Usage
var json = JsonSerializer.Serialize(asset, CatalogJsonContext.Default.Asset);
var asset = JsonSerializer.Deserialize(json, CatalogJsonContext.Default.Asset);
Migration from Newtonsoft.Json
Attribute Mapping
// Newtonsoft.Json
[JsonProperty("asset_name")]
[JsonIgnore]
[JsonConverter(typeof(StringEnumConverter))]
// System.Text.Json
[JsonPropertyName("asset_name")]
[JsonIgnore]
[JsonConverter(typeof(JsonStringEnumConverter))]
Custom Converter Migration
// Newtonsoft.Json converter
public class DateOnlyConverter : JsonConverter<DateOnly>
{
public override void WriteJson(JsonWriter writer, DateOnly value, JsonSerializer serializer)
{
writer.WriteValue(value.ToString("yyyy-MM-dd"));
}
public override DateOnly ReadJson(JsonReader reader, Type objectType,
DateOnly existingValue, bool hasExistingValue, JsonSerializer serializer)
{
return DateOnly.Parse((string)reader.Value);
}
}
// System.Text.Json converter
public class DateOnlyJsonConverter : JsonConverter<DateOnly>
{
public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert,
JsonSerializerOptions options)
{
return DateOnly.Parse(reader.GetString()!);
}
public override void Write(Utf8JsonWriter writer, DateOnly value,
JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString("yyyy-MM-dd"));
}
}
Polymorphic Serialization
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(PhysicalAsset), typeDiscriminator: "physical")]
[JsonDerivedType(typeof(DigitalAsset), typeDiscriminator: "digital")]
[JsonDerivedType(typeof(LicenseAsset), typeDiscriminator: "license")]
public abstract class Asset
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
}
public class PhysicalAsset : Asset
{
public string SerialNumber { get; set; } = string.Empty;
public string Location { get; set; } = string.Empty;
}
public class DigitalAsset : Asset
{
public string LicenseKey { get; set; } = string.Empty;
public DateTime ExpirationDate { get; set; }
}
Common Patterns
Enum Handling
// Global configuration
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
// Per-property
public class Asset
{
[JsonConverter(typeof(JsonStringEnumConverter))]
public AssetStatus Status { get; set; }
}
// With custom names
public enum AssetStatus
{
[JsonStringEnumMemberName("in_use")]
InUse,
[JsonStringEnumMemberName("available")]
Available,
[JsonStringEnumMemberName("maintenance")]
UnderMaintenance
}
DateTime Handling
public class DateTimeConverter : JsonConverter<DateTime>
{
private const string Format = "yyyy-MM-ddTHH:mm:ss.fffZ";
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert,
JsonSerializerOptions options)
{
return DateTime.Parse(reader.GetString()!, null,
DateTimeStyles.RoundtripKind);
}
public override void Write(Utf8JsonWriter writer, DateTime value,
JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToUniversalTime().ToString(Format));
}
}
Nullable Reference Types
public class Asset
{
public string Name { get; set; } = string.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Description { get; set; }
[JsonRequired]
public int TypeId { get; set; }
}
Performance Optimization
// Use JsonDocument for partial parsing
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
var id = root.GetProperty("id").GetInt32();
// Use Utf8JsonWriter for streaming
using var stream = new MemoryStream();
using var writer = new Utf8JsonWriter(stream);
writer.WriteStartObject();
writer.WriteNumber("id", 123);
writer.WriteString("name", "Asset");
writer.WriteEndObject();
writer.Flush();
// Use JsonNode for dynamic JSON
var node = JsonNode.Parse(json);
node["status"] = "active";
var modified = node.ToJsonString();
Testing Serialization
[Test]
public void Asset_Serialization_RoundTrip()
{
// Arrange
var asset = new Asset
{
Id = 1,
Name = "Test Asset",
Status = AssetStatus.InUse
};
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() }
};
// Act
var json = JsonSerializer.Serialize(asset, options);
var deserialized = JsonSerializer.Deserialize<Asset>(json, options);
// Assert
deserialized.Should().BeEquivalentTo(asset);
json.Should().Contain("\"status\":\"inUse\"");
}
Migration Checklist
- Remove all Newtonsoft.Json package references
- Replace JsonProperty with JsonPropertyName
- Convert custom JsonConverters
- Update serialization configuration
- Add source generation contexts
- Test serialization round-trips
- Update API documentation
Best Practices
- Always use source generation for known types
- Configure options globally in Program.cs
- Use JsonDocument for partial parsing
- Avoid JsonElement.GetRawText() for performance
- Test serialization thoroughly during migration
- Document breaking changes from Newtonsoft
- Use JsonIgnoreCondition to reduce payload size
Related ADRs
- ADR-008: .NET 9.0 with Latest C# Features (source generation support)
- ADR-014: TypedResults for Type-Safe Responses (JSON responses)
- ADR-019: OpenTelemetry Integration (JSON logging)