Email copiado — support@tuurt.com
Cargando experiencia
dotnet · 15 de septiembre de 2026 · 6 min

Source generators en .NET: reemplazar reflection en serialización sin perder rendimiento en arranque

System.Text.Json puede generar el código de serialización en tiempo de compilación en vez de resolverlo por reflection en cada arranque. Vemos cómo funciona y qué rompimos al mezclarlo con un fallback a reflection bajo Native AOT.

Por Equipo Tuurt

Source generators en .NET: reemplazar reflection en serialización sin perder rendimiento en arranque

Cuando System.Text.Json necesita serializar un objeto, por defecto resuelve en tiempo de ejecución qué propiedades tiene el tipo, cómo se llaman, qué atributos llevan y cómo acceder a sus getters y setters. Esa resolución se hace con reflection: el runtime inspecciona los metadatos del ensamblado, construye internamente una representación del tipo -un JsonTypeInfo- y la cachea para no repetir el trabajo en cada llamada posterior. El costo de esa primera resolución no es gratis, y en escenarios donde el arranque importa -una función serverless, un contenedor que escala a cero y necesita levantar en milisegundos, un binario compilado con Native AOT- ese trabajo de reflection inicial se nota.

El problema no es solo de velocidad. Native AOT compila la aplicación a código máquina nativo sin runtime de .NET detrás, y para reducir el tamaño del binario aplica trimming: elimina del ensamblado final cualquier código que el analizador estático no pueda demostrar que se usa. La reflection depende de metadatos que el trimmer no siempre puede rastrear -invocar un getter por nombre de propiedad en runtime no deja un rastro visible en el grafo de dependencias estático-, así que es habitual que un tipo se serialice bien en modo Debug con el runtime completo y falle en producción con Native AOT porque el trimmer eliminó los metadatos que la reflection necesitaba en ese momento.

Qué genera el compilador en vez de resolver en runtime

Los source generators de C#, disponibles desde .NET 6 para el compilador Roslyn, ejecutan código durante la compilación que analiza el árbol de sintaxis del proyecto y emite código C# adicional antes de que termine el build. Para serialización JSON, eso significa que en vez de que el runtime descubra las propiedades de un tipo por reflection la primera vez que lo serializa, el compilador ya generó, en tiempo de compilación, el código concreto que lee cada propiedad y escribe cada campo del JSON.

La forma de activarlo es declarar una clase parcial que hereda de JsonSerializerContext y marcarla con un atributo por cada tipo que se va a serializar:

[JsonSerializable(typeof(Pedido))]
[JsonSerializable(typeof(LineaPedido))]
[JsonSerializable(typeof(List<Pedido>))]
public partial class AppJsonContext : JsonSerializerContext
{
}

El compilador ve esa declaración y genera, en un archivo aparte que no se escribe a mano ni se versiona, el código real de serialización para Pedido, LineaPedido y List<Pedido>: propiedades leídas directamente, sin pasar por PropertyInfo.GetValue, sin resolver el tipo por su nombre en cada llamada. Para usarlo basta con pasar el contexto generado a las opciones del serializador:

var opciones = new JsonSerializerOptions
{
    TypeInfoResolver = AppJsonContext.Default
};

var json = JsonSerializer.Serialize(pedido, opciones);

Con TypeInfoResolver apuntando al contexto generado, JsonSerializer nunca entra a la ruta de reflection para esos tipos: usa directamente el JsonTypeInfo que el compilador ya construyó. El trimmer, además, puede analizar ese código generado como cualquier otro método normal del proyecto, así que ya no hay una zona ciega donde no sabe si algo se usa o no.

El efecto sobre el arranque

La resolución por reflection de un tipo no ocurre una sola vez de forma abstracta: ocurre la primera vez que ese tipo específico pasa por el serializador dentro del proceso. En una aplicación de larga duración ese costo se paga una vez y se amortiza en miles de llamadas siguientes, así que casi no se nota. En un escenario donde el proceso arranca, atiende unas pocas requests y puede terminar -una Azure Function con plan de consumo, un contenedor en Cloud Run que escala a cero entre ráfagas de tráfico- ese costo de primera resolución se paga en cada arranque en frío, porque no hay proceso de larga duración que lo amortice. Con source generation esa resolución ya no existe como paso de runtime: el código que en el modelo por reflection se ejecutaría la primera vez que se serializa un tipo, aquí ya está compilado como parte del ensamblado, así que arrancar no implica descubrir nada sobre los tipos que se van a serializar.

Lo que no funcionó: mezclar el contexto generado con fallback a reflection

La primera versión de nuestra migración no reemplazó por completo el resolver por defecto, sino que lo combinó con el contexto generado usando un resolver encadenado:

var opciones = new JsonSerializerOptions
{
    TypeInfoResolver = JsonTypeInfoResolver.Combine(
        AppJsonContext.Default,
        new DefaultJsonTypeInfoResolver())
};

La idea parecía razonable: los tipos registrados en AppJsonContext usan el código generado, y cualquier tipo que alguien olvidara registrar cae automáticamente al resolver por reflection en vez de fallar. En el entorno de desarrollo, con el runtime completo de .NET y sin trimming, funcionó exactamente así, sin que nadie notara nada raro. El problema apareció al publicar con Native AOT: un DTO nuevo que un desarrollador agregó a un endpoint sin actualizar la lista de atributos [JsonSerializable] del contexto compiló sin errores, pasó los tests -que corrían contra el runtime normal, no contra el binario AOT- y falló en producción con una NotSupportedException en el primer request real, porque el trimmer había eliminado los metadatos de reflection que el resolver por defecto necesitaba para ese tipo específico, y el fallback que en desarrollo funcionaba silenciosamente ya no tenía nada a lo que recurrir.

Lo corregimos quitando el fallback por completo y dejando el contexto generado como único resolver:

var opciones = new JsonSerializerOptions
{
    TypeInfoResolver = AppJsonContext.Default
};

Sin fallback, un tipo no registrado no falla en producción de forma silenciosa: falla en cualquier entorno, incluido el de desarrollo, con el mismo error, apenas alguien intenta serializarlo. Eso mueve el problema de un NotSupportedException en un request real de producción a un fallo reproducible en la primera prueba manual o automatizada que ejercite ese tipo, que es exactamente donde queremos que aparezca. El costo es que cada tipo nuevo que se serializa necesita agregarse explícitamente al contexto, un paso que antes era invisible y ahora es una línea de código que hay que recordar escribir, pero preferimos ese recordatorio explícito a un fallback que se comporta distinto según el modo de publicación.

Cuándo tiene sentido el cambio

No toda aplicación necesita este cambio. Un servicio de larga duración, sin restricciones de arranque en frío y sin Native AOT en el horizonte, paga el costo de reflection una sola vez por tipo y lo amortiza sin que nadie lo note. Donde sí importa es en cualquier escenario donde el proceso arranca con frecuencia -funciones serverless, contenedores que escalan a cero, CLIs de corta duración- o donde Native AOT es un requisito, porque ahí la reflection no es solo una cuestión de rendimiento sino de compatibilidad: sin source generation, cualquier tipo no cubierto por el resolver por defecto en modo trimmed puede fallar en runtime de una forma que ningún test corrido contra el runtime completo va a detectar. El criterio que aplicamos ahora es simple: si el proyecto ya apunta a Native AOT, el contexto generado no es una optimización opcional, es la única forma soportada de serializar de manera confiable.

dotnet source-generators system-text-json aot
← Volver al blog