Introducción
En el desarrollo con .NET, tendemos a confiar en los patrones que dominamos: clases base, JsonConverter
s personalizados y control explícito del flujo de serialización. Durante años, si necesitabas serializar jerarquías de clases en JSON preservando el tipo real, la respuesta era clara: implementar un convertidor y aplicar el patrón estrategia.
Este enfoque funciona, sí, pero con un coste: lógica repetitiva, mantenimiento manual y dificultad para escalar. Además, obliga a “conocer” el tipo a resolver, bien mediante un switch
, un if
, o inspección de propiedades clave en tiempo de ejecución.
Con la llegada de .NET 7 y su consolidación en .NET 8, System.Text.Json
ha dado un salto de calidad. Gracias a PolymorphismOptions
y los atributos [JsonPolymorphic]
y [JsonDerivedType]
, hoy podemos declarar nuestras jerarquías y dejar que el framework haga el trabajo sucio: identificar el tipo derivado correcto a partir de un simple discriminador de tipo ($type
) en el JSON.
Y esto nos lleva a una pregunta crucial:
¿Vale la pena abandonar nuestras soluciones personalizadas y apostar por lo que ya ofrece el framework?
No vamos a responderlo con opiniones. Vamos con datos.
Benchmark comparativo: estrategia manual vs soporte nativo
Se realizó una prueba comparativa con BenchmarkDotNet
en .NET 8 para medir el rendimiento al serializar y deserializar un objeto polimórfico (OperacionSuma
) usando dos enfoques:
-
🧱 Convertidor personalizado (
JsonConverter
) + patrón estrategia -
⚡ Soporte nativo con
PolymorphismOptions
Resultados
Método | Tiempo medio (ns) | Memoria (B) | Gen0 GC |
---|---|---|---|
Deserialize_CustomConverter |
649.1 ns | 256 B | 0.0200 |
Deserialize_PolymorphismOptions |
225.4 ns | 56 B | 0.0043 |
Deserialize_JsonPolymorphicAttribute |
234.3 ns | 56 B | 0.0043 |
Deserialize_GenericTypeConverter |
690.5 ns | 256 B | 0.0200 |
Serialize_CustomConverter |
117.9 ns | 80 B | 0.0062 |
Serialize_PolymorphismOptions |
170.4 ns | 424 B | 0.0336 |
Serialize_JsonPolymorphicAttribute |
151.4 ns | 424 B | 0.0336 |
Serialize_GenericTypeConverter |
107.7 ns | 80 B | 0.0063 |
Análisis: ¿qué nos dicen los datos?
-
Deserialización: ganadores claros
-
El más rápido es
PolymorphismOptions
conTypeInfoResolver
, seguido muy de cerca por el enfoque con atributos (JsonPolymorphic
). -
Ambos enfoques nativos también usan la menor cantidad de memoria, haciendo que sean la mejor opción para escenarios donde se reciben muchos objetos por segundo (por ejemplo, eventos, colas o APIs de alto tráfico).
-
Los enfoques clásicos (
CustomConverter
yGenericTypeConverter
) quedan muy por detrás en rendimiento, con más del doble de tiempo y consumo de memoria.
-
-
Serialización: diferencias menores
-
El enfoque más rápido en serializar es el convertidor genérico (
GenericTypeConverter
), seguido del convertidor personalizado. -
Los enfoques nativos (
PolymorphismOptions
yJsonPolymorphic
) son algo más lentos al serializar y consumen más memoria, ya que agregan el discriminador y metadata adicional.
-
¿Y qué significa esto en la práctica?
Mejor opción | |
---|---|
Alto volumen de deserialización, como colas/eventos | PolymorphismOptions o JsonPolymorphic |
Código mantenible y sin lógica condicional | JsonPolymorphic |
Flexibilidad máxima sin tocar los modelos | GenericTypeConverter |
Compatibilidad con versiones anteriores de .NET | JsonConverter personalizado |
Casos reales: mucho más que “sumar y restar”
Este tipo de serialización no es solo útil para demos académicas. Su verdadero poder se manifiesta cuando trabajas con:
Contratos inciertos
Cuando no puedes saber de antemano si recibirás un tipo u otro, pero sabes que todos comparten una estructura base. En vez de condicionar el flujo manualmente, dejas que el discriminador $type
haga el trabajo.
Única cola, único endpoint
En arquitecturas basadas en eventos o APIs unificadas, donde solo hay una entrada para múltiples tipos de mensaje, este patrón es perfecto. Permite canalizar distintos tipos derivados por un mismo canal sin necesidad de múltiples handlers o registros explícitos de cada tipo.
Reducción total del patrón estrategia
Ya no necesitas mantener clases o convertidores específicos para decidir “qué hacer con qué tipo”. Puedes enfocarte en implementar el comportamiento de cada clase concreta, y el sistema se encarga de la selección correcta.
APIs más limpias y resilientes
Al declarar todos los tipos derivados desde una clase base, puedes generar documentación (OpenAPI) clara, facilitar validaciones, y reducir el acoplamiento entre productor y consumidor.
¿Y el mantenimiento?
El enfoque tradicional requiere:
-
switch
oif
por tipo -
Registro manual de clases
-
Convertidores específicos y difíciles de testear
-
Revisión constante del código al incorporar nuevos tipos
Con PolymorphismOptions
:
-
Declaras los tipos derivados una sola vez
-
Ganas en claridad, extensibilidad y documentación
-
Reducen el riesgo de errores humanos
-
Es compatible con AOT y
source generators
para optimizar el rendimiento
Conclusión
Este benchmark demuestra que muchas veces subestimamos las mejoras del framework. Lo nuevo no es solo “más bonito” o “más moderno”, es más eficiente, más seguro y muchísimo más mantenible.
Si trabajas con contratos dinámicos, integración entre dominios o eventos polimórficos, la ventaja que ofrece
System.Text.Json
conPolymorphismOptions
es difícil de ignorar.
Y sí, los benchmarks importan. Pero lo que realmente cambia tu día a día es esto:
- Menos código
- Menos errores
- Más claridad arquitectónica
Mi recomendación es clara: usa el soporte nativo de serialización polimórfica en .NET 7+. Y si aún estás en .NET 6 o anterior, este es otro argumento más para planificar tu migración.
Los ejemplos y qué quiero mostrar con ellos
Para reforzar las conclusiones anteriores, he preparado tres ejemplos prácticos que ilustran distintas formas de abordar la serialización polimórfica en C#, desde lo más tradicional hasta lo más moderno. Cada uno representa un escenario real al que nos enfrentamos en arquitecturas distribuidas, APIs o sistemas de mensajería.
Soporte nativo con atributos [JsonPolymorphic]
y $type
Este ejemplo muestra lo más directo y moderno: declaramos una clase base Operacion
, añadimos los atributos [JsonDerivedType]
, y dejamos que System.Text.Json
deserialice automáticamente al tipo concreto (OperacionSuma
u OperacionResta
) según un discriminador $type
.
¿Qué quiero demostrar con este ejemplo?
-
Que con muy poco código se puede lograr polimorfismo funcional y limpio.
-
Que no necesitas
JsonConverter
niswitch
ni lógica adicional. -
Que funciona out-of-the-box en .NET 7+ y es ideal para APIs, eventos y contratos inciertos.
Ventaja clara: permite construir sistemas desacoplados y extensibles sin esfuerzo.
Configuración dinámica con DefaultJsonTypeInfoResolver
En este caso, el modelo es idéntico al anterior, pero en lugar de usar atributos, definimos los tipos derivados y el discriminador vía código utilizando JsonTypeInfoResolver
. Esto permite una configuración más flexible, especialmente útil cuando no puedes tocar las clases (por ejemplo, si provienen de una librería externa o un legacy).
¿Qué quiero demostrar con este ejemplo?
-
Que puedes lograr el mismo comportamiento de forma declarativa sin modificar tus modelos.
-
Que es ideal para escenarios en los que el polimorfismo depende del contexto o del entorno.
-
Que encaja bien con aplicaciones que usan inyección de dependencias (
ServiceCollection
), ya que el ejemplo lo integra conOperacionExecutor
.
Ventaja clave: separación de concerns y mayor adaptabilidad sin comprometer el tipado.
Patrón estrategia con JsonConverter
personalizado
Este es el enfoque clásico que hemos usado durante años: definimos un JsonConverter
que interpreta un campo tipo "Tipo"
en el JSON y deserializa al tipo correspondiente usando un switch
manual.
¿Qué quiero demostrar con este ejemplo?
-
Que esta técnica sigue siendo válida… pero es más verbosa y frágil.
-
Que requiere mantener código adicional cada vez que aparece una nueva subclase.
-
Que en proyectos grandes se convierte en deuda técnica rápidamente.
Valor comparativo: sirve como referencia para apreciar cuánto simplifica el soporte nativo las tareas de serialización polimórfica.
Conclusión de los ejemplos
Cada uno de estos enfoques funciona. Pero cuando los comparamos bajo criterios de productividad, claridad, rendimiento y mantenibilidad, el soporte nativo con PolymorphismOptions
y discriminadores como $type
gana en todos los frentes.
Y especialmente en escenarios reales como:
-
APIs unificadas con múltiples tipos de respuesta.
-
Mensajes en colas que representan distintas acciones.
-
Sistemas de proyección o integración que consumen datos de múltiples proveedores.
Estos ejemplos no son decorativos. Reflejan cómo puedes transformar tu arquitectura con menos código, menos errores y más claridad.
Y para terminar, cuando sacan versiones nuevas de .NET no son por capricho, si no por que las mejoras se nota:
