Search code examples
c#jsonjson.netdeserialization

Writing a custom JsonConverter for Value Objects


I have a generic ValueObject class like this one

public abstract class ValueObject : 
    IEquatable<ValueObject>, 
    IComparable, 
    IComparable<ValueObject> {

    protected abstract IEnumerable<object> GetEqualityComponents();
}

this class does not have any property. To implement a value object I can then create any new class like the following

public sealed class PersonName : ValueObject {

    private PersonName(string firstName, string middleName, string lastName) {
        FirstName = firstName;
        MiddleName = middleName;
        LastName = lastName;
    }

    public string FirstName { get; private set; }
    public string MiddleName { get; private set; }
    public string LastName { get; private set; }

    public static PersonName Create(string firstName, string middleName, string lastName) {
        //Validate input and create object
        return new PersonName(firstName, middleName, lastName);
    }

    protected override IEnumerable<object> GetEqualityComponents() {
        yield return FirstName;
        yield return MiddleName;
        yield return LastName;
    }
}

Please note that

  • object has a single private constructor
  • properties have private setters
  • object creation happens through a single Create static method

I am struggling with json serialization and deserialization using Newtonsoft.Json. I have also tried to understand the logic behind this answer and the original source code referenced by it.

What I am trying to achieve is how to make a generic JsonConverter that, compared to the one showd in the linked answer, will be able to

  • get the static Create method
  • use that method to construct the object during deserialization mapping the parameters by name.

Is there a way to achieve this?

EDIT

I forgot to mention that I am ok with serialization using value objects inside any other class for example when using in a class like this

public class Individual {
    public PersonName Name { get; }
    public string Citizenship { get; }
}

I am able to serialize json like this

{
    "$type":"Entities.Individual, myassembly",
    "name":{
        "$type":"ValueObjects.PersonName, myassembly",
        "firstName": "John",
        "middleName": "",
        "lastName": "Wayne"
    },
    "citizenship": "American"
}

Solution

  • Rather than a custom JsonConverter, you can write a custom contract resolver that looks for a static Create() method inside objects inheriting from ValueObject and assigns a delegate invoking it to JsonObjectContract.OverrideCreator. Having done so, the static method will be invoked in place of a normal constructor.

    To do this, first create the following contract resolver:

    public class ValueObjectResolver : DefaultContractResolver
    {
        const string CreateMethodName = "Create";
    
        protected override JsonObjectContract CreateObjectContract(Type objectType)
        {
            var contract = base.CreateObjectContract(objectType);
            if (typeof(ValueObject).IsAssignableFrom(objectType))
            {
                try
                {
                    var createMethod = objectType.GetMethod(CreateMethodName, BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly);
                    if (createMethod != null)
                    {
                        contract.OverrideCreator = (args) => createMethod.Invoke(null, args) ?? throw new InvalidOperationException($"null created object {objectType.Name}");
                        contract.CreatorParameters.Clear();
                        foreach (var param in CreateStaticConstructorParameters(createMethod, contract.Properties))
                            contract.CreatorParameters.Add(param);
                    }
                }
                catch (AmbiguousMatchException ex)
                {
                    // TODO: Handle multiple Create() methods with dfferent argument lists however you want.
                    Debug.WriteLine(ex);
                }
            }
    
            return contract;
        }
    
        JsonPropertyCollection CreateStaticConstructorParameters(MethodInfo createMethod, JsonPropertyCollection memberProperties)
        {
            // Adapted from https://github.com/JamesNK/Newtonsoft.Json/blob/master/Src/Newtonsoft.Json/Serialization/DefaultContractResolver.cs#L743
            // By https://github.com/JamesNK/
            // License: https://github.com/JamesNK/Newtonsoft.Json/blob/master/LICENSE.md
            var parameterCollection = new JsonPropertyCollection(createMethod.DeclaringType!);
            foreach (var parameterInfo in createMethod.GetParameters().Where(i => i.Name != null))
            {
                var matchingMemberProperty = MatchProperty(memberProperties, parameterInfo.Name, parameterInfo.ParameterType);
                if (CreatePropertyFromConstructorParameter(matchingMemberProperty, parameterInfo) is {} property)
                    parameterCollection.AddProperty(property);
            }
            return parameterCollection;
        }
    
        static JsonProperty? MatchProperty(JsonPropertyCollection properties, string? name, Type type)
        {
            if (name == null)
                return null;
            var property = properties.GetClosestMatchProperty(name);
            // must match type as well as name
            if (property?.PropertyType == type)
                return property;
            return null;
        }       
    }
    

    To use it, cache an instance statically somewhere as recommended by the docs:

    public static DefaultContractResolver Resolver { get; } = new ValueObjectResolver()
    {
        NamingStrategy = new CamelCaseNamingStrategy(),
    };
    

    And now you will be able to round-trip data models containing ValueObject like so:

    Individual individual1 = new()
    {
        Name = PersonName.Create("John", "", "Wayne"),
        Citizenship = "American",
    };
    
    JsonSerializerSettings settings = new()
    {
        ContractResolver = Resolver,
        // Add other settings as required.
        // TODO, CAUTION -- If you use TypeNameHandling you should write a custom serialization binder for security reasons.
        // See https://www.newtonsoft.com/json/help/html/t_newtonsoft_json_typenamehandling.htm
        TypeNameHandling = TypeNameHandling.Objects,
    };
    
    var json = JsonConvert.SerializeObject(individual1, Formatting.Indented, settings);
    
    var individual2 = JsonConvert.DeserializeObject<Individual>(json, settings);
    

    Notes:

    • For simplicity I simply call MethodInfo.Invoke() directly. If performance becomes an issue you could use reflection to manufacture a delegate.

    • C# record types also support value-based equality. You might consider whether you could use them instead of inventing something similar.

    • I notice you are using TypeNameHandling. Please be aware that there are security risks associated with this setting. As explained in the docs:

      TypeNameHandling should be used with caution when your application deserializes JSON from an external source. Incoming types should be validated with a custom SerializationBinder when deserializing with a value other than None.

      For details see TypeNameHandling caution in Newtonsoft Json and External json vulnerable because of Json.Net TypeNameHandling auto?.

    • The resolver assumes that each ValueObject subtype has only one Create() method. If you ever introduce value objects with multiple Create() methods you will have to update CreateObjectContract() accordingly, e.g. by selecting the Create() method with the largest number of parameters.

    • See JSON.net ContractResolver vs. JsonConverter for guidance when to use a custom converter vs. when to use a custom resolver. In practice, custom resolvers tend to play better with TypeNameHandling and with reference preservation.

    Demo fiddle here.