Static Factory Pattern: Solving JSON Deserialization Challenges in .NET

In today's web development landscape, serialization has become more than just a feature - it's a cornerstone of modern applications.

While primarily associated with REST APIs, serialization also serves as a practical solution for persisting data, whether in flat files or JSON database columns. Understanding its intricacies, particularly when dealing with complex class structures, is crucial for building robust applications.

The evolution of .NET has brought significant changes to how we handle serialization. System.Text.Json, Microsoft's native JSON library, has matured to match and sometimes surpass Newtonsoft.Json in performance.

While Newtonsoft.Json offers greater flexibility with its extensive configuration options, System.Text.Json takes a more opinionated approach, focusing on performance and security. For instance, System.Text.Json handles case sensitivity differently and provides source generation capabilities for improved performance.

Issue: Deserialisation with static constructors

At its core, deserialization is a process of reconstructing objects from their serialized representations. Traditionally, deserializers rely on a class's default parameterless constructor to create an instance before populating its properties.

However, this approach becomes challenging when dealing with classes that intentionally restrict their construction, as in this example:

public class Goat
{
    private Goat(string name, DateTime birthDate)
    {
        Name = name;
        BirthDate = birthDate;
    }
 
    public string Name { get; }
    public DateTime BirthDate { get; }
 
    public static Goat Restore(string name, DateTime birthDate)
    {
        return new Goat(name, birthDate);
    }

    public static Goat Create(string name)
    {
        return new Goat(name, DateTime.UtcNow);
    }
}

This class implements the static factory pattern, a design choice that offers several advantages. It provides semantic clarity through descriptive factory method names (Create vs Restore), enables object caching, and enforces invariants during object creation. However, this pattern presents challenges for serialization.

Attempting to deserialize JSON into this class using the standard approach:

var goats = JsonSerializer.Deserialize<Goat[]>(response);

Results in an exception:

System.NotSupportedException: Deserialization of types without a parameterless constructor, a singular parameterized constructor, or a parameterized constructor annotated with 'JsonConstructorAttribute' is not supported.

Solution 1: Create a JsonSerializerOptions

Fortunately, System.Text.Json provides multiple solutions to this challenge. One approach involves configuring custom deserialization behavior through JsonSerializerOptions:

public class StaticFactoryConverter<T> : JsonConverter<T>
{
    private readonly MethodInfo _factoryMethod;

    public StaticFactoryConverter(string factoryMethodName = "Restore")
    {
        _factoryMethod = typeof(T).GetMethod(factoryMethodName, BindingFlags.Public | BindingFlags.Static)
            ?? throw new ArgumentException($"No static method named {factoryMethodName} found in type {typeof(T).Name}");
    }

    public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        using (JsonDocument document = JsonDocument.ParseValue(ref reader))
        {
            // Create case-insensitive dictionary for property matching
            var properties = document.RootElement.EnumerateObject()
                .ToDictionary(
                    p => p.Name, 
                    p => GetPropertyValue(p.Value),
                    StringComparer.OrdinalIgnoreCase  // Add case-insensitive comparison
                );
            
            var parameters = _factoryMethod.GetParameters()
                .Select(param => {
                    // Try to find the property, accounting for different cases
                    if (!properties.TryGetValue(param.Name!, out var value))
                    {
                        throw new JsonException($"Missing property {param.Name} required by {_factoryMethod.Name}");
                    }
                    
                    // Convert the value to the parameter's expected type
                    return Convert.ChangeType(value, param.ParameterType);
                })
                .ToArray();

            return (T)_factoryMethod.Invoke(null, parameters);
        }
    }

    private object GetPropertyValue(JsonElement element)
    {
        return element.ValueKind switch
        {
            JsonValueKind.String => element.GetString()!,
            JsonValueKind.Number => element.TryGetDecimal(out var value) ? value : element.GetDouble(),
            JsonValueKind.True or JsonValueKind.False => element.GetBoolean(),
            JsonValueKind.Null => null,
            _ => throw new JsonException($"Unsupported value kind: {element.ValueKind}")
        };
    }

    public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
    {
        JsonSerializer.Serialize(writer, value, options);
    }
}

This generic converter works with any class that follows the static factory pattern. It uses reflection to find and invoke the appropriate factory method, matching JSON properties to constructor parameters by name. This approach provides more flexibility and reusability across different classes.

While the StaticFactoryConverter provides a flexible solution, it comes with some limitations. The most significant constraint is the convention of requiring a specific method name (defaulting to "Restore") across all classes. This can be problematic in several scenarios:

  • Legacy code might use different naming conventions for factory methods
  • Some classes might need different factory methods based on context
  • The required method signature must match exactly with JSON property names
  • Reflection usage impacts performance, especially in high-throughput scenarios

You can work around the naming constraint by specifying a different method name when creating the converter, but this requires more configuration code and careful documentation to maintain consistency across your codebase.

JsonConverter<T> Classe (System.Text.Json.Serialization)
Convertit un objet ou une valeur vers ou depuis JSON.

Solution 2: Use JsonConstructor Attribute

Alternatively, we can use the JsonConstructor attribute to designate a specific constructor for deserialization:

public class Goat
{
    [JsonConstructor]
    private Goat(string name, DateTime birthDate)
    {
    
        Name = name;
        BirthDate = birthDate;
    }
 
    ...
}

This second approach is more concise but potentially less flexible when complex deserialization logic is required.

The JsonConstructor approach shines brightest when working with Data Transfer Objects (DTOs) or scenarios where creation logic is minimal and data validity is presumed.

A common example is retrieving data from JSON database columns - since this data was inserted through our own system's validation checks, we can reasonably assume its validity. In these cases, using a simple Restore method without additional validation is appropriate and efficient.

However, the limitations of JsonConstructor become apparent when dealing with data from external systems, such as HTTP requests or file uploads. In these scenarios, we need to rigorously validate the incoming data during object creation to maintain our domain's integrity. Let's take another look at the example:

public class Goat
{
    [JsonConstructor]
    private Goat(string name, DateTime birthDate)
    {
        // DANGER: No validation here. It depends on the method used to instanciate Goat (Create or Restore)
        Name = name;
        BirthDate = birthDate;
    }
 
    ...

    public static Goat Create(string name)
    {
        // Additional security measures and validation
        return new Goat(name, DateTime.UtcNow);
    }
}

In this case, using JsonConstructor would bypass crucial validation and security measures. Instead, we should employ a StaticFactoryConverter configured to use the Create method, ensuring all business rules and validations are properly enforced.

These improvements align with modern C# development practices, particularly the trend toward more immutable objects and stricter construction patterns. However, if you're working with older .NET versions, you might need to rely on custom converters instead.

JsonConstructorAttribute Class (System.Text.Json.Serialization)
When placed on a constructor, indicates that the constructor should be used to create instances of the type on deserialization.

Conclusion

When designing classes with restricted construction patterns, it's crucial to consider serialization needs early in the development process. While private constructors and static factory methods offer valuable benefits like immutability and semantic clarity, they require careful integration with serialization frameworks.

Remember that the choice between these approaches isn't just about making serialization work - it's about maintaining your domain model's integrity while ensuring it can be effectively serialized and deserialized. The best approach depends on your specific needs:

  • Use JsonConstructor when you want a simple, maintainable solution and don't need complex construction logic
  • Implement a generic converter when you have multiple classes following similar construction patterns
  • Create custom converters when you need specialized handling of complex construction scenarios

By understanding these patterns and their implications, you can build more robust and maintainable applications that properly balance encapsulation with serialization needs.

Have a goat day 🐐