Migrar de Newtonsoft.Json 13 a System.Text.Json en una base de código grande de .NET 11
Una guía con versiones fijadas para reemplazar Newtonsoft.Json 13.0.4 por el System.Text.Json integrado en .NET 11: los mapeos de atributos y opciones, los valores predeterminados que cambian en silencio tu formato de salida, una estrategia de despliegue por etapas, la verificación y los problemas que afectan a las bases de código grandes.
Reemplazar Newtonsoft.Json por System.Text.Json en una base de código grande rara vez es un trabajo de buscar y reemplazar. Las dos bibliotecas difieren en sus valores predeterminados de maneras que cambian tu salida serializada y rompen la deserialización en silencio, así que un reemplazo ingenuo envía un cambio de contrato a cada consumidor de tu JSON. Reserva unos días para un servicio pequeño y de dos a cuatro semanas para una base de código extensa con convertidores personalizados, payloads polimórficos y análisis con dynamic/JObject. La ganancia es real: System.Text.Json se incluye de fábrica con el runtime, serializa aproximadamente el doble de rápido con una fracción de las asignaciones, y es el único de los dos que se ejecuta bajo Native AOT. Este artículo fija Newtonsoft.Json 13.0.4 (la versión estable actual, lanzada el 2025-12-30) como origen y el System.Text.Json integrado en el .NET 11 SDK con C# 14 como destino. Si todavía estás decidiendo si moverte o no, lee primero System.Text.Json vs Newtonsoft.Json en 2026; este artículo asume que ya decidiste migrar.
Por qué migrar ahora
System.Text.Jsonforma parte del framework compartido en .NET 11. Eliminar elPackageReferencedeNewtonsoft.Jsonquita una dependencia transitiva que el runtime, ASP.NET Core y la plataforma de pruebas han estado desprendiendo activamente.- Throughput. En payloads POCO típicos,
System.Text.Jsonserializa alrededor de 2x más rápido queNewtonsoft.Jsoncon asignaciones marcadamente menores, porque trabaja directamente sobre bytes UTF-8 conUtf8JsonReaderyUtf8JsonWriteren lugar de pasar porstringyTextReader. - Native AOT y trimming.
Newtonsoft.Jsondepende de la reflexión y no funciona bajo Native AOT.System.Text.Jsontiene un modo de generador de código fuente (JsonSerializerContext) que emite metadatos de serialización compatibles con AOT y seguros para el trimming en tiempo de compilación. Si Native AOT está en tu hoja de ruta, esta migración es un prerrequisito, no una optimización. - Postura de seguridad.
System.Text.Jsones estricto por defecto (RFC 8259), escapa caracteres sensibles a HTML y no ASCII en la salida, y no interpreta JSON mal formado. Eso elimina una clase de sorpresas de inyección y análisis que los valores predeterminados permisivos deNewtonsoft.Jsonpermiten.
Qué se rompe
El peligro de esta migración no es el código que no compila. Es el código que compila bien y cambia tu formato de salida. Esta tabla es la que hay que leer dos veces.
| Área | Cambio | Severidad |
|---|---|---|
| Coincidencia de nombres de propiedad | Newtonsoft.Json no distingue mayúsculas al leer por defecto; System.Text.Json sí distingue | alta |
| Comentarios y comas finales | Aceptados por defecto en Newtonsoft.Json, lanzan JsonException en System.Text.Json | alta |
| JSON con comillas simples / sin comillas | Aceptado por Newtonsoft.Json, rechazado por diseño en System.Text.Json | alta |
Valor no-string en propiedad string | Newtonsoft.Json convierte 1 o true; System.Text.Json lanza una excepción | alta |
| Números entre comillas | Newtonsoft.Json lee "23" en un int; System.Text.Json necesita NumberHandling | media |
| Escape de caracteres | System.Text.Json escapa de forma más agresiva, así que los bytes de salida difieren para no-ASCII y HTML | media |
[JsonProperty("name")] | Se convierte en [JsonPropertyName("name")]; no hay opciones combinadas de ignore/required en un atributo | media |
TypeNameHandling.All | No hay equivalente, por diseño. El polimorfismo usa [JsonDerivedType] en su lugar | alta |
JObject / JToken / dynamic | Reemplazados por JsonNode / JsonDocument / JsonElement con una API diferente | media |
JsonConvert.PopulateObject | No hay equivalente integrado; necesita un convertidor personalizado o una fusión manual | media |
ReferenceLoopHandling.Ignore | No hay modo “descartar el bucle en silencio”; obtienes ReferenceHandler.Preserve o rediseñas el grafo | media |
DateFormatString, DateTimeZoneHandling | No hay opción global de formato de fecha; necesita un JsonConverter<DateTime> personalizado | media |
| Precedencia de registro de convertidores | La colección Converters ahora anula un atributo a nivel de tipo (invertido respecto a Newtonsoft.Json) | baja |
La referencia autoritativa para cada fila aquí es la guía de migración de Microsoft, que también lista el puñado de características (consultas JsonPath, TypeNameHandling.All, análisis de comillas simples) que no tienen solución alternativa.
Lista de comprobación previa
Haz todo esto antes de borrar un solo using Newtonsoft.Json.
- Fija el contrato. Si tu JSON cruza un límite de proceso (una API pública, una cola de mensajes, una columna persistida), captura muestras de referencia de la salida actual. Serializa un conjunto representativo de objetos con
Newtonsoft.Json13.0.4 y guarda las cadenas como fixtures de prueba. Estas son tu oráculo de regresión. - Inventaría la superficie. Busca con grep todo lo que toque la biblioteca antigua para conocer el tamaño del trabajo:
Verifica: la lista de archivos coincide con tu modelo mental de dónde vive la serialización. Las sorpresas aquí (un convertidor enterrado en un ayudante de registro) son exactamente lo que quieres encontrar ahora.# run from the repo root; counts the call sites you have to touch grep -rEl "Newtonsoft\.Json|JsonConvert|JObject|JArray|JToken|JsonProperty|JsonSerializerSettings" --include="*.cs" . - Marca las características sin solución alternativa. Busca específicamente
TypeNameHandling,SelectToken,SelectTokensy fixtures de prueba con comillas simples. Si encuentrasTypeNameHandling.Allo.Auto, detente y diseña el reemplazo del polimorfismo antes de continuar, porque no hay un sustituto directo para ello. - Confirma el destino. Ejecuta
dotnet --versiony confirma11.0.x.System.Text.Jsonviene integrado, así que no hay paquete que agregar para los escenarios principales; solo agregasSystem.Text.Jsoncomo unPackageReferenceexplícito si necesitas una versión out-of-band más nueva que la que incluye el SDK.
Pasos de migración
-
Mapea las opciones globales.
Newtonsoft.Jsoncentraliza el comportamiento enJsonSerializerSettings.System.Text.JsonusaJsonSerializerOptions. Traduce tu objeto de configuración existente campo por campo; no aceptes los valores predeterminados deSystem.Text.Jsona ciegas, porque difieren de lo que tu código ha estado emitiendo durante años.// .NET 11, C# 14 // BEFORE: Newtonsoft.Json 13.0.4 var settings = new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver(), NullValueHandling = NullValueHandling.Ignore, ReferenceLoopHandling = ReferenceLoopHandling.Ignore, }; // AFTER: System.Text.Json (in-box on .NET 11) var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, // restore Newtonsoft read behavior DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, ReferenceHandler = ReferenceHandler.IgnoreCycles, // closest match to ReferenceLoopHandling.Ignore // AllowTrailingCommas = true, // uncomment only if your inputs have them // ReadCommentHandling = JsonCommentHandling.Skip, // uncomment only if your inputs have comments };Verifica: serializa tus objetos de muestra de referencia con estas
optionsy compáralos con los fixtures del paso 1 del preflight. La diferencia debería estar vacía o explicada.PropertyNameCaseInsensitive = truees la línea más importante para las bases de código grandes, porque innumerables rutas de deserialización dependen silenciosamente de la coincidencia sin distinción de mayúsculas deNewtonsoft.Json. -
Reemplaza los atributos.
El renombrado de atributos es mecánico, pero
[JsonProperty]empaquetaba varias responsabilidades en un solo atributo queSystem.Text.Jsonsepara.// .NET 11, C# 14 // BEFORE public class Order { [JsonProperty("order_id")] public int Id { get; set; } [JsonProperty("notes", NullValueHandling = NullValueHandling.Ignore)] public string? Notes { get; set; } [JsonProperty(Required = Required.Always)] public string Customer { get; set; } = ""; [JsonIgnore] public string Internal { get; set; } = ""; } // AFTER public class Order { [JsonPropertyName("order_id")] public int Id { get; set; } [JsonPropertyName("notes")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Notes { get; set; } [JsonRequired] // or the C# `required` modifier public string Customer { get; set; } = ""; [JsonIgnore] public string Internal { get; set; } = ""; }Verifica: el proyecto compila con cero directivas
usingdeNewtonsoft.Jsonen el ensamblado de modelos, y una prueba de ida y vuelta (Deserialize(Serialize(order))) preserva cada campo. -
Porta los convertidores personalizados.
Aquí es donde se van las horas. Las formas son similares pero los contratos difieren: los convertidores de
System.Text.Jsontrabajan sobreUtf8JsonReader(unref struct) yUtf8JsonWriter, yReadse llama posicionado en el primer token.// .NET 11, C# 14 -- a converter that reads/writes DateTime in a fixed format, // replacing Newtonsoft's DateFormatString / DateTimeZoneHandling settings. public sealed class Iso8601DateTimeConverter : JsonConverter<DateTime> { public override DateTime Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions o) => DateTime.ParseExact(reader.GetString()!, "yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal); public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions o) => writer.WriteStringValue(value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture)); }Regístralo en
options.Converters, no solo como un atributo de tipo, y nota el cambio de precedencia: enSystem.Text.Jsonun convertidor en la colecciónConvertersanula un atributo[JsonConverter]a nivel de tipo, lo contrario deNewtonsoft.Json. Los detalles mecánicos están en cómo escribir un JsonConverter personalizado en System.Text.Json. Verifica cada convertidor portado contra su propio fixture, no solo el payload de extremo a extremo, para que una sorpresa de precedencia no se esconda detrás de una prueba de integración que pasa. -
Reemplaza el polimorfismo y TypeNameHandling.
Si usabas
TypeNameHandlingpara hacer un ida y vuelta de una jerarquía de clases, no hay equivalente, y eso es deliberado:TypeNameHandling.Alles un vector de ejecución remota de código bien conocido.System.Text.Jsonhace polimorfismo discriminado con atributos en el tipo base.// .NET 11, C# 14 [JsonDerivedType(typeof(Dog), typeDiscriminator: "dog")] [JsonDerivedType(typeof(Cat), typeDiscriminator: "cat")] public abstract class Animal { public string Name { get; set; } = ""; } public sealed class Dog : Animal { public bool GoodBoy { get; set; } } public sealed class Cat : Animal { public int Lives { get; set; } }Esto emite un discriminador
"$type": "dog"y lo lee de vuelta al subtipo correcto. Verifica: serializa unaList<Animal>de subtipos mezclados, deserialízala y comprueba que los tipos en tiempo de ejecución sobreviven. Nota que el formato de salida cambió (una cadena discriminadora explícita en lugar del$typecalificado por ensamblado deNewtonsoft.Json), así que cualquier consumidor externo debe actualizarse al mismo tiempo. -
Convierte el análisis con dynamic y JObject.
El código que hurga en JSON sin tipo vía
JObject/JToken/dynamicpasa aJsonNode(mutable) oJsonDocument/JsonElement(de solo lectura, agrupado en un pool).// .NET 11, C# 14 // BEFORE: JObject o = JObject.Parse(json); var name = (string)o["user"]!["name"]!; JsonNode root = JsonNode.Parse(json)!; string name = root["user"]!["name"]!.GetValue<string>();La única trampa:
JsonDocumentposee un búfer agrupado en un pool y esIDisposable, a diferencia deJObject. Envuélvelo en unusingo filtrarás el búfer alquilado. PrefiereJsonNodecuando necesites un árbol mutable parecido aJObject. Verifica: cada antigua ruta de acceso aJObjecttiene una prueba unitaria que ejercita las mismas búsquedas de clave. -
Cambia la integración de ASP.NET Core.
Si la base de código llama a
AddNewtonsoftJson()enProgram.cs, eliminarlo conmuta todo el pipeline aSystem.Text.Json. Los valores predeterminados web de ASP.NET Core ya habilitan camelCase, la coincidencia sin distinción de mayúsculas y la lectura de números entre comillas, así que muchas de tus opciones manuales se vuelven redundantes en la ruta MVC.// .NET 11, C# 14 // BEFORE: builder.Services.AddControllers().AddNewtonsoftJson(); builder.Services.AddControllers().AddJsonOptions(o => { o.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; // camelCase + case-insensitive are already on by ASP.NET Core web defaults });Atención al límite de profundidad: ASP.NET Core limita
MaxDepthdeSystem.Text.Jsona 32, no al valor predeterminado de la biblioteca de 64. Los payloads profundamente anidados que funcionaban conAddNewtonsoftJson()pueden empezar a lanzar excepciones. Verifica: ejecuta las pruebas de integración del controlador y confirma que ningún payload supera el límite de profundidad.
Verificación
Ejecuta esta lista de pruebas de humo después de cada PR, no solo al final:
- La solución compila con cero referencias a
Newtonsoft.Jsonen los proyectos migrados (grep -r "Newtonsoft" --include="*.csproj"no devuelve nada para esos proyectos). - La diferencia de muestras de referencia del paso 1 del preflight está vacía o cada diferencia está documentada y es intencional.
- Toda la suite de pruebas pasa:
dotnet testinforma cero fallos. - Una prueba de ida y vuelta (
Deserialize(Serialize(x))) se cumple para cada modelo con un convertidor personalizado o una jerarquía polimórfica. - Para las rutas calientes, ejecuta una comparación rápida con
BenchmarkDotNety confirma que los números de throughput y asignaciones se movieron en la dirección correcta en lugar de retroceder por un accidentalnew JsonSerializerOptions()por llamada (siempre cachea y reutiliza la instancia de options; construirla en cada llamada es la regresión de rendimiento más común en esta migración).
Plan de reversión
Esta migración es reversible por proyecto pero no trivialmente, porque el paso 1 cambia tu formato de salida. La estrategia limpia es un enfoque strangler: migra un ensamblado o un endpoint a la vez, mantén Newtonsoft.Json referenciado hasta que el último consumidor se haya movido, y protege los endpoints riesgosos detrás de un feature flag que pueda enrutar de vuelta al formateador de Newtonsoft.Json. Una vez que hayas borrado el PackageReference y enviado el nuevo formato de salida a los consumidores externos, revertir significa volver a agregar el paquete y deshacer el cambio de formato en todas partes a la vez, lo cual es una versión coordinada, no un git revert. No borres la referencia al paquete hasta que las diferencias de muestras de referencia hayan estado en verde en la telemetría de producción durante al menos un ciclo de versión.
Problemas que encontramos
- Pérdida silenciosa de datos por distinción de mayúsculas. Un objeto de configuración deserializado de un archivo con claves
PascalCasevolvió con cada propiedad en su valor predeterminado porqueSystem.Text.Jsoncoincidió distinguiendo mayúsculas contra los miembros en camelCase. Nada lanzó una excepción. La solución fuePropertyNameCaseInsensitive = true, y la lección fue verificar valores, no solo si “se analizó”. - Los ida y vuelta de
DateTimese desviaron. ElDateTimeZoneHandlingdeNewtonsoft.Jsonhabía estado normalizando timestamps en silencio.System.Text.Jsonlee el formato de ida y vuelta ISO 8601 y preserva el offset, así que los timestamps almacenados volvieron con un kind diferente. El convertidor personalizado del paso 3 más la corrección de el valor JSON no se pudo convertir a System.DateTime lo resolvió. - Los ciclos de objetos lanzaban excepciones en lugar de descartarse.
ReferenceLoopHandling.Ignorehabía estado enmascarando una referencia circular genuina en una propiedad de navegación de EF Core.System.Text.Jsonla sacó a la superficie como se detectó un posible ciclo de objetos.ReferenceHandler.IgnoreCycleses el puente, pero la mejor solución fue un DTO de proyección que no tenía el bucle en absoluto. - Un
new JsonSerializerOptions()por solicitud hundió el throughput. Construir el objeto de options dentro de un handler caliente derrota la caché de metadatos interna y fue más lento que el código deNewtonsoft.Jsonque reemplazó. Cachea unstatic readonly JsonSerializerOptionsy reutilízalo.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.