9

I have a generic type that wraps a single primitive type to give it value equality semantics

public class ValueObject<T>
{
    public T Value { get; }
    public ValueObject(T value) => Value = value;

    // various other equality members etc...
}

It is used like:

public class CustomerId : ValueObject<Guid>
{
    public CustomerId(Guid value) : base(value) { }
}

public class EmailAddress : ValueObject<string>
{
    public EmailAddress(string value) : base(value) { }
}

The issue is when serializing a type like:

public class Customer
{
    public CustomerId Id { get; }
    public EmailAddress Email { get; }

    public Customer(CustomerId id, EmailAddress email) 
    { 
        Id = id;
        Email = email;
    }
}

Each object the inherits from ValueObject<T> is wrapped in a Value property (as expected). For example

var customerId = new CustomerId(Guid.NewGuid());
var emailAddress = new EmailAddress("[email protected]");

var customer = new Customer(customerId, emailAddress);

var customerAsJson = JsonConvert.SerializeObject(customer, Formatting.Indented, new JsonSerializerSettings
{
    ContractResolver = new CamelCasePropertyNamesContractResolver() 
})

Results in

{
  "id": {
    "value": "f5ce21a5-a0d1-4888-8d22-6f484794ac7c"
  },
  "email": {
    "value": "[email protected]"
  }
}

Is there a way to write a custom JsonConverter so the the Value property is excluded for types subclassing ValueObject<T> so that the above example would output

{
  "id": "f5ce21a5-a0d1-4888-8d22-6f484794ac7c",
  "email": "[email protected]"
}

I would prefer to have a single JsonConverter that can handle all ValueObject<T> rather than having to define a separate JsonConverter for each ValueObject<T> subclass

My first attempt was

public class ValueObjectOfTConverter : JsonConverter
{
    private static readonly Type ValueObjectGenericType = typeof(ValueObject<>);
    private static readonly string ValuePropertyName = nameof(ValueObject<object>.Value);

    public override bool CanConvert(Type objectType) =>
        IsSubclassOfGenericType(objectType, ValueObjectGenericType);

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        // converts "f5ce21a5-a0d1-4888-8d22-6f484794ac7c" => "value": "f5ce21a5-a0d1-4888-8d22-6f484794ac7c"
        var existingJsonWrappedInValueProperty = new JObject(new JProperty(ValuePropertyName, JToken.Load(reader)));
        return existingJsonWrappedInValueProperty.ToObject(objectType, serializer);
    }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        // to implement
    }

    private static bool IsSubclassOfGenericType(Type typeToCheck, Type openGenericType)
    {
        while (typeToCheck != null && typeToCheck != typeof(object))
        {
            var cur = typeToCheck.IsGenericType ? typeToCheck.GetGenericTypeDefinition() : typeToCheck;
            if (openGenericType == cur) return true;

            typeToCheck = typeToCheck.BaseType;
        }

        return false;
    }
}
3
  • Sure, you could add a custom JsonConverter like the ones shown in Json.Net: Serialize/Deserialize property as a value, not as an object. But using a converter can sometimes interfere with polymorphism and TypeNameHandling. Are you ever using ValueObject<T> in cases where you want to preserve type information for primitives? And why only for types subclassing ValueObject<T>? Commented May 13, 2018 at 15:32
  • Or, is your difficulty with writing the JsonConverter.CanConvert method? Commented May 13, 2018 at 16:53
  • 1
    I have updated the question. I would prefer to not have to write a custom converter for each subclass. Commented May 13, 2018 at 20:36

1 Answer 1

8

You can do this with a custom JsonConverter similar to the ones shown in Json.Net: Serialize/Deserialize property as a value, not as an object. However, since ValueObject<T> does not have a non-generic method to get and set the Value as an object, you will need to use reflection.

Here's one approach:

class ValueConverter : JsonConverter
{
    static Type GetValueType(Type objectType)
    {
        return objectType
            .BaseTypesAndSelf()
            .Where(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(ValueObject<>))
            .Select(t => t.GetGenericArguments()[0])
            .FirstOrDefault();
    }

    public override bool CanConvert(Type objectType)
    {
        return GetValueType(objectType) != null;
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        // You need to decide whether a null JSON token results in a null ValueObject<T> or 
        // an allocated ValueObject<T> with a null Value.
        if (reader.SkipComments().TokenType == JsonToken.Null)
            return null;
        var valueType = GetValueType(objectType);
        var value = serializer.Deserialize(reader, valueType);

        // Here we assume that every subclass of ValueObject<T> has a constructor with a single argument, of type T.
        return Activator.CreateInstance(objectType, value);
    }

    const string ValuePropertyName = nameof(ValueObject<object>.Value);

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        var contract = (JsonObjectContract)serializer.ContractResolver.ResolveContract(value.GetType());
        var valueProperty = contract.Properties.Where(p => p.UnderlyingName == ValuePropertyName).Single();
        // You can simplify this to .Single() if ValueObject<T> has no other properties:
        // var valueProperty = contract.Properties.Single();
        serializer.Serialize(writer, valueProperty.ValueProvider.GetValue(value));
    }
}

public static partial class JsonExtensions
{
    public static JsonReader SkipComments(this JsonReader reader)
    {
        while (reader.TokenType == JsonToken.Comment && reader.Read())
            ;
        return reader;
    }
}

public static class TypeExtensions
{
    public static IEnumerable<Type> BaseTypesAndSelf(this Type type)
    {
        while (type != null)
        {
            yield return type;
            type = type.BaseType;
        }
    }
}

You could then apply the converter directly to ValueType<T> like so:

[JsonConverter(typeof(ValueConverter))]
public class ValueObject<T>
{
    // Remainder unchanged
}

Or apply it in settings instead:

var settings = new JsonSerializerSettings
{
    Converters = { new ValueConverter() },
    ContractResolver = new CamelCasePropertyNamesContractResolver() 
};
var customerAsJson = JsonConvert.SerializeObject(customer, Formatting.Indented, settings);

Working sample .Net fiddle #1 here.

Alternatively, you might consider adding a non-generic method to access the value as an object, e.g. like so:

public interface IHasValue
{
    object GetValue(); // A method rather than a property to ensure the non-generic value is never serialized directly.
}

public class ValueObject<T> : IHasValue
{
    public T Value { get; }
    public ValueObject(T value) => Value = value;

    // various other equality members etc...

    #region IHasValue Members

    object IHasValue.GetValue() => Value;

    #endregion
}

With this addition, WriteJson() becomes much simpler:

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        serializer.Serialize(writer, ((IHasValue)value).GetValue());
    }

Working sample .Net fiddle #2 here.

Notes:

  • ReadJson() assumes that every subclass of Value<T> has a public constructor taking a single argument of type T.

  • Applying the converter directly to ValueType<T> using [JsonConverter(typeof(ValueConverter))] will have slightly better performance, since CanConvert need never get called. See Performance Tips: JsonConverters for details.

  • You need to decide how to handle a null JSON token. Should it result in a null ValueType<T>, or an allocated ValueType<T> with a null Value?

  • In the second version of ValueType<T> I implemented IHasValue.GetValue() explicitly to discourage its use in cases where an instance of ValueType<T> is used in statically typed code.

  • If you really only want to apply the converter to types subclassing ValueObject<T> and not ValueObject<T> itself, in GetValueType(Type objectType) add a call to .Skip(1):

    static Type GetValueType(Type objectType)
    {
        return objectType
            .BaseTypesAndSelf()
            .Skip(1) // Do not apply the converter to ValueObject<T> when not subclassed
            .Where(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(ValueObject<>))
            .Select(t => t.GetGenericArguments()[0])
            .FirstOrDefault();
    }
    

    And then apply the converter in JsonSerializerSettings.Converters rather than directly to ValueObject<T>.

Sign up to request clarification or add additional context in comments.

3 Comments

Not knowing much about custom serialization, my first attempt was to manipulate the JObject directly to avoid reflection. This is where I got to with ReadJson which seemed to work but I had issues with WriteJson .. Is this alternative worth considering? ` public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) { var existingJsonWrappedInValueProperty = new JObject(new JProperty("Value"), JToken.Load(reader))); return existingJsonWrappedInValueProperty.ToObject(objectType, serializer); } `
@kimsagro - not sure, I'd need to see a full, properly formatted example. The JObject is an intermediate representation however, which the above converter avoids creating.
@kimsagro - OK, so what is your question then? Is it [How can I] serialize a generic type wrapper without the property name or is it, How can I fix my current ValueObjectOfTConverter? If the second, what is not working? Is it just that you need to write WriteJson()? If so, you should be able to use the one from my answer.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.