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

  1. Always use source generation for known types
  2. Configure options globally in Program.cs
  3. Use JsonDocument for partial parsing
  4. Avoid JsonElement.GetRawText() for performance
  5. Test serialization thoroughly during migration
  6. Document breaking changes from Newtonsoft
  7. Use JsonIgnoreCondition to reduce payload size
  • 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)