Cómo hacer que Newtonsoft haga la serialización/deserialización de una clase sin constructores públicos

Table of contents

El problema

A veces, en DDD, tenemos clases que tienen constructores únicamente privados porque las construimos mediante un método estático o de alguna otra forma. Si intentamos aplicar el método JsonConvert.SerializeObject o JsonConvert.DeserializeObject nos encontraremos con una excepción que dice que la clase debe tener un constructor público o estar marcada con un atributo.

Lógicamente, si esto es una Entity (o parte de una Entity) no queremos tener constructores públicos, porque no queremos que nadie desde fuera pueda construir nuestros objetos saltándose las reglas de validación del dominio, y tampoco queremos tener en nuestras entities atributos (que encima nos generan una dependencia en la capa de dominio de Newtonsoft).

Entonces, ¿cómo hacemos para poder serializar/deserializar sin molestar a la capa de dominio? La respuesta es: mediante un converter.

La solución

Imaginemos que tenemos una clase Student con ciertos campos, como esta:

public sealed class Student : Entity 
{
    public string Name { get; init; }

    public string Surname { get; init; }

    public int Group { get; init; }

    private Student(string name, string surname, int group)
    {
        Name = name;
        Surname = surname;
        Group = group;
    }

    public static Result<Student> Create(string name, string surname, int group)
    {
        // reglas de validación
        return Result.Ok(new Student(name, surname, group));
    }
}

Esta clase no tiene ningún constructor público ni queremos anotarla con ningún atributo perteneciente a Newtonsoft (puesto que está en la capa del dominio y no queremos tener una dependencia de librerías externas).

Para poder hacer la serialización/deserialización vamos a crear la siguiente clase en nuestra capa de infraestructura:

public sealed class PrivateConstructorConverter<T> : JsonConverter<T> where T : class
{
    public override T? ReadJson(JsonReader reader, Type objectType, T? existingValue, bool hasExistingValue, JsonSerializer serializer)
    {
        if (reader.TokenType == JsonToken.Null)
        {
            return null;
        }

        if (reader.TokenType == JsonToken.StartObject)
        {
            JObject jsonObject = JObject.Load(reader);

            var constructorInfo = typeof(T).GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance)[0];
            Tuple<string, Type>[] parameters = constructorInfo.GetParameters().Select(p => new Tuple<string, Type>(p.Name!, p.ParameterType)).ToArray();

            var parameterValues = new Dictionary<string, JToken>();
            foreach (var parameter in parameters)
            {
                if (jsonObject.TryGetValue(parameter.Item1, StringComparison.OrdinalIgnoreCase, out var token))
                {
                    parameterValues[parameter.Item1] = token;
                }
            }

            var args = parameters.Select(parameter => parameterValues[parameter.Item1].ToObject(parameter.Item2, serializer)).ToArray();
            var result = constructorInfo.Invoke(args) as T;

            return result;
        }

        throw new JsonSerializationException("Unexpected token type");
    }

    public override void WriteJson(JsonWriter writer, T? value, JsonSerializer serializer)
    {
        if (value == null)
        {
            writer.WriteNull();
            return;
        }

        writer.WriteStartObject();

        foreach (var property in typeof(T).GetProperties())
        {
            writer.WritePropertyName(property.Name);
            serializer.Serialize(writer, property.GetValue(value));
        }

        writer.WriteEndObject();
    }
}

El código es bastante fácil de seguir. Para leer simplemente buscamos los constructores usando Reflection y en este caso nos quedamos con el primero (asumimos que estas clases siempre van a tener un único constructor, en otros casos habría que "tunearla" un poco). Guardamos los nombres de los parámetros y los tipos y también usando Reflection construimos el objeto.

Solo nos queda añadirla. A mí me gusta tener métodos de extensión que nos permitan serializar y deserializar cualquier objeto con una sintaxis tipo miObjeto.Serialize(), por lo que me hago una clase, también en la capa de infraestructura, que haga exactamente esto en vez de llamar al método estático de JsonConvert y aquí añado este converter (además de configurar otras cosas como la indentación o el naming):

public static class JsonExtensions
{
    private static readonly JsonSerializerSettings _defaultSerializationSettings;

    static JsonExtensions()
    {
        DefaultContractResolver contractResolver = new()
        {
            NamingStrategy = new CamelCaseNamingStrategy()
        };

        _defaultSerializationSettings = new()
        {
            ContractResolver = contractResolver,
            Formatting = Formatting.Indented
        };

        _defaultSerializationSettings.Converters.Add(new PrivateConstructorConverter<ConnectionDetails>());
    }

    public static string Serialize<T>(this T obj)
    {
        return JsonConvert.SerializeObject(value: obj, settings: _defaultSerializationSettings);
    }

    public static T? Deserialize<T>(this string json)
    {
        return JsonConvert.DeserializeObject<T>(json, _defaultSerializationSettings);
    }
}

Y ya está, con esto no nos dará ninguna excepción al convertir el objeto y además no hemos agregado ninguna dependencia en la capa de dominio.