La evolución de .NET: Integrando capacidades de IA y móviles nativas en aplicaciones web modernas
Milan Jovanović recientemente publicó un fuerte argumento contra la versión prematura de API. El punto central: la mayoría de los equipos recurren a v2 demasiado pronto porque carecen de una estrategia de evolución de contratos. La versionado es una herramienta de compatibilidad, no una estrategia de diseño.
El argumento resuena con nuestro equipo de ingeniería en Iron Software. Enviamos bibliotecas .NET, lo que significa que la superficie pública de nuestros productos es una API. Cada firma de método, cada propiedad, cada comportamiento predeterminado es un contrato dentro de miles de bases de código de cliente. Un salto de versión mayor no es un lanzamiento. Es un proyecto de migración para todos los que están aguas abajo.
Lo que sigue es la opinión de un desarrollador sobre la pieza de Milan desde la perspectiva de un autor de biblioteca, y cómo se aplican las mismas reglas de compatibilidad ya sea que estés enviando una API REST o un paquete NuGet.
En resumen
- La versionado no es una estrategia de diseño. Es la escotilla de escape cuando la coexistencia falla.
- Los cambios graves se ocultan en el comportamiento, no solo en URL o esquemas.
- Las cuatro reglas de compatibilidad: no quitar, no cambiar el procesamiento, no endurecer la validación, mantener las adiciones opcionales.
- Una nueva operación es casi siempre más barata que una nueva versión.
- La verdadera depreciación requiere señales de tiempo de ejecución y telemetría, no solo actualizaciones de documentación.
Las reglas HTTP también se aplican a las API de bibliotecas
Milan enmarca la discusión en torno a una API REST para /orders, pero las mismas reglas se aplican cuando tu API es una clase pública de C# incluida en un paquete NuGet. La correspondencia es directa:
| Cambio de API REST | Equivalente de biblioteca NuGet |
|---|---|
| Renombrando un campo JSON | Renombrando una propiedad pública |
| Eliminando un punto final | Eliminando un método público |
| Endureciendo la validación de la solicitud | Agregando un parámetro no anulable |
| Cambiando el comportamiento de la operación | Cambiando lo que un método hace bajo el capó |
| Agregando un campo requerido | Agregando un parámetro de constructor requerido |
Si alguna vez has descargado una versión principal de una biblioteca popular de .NET y pasaste medio día arreglando APIs renombradas, has estado en el extremo receptor de una decisión de v2 que probablemente podría haberse manejado de forma aditiva.
Lo que realmente rompe a los consumidores
La lista de Milan es precisa:
- Eliminar o renombrar campos
- Cambiar el significado de los datos existentes
- Endurecer la validación de solicitudes
- Cambiar el formato de paginación o errores
- Asumir que los valores tipo enum están cerrados para siempre
El segundo punto es el que con mayor frecuencia toma a los equipos por sorpresa: cambiar el significado de los datos existentes sin cambiar su forma. El JSON luce igual. La firma C# luce igual. Todo compila. Nada lanza en tiempo de ejecución. Pero el campo ahora significa algo diferente, y cada consumidor que dependía de la semántica anterior está silenciosamente equivocado.
Ejemplo de Milan:
// Before
{ "total": 100 }
// After
{ "total": { "amount": 100, "currency": "USD" } }Mismo nombre de campo. Mismo punto final. Cada cliente que analizaba total como un número ahora está roto.
El equivalente de biblioteca es cambiar lo que un método devuelve o cómo interpreta sus entradas. Un método de Save() que antes sobrescribía y ahora añade. Un parámetro de Trim cuyo valor predeterminado cambia de true a false. Un método que solía lanzar en caso de entrada invalida y ahora devuelve un valor por defecto silenciosamente.
Las cuatro reglas de compatibilidad
Milan resume las reglas como: no quitar nada, no cambiar las reglas de procesamiento, no hacer requeridas las cosas opcionales, y todo lo que agregues debe ser opcional. Los cuatro principios son dignos de mantener al frente de cualquier equipo responsable de una API pública:
- Mantén en su lugar los campos existentes y el comportamiento.
- No conviertas los datos opcionales de solicitud en requeridos.
- No cambies lo que hace una operación existente.
- Haz que todo sea nuevo, aditivo y opcional por defecto.
Estos se aplican directamente al diseño de bibliotecas. "No quitar nada" significa no eliminar miembros públicos. "No cambiar las reglas de procesamiento" significa que los métodos existentes deben comportarse de la forma en que lo hicieron cuando se enviaron. "No hacer requerido lo opcional" significa no agregar parámetros requeridos a un método existente; proporciona una sobrecarga en su lugar. "Aditivo y opcional" significa que la nueva funcionalidad pertenece a nuevos métodos o parámetros opcionales con valores predeterminados sensatos.
Cómo se juega esto en la práctica
La forma más clara de ilustrar estas reglas es con una decisión real de API, así que aquí hay una de las nuestras.
Hace algunos lanzamientos, IronPDF necesitaba soportar un conjunto más rico de opciones de renderizado para la conversión de HTML a PDF: tamaños de papel personalizados, márgenes personalizados, emulación de medios CSS, plantillas de encabezado y pie de página, y más. El enfoque sencillo habría sido mutar el método de renderizado existente para aceptar las nuevas opciones. Esa decisión habría roto a cada cliente que usaba la forma simple de la API.
Para contexto, la biblioteca se instala a través de los canales estándar de paquetes .NET:
# .NET CLI
dotnet add package IronPdf
# Package Manager Console
Install-Package IronPdf# .NET CLI
dotnet add package IronPdf
# Package Manager Console
Install-Package IronPdfEl paquete NuGet de IronPdf ha acumulado más de 18 millones de descargas, que es parte de por qué la estabilidad de la API importa: cada cambio de ruptura se propaga a través de tantas integraciones.
El enfoque que enviamos en su lugar:
// The original, three-year-old API. Still works. Still unchanged.
var renderer = new ChromePdfRenderer();
PdfDocument pdf = renderer.RenderHtmlAsPdf("<h1>Hello</h1>");
// New rendering options live on an options object, not in the method signature.
var renderer = new ChromePdfRenderer();
renderer.RenderingOptions.PaperSize = PdfPaperSize.A4;
renderer.RenderingOptions.MarginTop = 20;
renderer.RenderingOptions.CssMediaType = PdfCssMediaType.Print;
renderer.RenderingOptions.HtmlHeader = new HtmlHeaderFooter { HtmlFragment = "..." };
PdfDocument pdf = renderer.RenderHtmlAsPdf("<h1>Hello</h1>");// The original, three-year-old API. Still works. Still unchanged.
var renderer = new ChromePdfRenderer();
PdfDocument pdf = renderer.RenderHtmlAsPdf("<h1>Hello</h1>");
// New rendering options live on an options object, not in the method signature.
var renderer = new ChromePdfRenderer();
renderer.RenderingOptions.PaperSize = PdfPaperSize.A4;
renderer.RenderingOptions.MarginTop = 20;
renderer.RenderingOptions.CssMediaType = PdfCssMediaType.Print;
renderer.RenderingOptions.HtmlHeader = new HtmlHeaderFooter { HtmlFragment = "..." };
PdfDocument pdf = renderer.RenderHtmlAsPdf("<h1>Hello</h1>");' The original, three-year-old API. Still works. Still unchanged.
Dim renderer As New ChromePdfRenderer()
Dim pdf As PdfDocument = renderer.RenderHtmlAsPdf("<h1>Hello</h1>")
' New rendering options live on an options object, not in the method signature.
renderer = New ChromePdfRenderer()
renderer.RenderingOptions.PaperSize = PdfPaperSize.A4
renderer.RenderingOptions.MarginTop = 20
renderer.RenderingOptions.CssMediaType = PdfCssMediaType.Print
renderer.RenderingOptions.HtmlHeader = New HtmlHeaderFooter With {.HtmlFragment = "..."}
pdf = renderer.RenderHtmlAsPdf("<h1>Hello</h1>")Tres observaciones sobre la decisión:
- La firma original de
RenderHtmlAsPdf(string html)no cambia. Los clientes que mejoraron no tuvieron que modificar una línea de código. - Las nuevas capacidades viven en un objeto de opciones al que los consumidores optan ingresar. El método no tiene nuevos parámetros requeridos.
- Los valores predeterminados en
RenderingOptionsproducen una salida equivalente a la API anterior. El comportamiento no cambia para quien no configure nada.
Esas son las reglas 1, 2 y 4 de la lista de Milan aplicadas de una vez. El producto evolucionó. El contrato no.
La tentación de enviar RenderHtmlAsPdfV2(string html, RenderingOptions options) era real. Se habría visto más ordenado en la página de referencia del API. Habría costado a cada cliente una migración. Elegimos otra cosa.
Lectores tolerantes
La otra mitad del argumento de agregar-no-reemplazar de Milan es que los consumidores también tienen responsabilidad. Un cliente bien comportado debería ignorar los campos que no entiende.
En .NET, System.Text.Json ignora propiedades desconocidas por defecto, lo cual es el valor predeterminado correcto. El riesgo usualmente aparece en dos lugares:
- SDKs generados con esquemas estrictos que rechazan campos inesperados
- Pruebas de contrato que afirman igualdad exacta de JSON
Ambos convierten una garantía de 'ignoramos campos desconocidos' en una trampa. Si tu CI se rompe en el momento en que el servidor añade una nueva propiedad opcional, no tienes compatibilidad hacia atrás. Tienes un detector de regresión disfrazado de política de compatibilidad.
El comportamiento es parte del contrato
La sección de Milan sobre DELETE /orders/{id} cambiando silenciosamente de eliminación suave a dura es el tratamiento más claro de este problema que hemos visto.
El URL es el mismo. El cuerpo de la solicitud es el mismo. La forma de la respuesta es la misma. Lo que la operación hace en el servidor es diferente.
Esta es la categoría más peligrosa de cambio rompedor porque nada en un diff de esquema lo detecta. La especificación OpenAPI es idéntica. El cliente generado compila. Las pruebas de integración pasan. Y cada consumidor que construyó herramientas alrededor de 'órdenes eliminadas son recuperables' destruyen datos silenciosamente en producción.
El equivalente en la biblioteca es cambiar lo que hace un método sin cambiar su firma. Ejemplos que hemos evitado explícitamente:
- Un método de
Save()que anteriormente se vaciaba sincrónicamente pasa silenciosamente a ser async-fire-and-forget - Un método de OCR que devolvía resultados en bruto y comienza a procesarlos posteriormente
- Un lector de códigos de barras que arrojaba un error en la entrada no legible y comienza a devolver una cadena vacía
Cada uno de estos es una ruptura de contrato disfrazada de mejora. La respuesta correcta es la misma que la de Milan: añade un nuevo método u opción, deja el viejo comportamiento sin cambios, y depreca el viejo camino solo cuando la telemetría indica que es seguro hacerlo.
Endurecimiento de la validación
Esta categoría eventualmente afecta a cada equipo. Hay dos variantes del mismo error:
- Tomar un campo opcional existente y hacerlo requerido
- Añadir un nuevo campo y marcarlo como requerido desde el primer día
Ambos rompen clientes más antiguos. La ruta del punto final no se mueve, pero las solicitudes que previamente tuvieron éxito ahora fallan en tiempo de ejecución.
La versión de biblioteca de este error es añadir un parámetro de constructor requerido o hacer obligatorio un parámetro opcional existente. Cada llamador existente se rompe en tiempo de compilación, lo cual es preferible a la falla en tiempo de ejecución, pero aún impone un costo de migración en cada consumidor.
Caminos más seguros:
- Aceptar valores faltantes durante una ventana de transición e inferir valores predeterminados donde sea posible.
- Añadir una nueva sobrecarga o constructor que requiera la forma de entrada más rica.
- Introducir una nueva operación o constructor para el flujo de trabajo más estricto.
La regla subyacente es coherente: cualquier cosa añadida al contrato debe ser opcional, y cualquier cosa previamente opcional debe seguir siendo opcional. Si realmente se necesitan requisitos más estrictos, pertenecen a una nueva operación, no a un endurecimiento de la existente.
Una nueva operación es casi siempre más barata que una nueva versión
Este es el principio que vale más la pena internalizar.
Cuando un caso de uso realmente ha evolucionado más allá de lo que un punto final existente admite limpiamente, el reflejo común es sobrecargar el punto final con banderas:
POST /orders?validateOnly=true&includeTaxEstimate=true&reserveInventory=trueO, de manera más disruptiva, declarar el cambio como un problema de versionado y comenzar a trabajar en /v2/orders. Ambos son usualmente incorrectos. El enfoque más limpio es una nueva operación junto a la existente:
POST /orders
POST /orders/quote
POST /checkout-sessionsCada operación tiene un contrato limpio, permisos distintivos, validación independiente, y su propio camino de evolución. El punto final original permanece simple. El resto del API no es arrastrado a un gran aumento de versión.
En un contexto de biblioteca, el equivalente es añadir un nuevo método en lugar de sobrecargar uno existente con parámetros opcionales hasta que se vuelva ilegible. ExtractText() permanece como el extractor de texto simple. ExtractTextWithLayout() se convierte en la variante más rica. ExtractStructuredDocument() se convierte en la más rica. Tres métodos con contratos claros son preferibles a un método con ocho parámetros opcionales.
Despreciar deliberadamente
Esta es la mitad de la gestión del cambio de API que la mayoría de los equipos omite, y es la mitad que determina si la estrategia funciona.
La deprecación real no es una nota en un registro de cambios. Involucra cuatro pasos:
- Marca el campo o endpoint como obsoleto en la descripción de OpenAPI (o con el atributo
[Obsolete]en el mundo .NET). - Señalar la deprecación en tiempo de ejecución para que el tráfico en vivo lo evidencie.
- Enlazar a una guía de migración real.
- Medir el uso con telemetría para determinar cuándo es seguro eliminarlo.
Para las APIs HTTP, la señalización en tiempo de ejecución es sencilla:
Deprecation: true
Sunset: Wed, 31 Dec 2026 23:59:59 GMT
Link: <https://docs.example.com/migrations/orders-total>; rel="deprecation"Para bibliotecas .NET, el equivalente es una [Obsolete("Use NewMethod instead. Esto se eliminará en v2026.x", DiagnosticId = "IRON001")] attribute paired with a UrlFormat apuntando a una página de migración. La advertencia del compilador aparece en la salida de build de cada consumidor, el identificador de diagnóstico permite la supresión deliberada, y el enlace proporciona a los consumidores un camino de migración documentado.
El paso de telemetría no es negociable. Sin saber qué clientes todavía dependen del método obsoleto, la eliminación se convierte en una conjetura. El resultado es una eliminación prematura que rompe integraciones activas, o un costo de transporte indefinido que derrota el propósito de la deprecación.
Cuando la versión es la decisión correcta
Milan no está en contra de la versión, y nosotros tampoco. La versión es apropiada cuando:
- La semántica antigua y nueva realmente no pueden coexistir
- El modelo de recursos ha cambiado fundamentalmente
- Las reglas de compatibilidad forzarían un contrato que nadie puede razonar
El punto no es evitar por completo la versión. El punto es acudir a ella porque la coexistencia ha fallado, no porque fue la primera idea sobre la mesa.
Cuando la versión es necesaria, debe ir acompañada de un proceso de deprecación real. El trabajo difícil no es enviar v2. El trabajo difícil es alejar a los consumidores de v1.
La regla de decisión
El marco de Milan es el correcto para aplicar:
- ¿Puedo añadir en lugar de reemplazar?
- ¿Pueden coexistir los contratos antiguos y nuevos durante una ventana de migración?
- ¿Puedo introducir una nueva operación en lugar de mutar una antigua?
- ¿Puedo deprecar la vieja forma con documentación, encabezados y telemetría?
Si la respuesta es sí a los cuatro, es probable que una nueva versión no sea necesaria. Si la respuesta es no, y los dos mundos realmente no pueden coexistir, versiona deliberadamente.
Diseña contratos para evolucionar. Trata a los consumidores como integraciones de larga duración en lugar de código de hoy. Reserva la versionado para los casos donde la compatibilidad realmente ha sido agotada.
Para el artículo completo, incluidos ejemplos más largos trabajados, lee la publicación original de Milan.
Al seleccionar una biblioteca .NET de la que depender, la pregunta que vale la pena hacer es la que el post de Milan está construido alrededor: ¿esta biblioteca seguirá pareciendo el API contra el que integré en tres años?
Esa es la pregunta que trabajamos para responder con cada lanzamiento. Las llamadas simples de 2020 todavía funcionan. Nuevas capacidades están junto a ellas, opcionales y aditivas. No hay migraciones de versiones mayores forzadas.
Si ese enfoque de diseño de biblioteca coincide con lo que necesitas, comienza una prueba gratuita de 30 días y revisa la referencia del API por ti mismo. El inicio rápido de cinco minutos explica la instalación, activación de la licencia y un primer PDF renderizado. El paquete en sí está a un comando de distancia de cualquier proyecto .NET:
Para entornos donde NuGet no es la ruta preferida, la descarga directa proporciona el DLL y el instalador de Windows.
