Mihael Costa/diario de migración
← índice
MIGRACIÓN DE LEGADO

De .NET Framework 4.8 a .NET 8, decisión por decisión

Un back office de fletes construido en 2011: MVC 5, unWeb.config de 304 líneas, una acción de controlador de 272 líneas y cuatro dependencias sin sucesor. Estrangulado una ruta a la vez, con los fallos incluidos.

  • .NET 8
  • EF Core
  • YARP
  • Strangler fig
00Sistema completo

La aplicación que heredamos

Qué era realmente Meridian Despatch, medida en lugar de caracterizada, antes de que nada se moviera.

Meridian Despatch gestiona envíos de mercancía. Un administrativo registra una recogida, el sistema la tarifica, una etiqueta se imprime en un depósito, un manifiesto sale de madrugada y una factura llega a final de mes. Lleva en servicio desde 2011 y funciona, que es lo primero que vale la pena decir de ella: nadie está migrando esto porque esté roto.

Antes de describirla, esto es lo que mide. Los adjetivos sobre código legado son baratos; los números no.

Meridian.Web en el commit en que la heredamos
C#
1.331 líneas en 20 archivos
Web.config
304 líneas
Binding redirects
18
Claves en appSettings
22, de las cuales 12 son reglas de negocio
Paquetes NuGet
22, vía packages.config
Entradas Compile
18, mantenidas a mano en el .csproj
Método más largo
ConsignmentsController.Create: 272 líneas
Tests
0

El método que es la migración

ConsignmentsController.Create tiene 272 líneas. En una sola acción deriva el peso facturable, deduce la zona de reparto de una cadena de prefijos postales, abre una SqlConnection para consultar una banda tarifaria, aplica recargos de combustible, zona remota, entrega programada y mercancía peligrosa, aplica un descuento de contrato que se comporta distinto para tres clientes, calcula el IVA, hace una comprobación de duplicados concatenando los valores del formulario en un SELECT COUNT(*), guarda vía EF6, manda una cadena de ancho fijo a MSMQ, llama a un servicio SOAP y escribe dos filas de auditoría.

No hay capa de servicio a la que moverlo. No hay costura. No está tan mal escrito como indistinto: nueve años de decisiones pequeñas y correctas, cada una con sentido el día que se tomó, ninguna consolidada nunca.

Ese método es la migración. Todo lo demás es preparación para poder tocarlo.

Cuatro dependencias sin sucesor

Esta es la parte que decide si una migración es un proyecto o una aventura. Las diferencias de framework y de lenguaje son trabajo; una dependencia que simplemente no existe al otro lado es una decisión.

Sin equivalente en .NET 8
System.Messaging
MSMQ, el traspaso al depósito. Nunca se portó a .NET Core y nunca se hará. Sin shim, sin paquete de compatibilidad.
Microsoft.ReportViewer
Veinte informes RDLC escritos en el diseñador de informes de Visual Studio entre 2011 y 2017. El formato no tiene otro renderizador.
MachineKey
La autenticación por formularios cifra su cookie con él. ASP.NET Core tiene Data Protection en su lugar, que es un algoritmo distinto.
System.Drawing
Etiquetas de expedición, incluido un código de barras dibujado a mano a partir de una tabla Code 39 porque el componente con licencia se perdió en 2012.

Dos defectos dejados a propósito

El buscador concatena su contenido en una cláusula WHERE. Se reportó internamente en 2019 como MER-2213 y sigue abierto. La asignación de referencia es MAX(...) + 1 sin ningún bloqueo, así que dos administrativos que registran en el mismo segundo chocan y el segundo se lleva una pantalla amarilla.

Ambos se dejaron exactamente como se encontraron en este primer commit. Arreglarlos aquí significaría que la línea base ya no es la cosa que se está migrando, y el arnés de paridad que llega en la etapa 03 estaría comparando el código nuevo contra un oráculo que nadie ejecutó nunca en producción.

Se arreglan más adelante, en las etapas en las que ese código se estaba reescribiendo de todas formas. Eso no es una virtud: es como se cierran de verdad los tickets que llevan años abiertos.

Qué es esto, y qué no

Este es un sistema construido a propósito. El código de un antiguo cliente no es mío para publicarlo, así que Meridian se escribió para cargar con las patologías concretas que encarecen estas migraciones, y luego se migró de verdad.

Las trampas son reales. System.Messaging de verdad no tiene sucesor. MachineKey de verdad no lo puede leer Data Protection. EF Core de verdad asume (18,2) para decimal donde a EF6 se le había dicho (18,4), y de verdad lo hace en silencio. Cada una de esas cosas está descrita más abajo porque mordió a esta base de código, en este repositorio, con un commit que la arregló.

Lo que no se afirma en ningún punto de este diario es un incidente de producción. Nada de aquí dice que llamó un cliente. Donde una etapa da un coste, es el coste de encontrarlo aquí.

01Enrutamiento

Un gateway delante de todo, enrutando nada

Un proxy inverso entró primero, mandando el 100% del tráfico a la aplicación que se suponía que iba a sustituir.

El primer commit de la migración no migra nada. Pone YARP delante de la aplicación con una única ruta catch-all que apunta a la misma máquina IIS que ya servía todas las peticiones.

Ningún usuario vio diferencia. Ningún código se movió. Es una semana difícil de justificar en una daily, y es el primer movimiento correcto.

Camino de la petición, etapa 01
Antes
  1. Navegadorcliente
  2. IISmeridian-app01
  3. Meridian.Web.NET 4.8
  4. SQL Serverbase de datos
Después
  1. Navegadorcliente
  2. GatewayYARP · .NET 8
  3. IISmeridian-app01
  4. Meridian.Web.NET 4.8
  5. SQL Serverbase de datos
sin cambiosnuevo

Una caja nueva, y todas las rutas siguen terminando en el mismo sitio. Lo único que cambió es que ahora hay dónde poner una decisión.

Por qué esto va primero y no tercero

El patrón strangler es una frase: mueve una ruta cada vez a la aplicación nueva. Esa frase exige algo que sea dueño de la tabla de enrutamiento.

Sin eso, el primer módulo que se mueve tiene que moverse por DNS, por una regla de rewrite de IIS, o por una ventana de despliegue, y cada una de esas mueve todo de golpe para alguna clase de usuario. Una migración que no puede dirigir tráfico por ruta no tiene etapas. Tiene un cutover, disfrazado.

Hacerlo primero tiene una segunda ventaja que en la práctica importa más. Es el cambio de infraestructura más arriesgado de todo el proyecto (un salto nuevo en cada petición, con sus propios timeouts, manejo de cabeceras y pool de conexiones), y aterriza mientras exactamente nada depende de él. Si el proxy va a estropear algo, la semana de descubrirlo es la semana en que revertir significa borrar un contenedor.

La tabla de rutas es la barra de progreso

etapa 01etapa 09−1  +14

El mismo archivo, ocho etapas después. Cada ruta que nombra el clúster moderno es un trozo de la aplicación que se ha movido; el catch-all en el orden 100 es todo lo que no.


          
          "Routes": {
        
          
            "ratecards-new": {
        
          
              "ClusterId": "modern",
        
          
              "Match": { "Path": "/ratecards/{**catch-all}" },
        
          
              "Order": 1
        
          
            },
        
          
            "consignments-api-new": {
        
          
              "ClusterId": "modern",
        
          
              "Match": {
        
          
                "Path": "/consignments/{**catch-all}",
        
          
                "Headers": [ { "Name": "Accept", "Values": [ "application/json" ] } ]
        
          
              },
        
          
              "Order": 2
        
          
            },
        
          
            "everything-legacy": {
        
          
            "everything-else-legacy": {
        
          
            "ClusterId": "legacy",
        
          
            "Match": { "Path": "{**catch-all}" },
        
          
            "Order": 100
        
          
          }
        
          
          }
        

Los números de orden bajos son las rutas migradas, así que las nuevas se añaden arriba y el catch-all nunca tiene que moverse. Leer este archivo de arriba abajo es leer la migración en orden.

Qué se rompió

Qué se rompióDos días, la mayoría mirando al proxy
Síntoma
Inicia sesión, envía cualquier formulario, y el navegador acaba en una URL que contiene el puerto interno (meridian-app01:52341), inalcanzable desde fuera. Todos los GET funcionaban.
Causa
Meridian.Web construye URLs absolutas a partir de Request.Url. Detrás de un proxy esa es la dirección del salto interno, no la que pidió el cliente. Solo el redirect-después-de-POST lo expone, porque solo los redirects meten una URL absoluta construida por el servidor en una cabecera Location.
Detectado por
A mano, usando la aplicación. Ningún test cubría un destino de redirect, y ninguna línea de log estaba mal: la aplicación se comportaba exactamente como estaba escrita.

El instinto fue que un proxy nuevo había roto algo, así que se fueron dos días en el reenvío de cabeceras de YARP antes de que nadie leyera Request.Url. El proxy estuvo correcto todo el tiempo. La aplicación siempre se había equivocado sobre su propia dirección; nunca nadie se lo había preguntado.

El arreglo del lado 4.8 es una URL base consciente de X-Forwarded-*. Del lado .NET 8 es UseForwardedHeaders, que está en el commit de la etapa 04 y no costó nada, porque para entonces era un problema conocido y no uno descubierto.

Coste

Cada petición tiene ahora un salto de red extra, un conjunto extra de timeouts que ajustar, y un segundo sitio donde mirar cuando algo va lento. El gateway se convierte además en un punto único de fallo para una aplicación que antes tenía uno menos.

Compró

El tráfico se puede dirigir por ruta. Cada etapa posterior es un cambio de configuración en un archivo, reversible en segundos, en lugar de una ventana de despliegue.

02Tarificación

Las reglas de tarificación, sacadas del controlador

Un proyecto multi-target que referencian las dos mitades, para que las reglas de precio existan una sola vez mientras hay dos aplicaciones vivas.

Una migración strangler significa dos aplicaciones corriendo a la vez, durante meses. Las dos tarifican envíos. Eso deja tres opciones, y solo una de ellas es sobrevivible.

Copiar las reglas a la aplicación nueva y mantener las dos: cada cambio de tarifa hay que hacerlo ahora dos veces, bien, por quien coja el ticket. Llamar de vuelta a la aplicación vieja por HTTP: el código nuevo depende de aquello que está sustituyendo, y la dependencia apunta al lado equivocado durante toda la migración.

O poner las reglas en un solo proyecto que los dos runtimes puedan referenciar.

Meridian.Web.csproj (fragmento)Meridian.Domain.csproj (archivo entero)−18  +7

El proyecto viejo lista cada archivo fuente a mano y cada referencia por HintPath hacia ..\packages\. El nuevo tiene diecinueve líneas y hace multi-target sobre los dos runtimes.


          
          <Project ToolsVersion="15.0" DefaultTargets="Build"
        
          
                   xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
        
          
            <PropertyGroup>
        
          
              <TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
        
          
            </PropertyGroup>
        
          
            <ItemGroup>
        
          
              <Reference Include="EntityFramework, Version=6.0.0.0, Culture=neutral, ...">
        
          
                <HintPath>..\packages\EntityFramework.6.4.4\lib\net45\EntityFramework.dll</HintPath>
        
          
              </Reference>
        
          
              <Reference Include="System.Web.Mvc, Version=5.2.9.0, ...">
        
          
                <HintPath>..\packages\Microsoft.AspNet.Mvc.5.2.9\lib\net45\System.Web.Mvc.dll</HintPath>
        
          
              </Reference>
        
          
            </ItemGroup>
        
          
            <ItemGroup>
        
          
              <Compile Include="Controllers\ConsignmentsController.cs" />
        
          
              <Compile Include="Data\ConsignmentRepository.cs" />
        
          
            </ItemGroup>
        
          
          </Project>
        
          
          <Project Sdk="Microsoft.NET.Sdk">
        
          
            <PropertyGroup>
        
          
              <TargetFrameworks>net48;net8.0</TargetFrameworks>
        
          
              <LangVersion>latest</LangVersion>
        
          
              <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
        
          
            </PropertyGroup>
        
          
          </Project>
        

Un .csproj, dos targets. La aplicación 4.8 lo referencia en net48, la API .NET 8 referencia el mismo archivo en net8.0. No es una copia, ni un paquete NuGet recortado de él: es el mismo proyecto.

El precio del puente

Todo lo que hay en ese proyecto tiene que compilar en los dos. Sin anotaciones de referencia anulable, sin records, sin System.Text.Json, sin setters init-only, sin namespaces con ámbito de archivo. Se lee como C# de 2015 porque tiene que hacerlo.

Ese es un coste real y vale la pena nombrarlo: el proyecto compartido es el código menos moderno de la aplicación nueva, y es el código que hace el trabajo más importante. Cada revisor que lo lee pregunta por qué tiene esa pinta.

Sigue siendo el intercambio correcto. La alternativa son dos copias de la aritmética del tarifario divergiendo en silencio durante toda la migración, y el modo de fallo de eso no es un error de compilación: son dos facturas del mismo envío con totales distintos.

Movido literalmente, después dejado presentable

La primera versión de RatingEngine era más fea que el controlador del que venía: un método estático con once parámetros y los mismos condicionales anidados, porque literal era el requisito y la forma no.

Solo cuando el arnés de la etapa 03 pudo demostrar que coincidía con el original en cuatro mil entradas se le permitió convertirse en una clase con métodos privados con nombre. La refactorización es segura porque el test es real; el test es real porque el código se movió sin mejorarlo antes.

Hacer esas dos cosas en un solo paso es la forma más común de que una migración introduzca un defecto que nadie puede encontrar, porque cuando la salida cambia no hay manera de saber si lo hizo el traslado o el arreglo.

Las rarezas que sobrevivieron

Tres reglas parecen bugs y no lo son. Cada una es un precio que alguien paga hoy.

Conservadas a propósito, con el motivo al lado
Redondeo de medio kilo hacia arriba
El peso facturable redondea al siguiente medio kilo. Introducido en 2014 tras una disputa con un cliente por 0,3kg. Finanzas aprueba cada cambio de tarifa, y esto es un cambio de tarifa.
El recargo de mercancía peligrosa se triplica para Irlanda
£15 se convierten en £42,50. Es la tarifa del agente de aduanas repercutida, no nuestro margen, y se añadió en 2018 cuando el agente empezó a cobrarla.
Descuento sobre el neto para tres cuentas
Un bug de 2017 aplicaba el descuento de contrato sobre el neto entero en lugar de sobre el porte. Los clientes notaron la corrección y el equipo comercial acordó respetarla. Hoy es una cláusula del contrato.

Una migración es el momento equivocado para corregir un precio. Corregir uno aquí significa que el arnés de paridad falla, y entonces ya no hay manera de distinguir “esto lo cambiamos a propósito” de “hemos roto algo” durante el resto del proyecto.

Lo único que sí cambió de forma: las tres cuentas eran un if sobre customer.Id. Ahora son un booleano en la petición, y de dónde sale ese booleano es problema de la etapa 05.

Coste

El código más importante de la aplicación nueva está escrito con un estándar de lenguaje de 2015, y seguirá así hasta que se apague el lado 4.8.

Compró

Una sola implementación de las reglas de precio durante toda la migración. Un cambio de tarifa es una edición, en un sitio, probada una vez.

03Arnés de tests

Una transcripción de la aritmética vieja, conservada a propósito

La lógica de precios de 4.8, copiada sin mejoras dentro de una fixture de test, para que cada etapa posterior tenga algo contra lo que estar equivocada.

La línea base tenía cero tests. Eso es normal y es justamente el problema: no existe una definición de comportamiento correcto en ninguna parte salvo el propio comportamiento.

Así que la definición se escribe. LegacyRating.cs es una transcripción de la aritmética que hay dentro de ConsignmentsController.Create, con la base de datos, el HttpContext y MSMQ recortados y nada más tocado. El mismo anidamiento, el mismo orden, los mismos dos puntos de redondeo separados.

No es una versión más ordenada. Todo su valor está en no ser una mejora.

Barrer, no muestrear

Cuatro mil entradas generadas, comparadas campo a campo, fallando por un céntimo. La semilla es fija: un arnés que genera entradas distintas en cada ejecución no puede decirte si arreglaste algo.

Ejemplos elegidos a mano se habrían perdido las tres discrepancias interesantes encontradas hasta ahora, porque todas estaban en una frontera para la que nadie habría pensado en escribir un test.

Encontrados barriendo, no revisando
Un código postal de Dublín que empieza por PA
El país tiene que ganarle al prefijo postal. En el original le ganaba, pero solo porque la comprobación de país corría la última y sobrescribía lo que las comprobaciones de prefijo ya habían fijado. Reescrito como una tabla de consulta, la lectura natural lo invierte y Dublín tarifica como las Tierras Altas de Escocia.
Exactamente 10kg en un tarifario cuyas bandas se juntan en 10
La aplicación vieja tenía dos consultas de tarifa que discrepaban en la frontera: SQL en línea con BETWEEN en la pantalla de reserva, una consulta LINQ en la pantalla del tarifario. Nueve años, nadie lo sabía.
Un neto que redondea hacia arriba mientras su IVA redondea hacia abajo
El asunto del segundo commit de esta etapa, más abajo.

El commit que arregló un bug que los tests no podían ver

El motor sacaba el IVA del neto ya redondeado. El controlador 4.8 saca el IVA del neto sin redondear y redondea el neto por separado, en la línea siguiente.

Los cuatro mil casos de paridad pasaban de las dos maneras.

RatingEngine.cs (tal cual)RatingEngine.cs (a227e30)−2  +2

Dos líneas intercambiadas, ninguna diferencia observable en todo el corpus, y merecedoras de un commit propio.


          
          var roundedNet = Round(net);
        
          
          var vat = Round(roundedNet * vatRate);
        
          
          var vat = Round(net * vatRate);
        
          
          var roundedNet = Round(net);
        

Pasar no es equivalencia. Distinguir esos dos exige un neto cuyo tercer decimal lleve su IVA a través de una frontera de medio céntimo, y un corpus que nunca produce uno no ha demostrado nada: ha fallado en refutar.

Fui a buscar una entrada divergente con un barrido dirigido de cuatro millones de valores y tampoco encontré ninguna. Eso sigue sin ser equivalencia; es una búsqueda más estrecha con la misma forma de respuesta.

Qué se rompióVeinte minutos en encontrarlo, cinco en arreglarlo, y habría sido una tarde muy larga más adelante
Síntoma
Nada. Todos los tests pasaron, y un barrido de cuatro millones de valores en busca de un contraejemplo también salió vacío.
Causa
El motor y el original sacan el IVA de cantidades distintas: uno redondea antes de multiplicar, el otro después. La diferencia es estructural y el corpus de tests simplemente nunca produjo una entrada que la expusiera.
Detectado por
Leyendo las dos implementaciones lado a lado mientras escribía esta entrada. Ningún test lo habría hecho.

El arreglo consiste en igualar la estructura del original en vez de su salida observada. Coste de hacerlo: nada. Coste de equivocarse: un céntimo sistemático en 41.000 envíos, y una conversación con finanzas, que concilia los totales nuevos contra los viejos.

Eso se convirtió en la regla para el resto de la migración. Donde la forma del código viejo es arbitraria, mejórala. Donde la forma es estructural y el corpus de tests no puede distinguir, copia la forma.

El test que estaba mal

Un test afirmaba que medio kilo se factura como medio kilo. Falla: medio kilo se factura como uno, porque MinimumChargeableWeightKg se aplica antes del redondeo.

El motor tenía razón y el test estaba mal. Se conserva, con la expectativa corregida y un comentario que explica la trampa, porque la siguiente persona que lea la fórmula volumétrica hará exactamente la misma suposición.

Coste

Un archivo en el proyecto de tests que es una copia deliberada de código que se está borrando, que todo revisor marca como duplicación, y que hay que mantener si las reglas viejas cambian durante la migración.

Compró

Cada etapa posterior se puede comprobar contra algo en lugar de revisarse por plausibilidad. Dos de los tres defectos de arriba habrían llegado a producción.

LegacyRating.cs se borra el día que se apague la aplicación 4.8, y ni un commit antes.

04Autenticación

La cookie que tenía que ser legible por las dos stacks

La autenticación por formularios cifra con MachineKey y ASP.NET Core no puede leerlo. Nada podía desplegarse hasta resolver esto.

Esta es la etapa que decide si una migración strangler es posible siquiera, y por eso va antes de que se mueva ningún módulo.

La autenticación por formularios cifra su ticket con MachineKey. ASP.NET Core no tiene MachineKey: tiene Data Protection, un algoritmo distinto con otra derivación de clave y otro formato de payload. De fábrica, las dos stacks no pueden leer la cookie de la otra.

La consecuencia no es sutil. Un usuario sigue un enlace desde una página servida por la aplicación vieja hacia una servida por la nueva y sencillamente se queda sin sesión. Cada ruta que se mueve se lleva la sesión del usuario con ella.

Autenticación, etapa 04
Antes
  1. Meridian.Web.NET 4.8
  2. Ticket FormsAuthMachineKey
  3. .MERIDIANAUTHcookie
Después
  1. Meridian.Web.NET 4.8
  2. Meridian.Api.NET 8
  3. Data Protectionkey ring compartido
  4. .MERIDIANAUTHuna cookie
sin cambiosadaptadonuevo

Las dos aplicaciones escriben y leen una sola cookie, a través de un key ring en una ruta compartida. El lado 4.8 llega ahí con Microsoft.AspNetCore.DataProtection.SystemWeb y el formateador de tickets de interop de Owin.

Por qué no simplemente iniciar sesión dos veces

La alternativa obvia (emitir una segunda cookie desde la aplicación nueva y dejar que existan las dos) se probó y se abandonó en una tarde.

Dos cookies significan dos relojes de expiración, dos renovaciones de expiración deslizante que se separan, y un logout que tiene que llegar a las dos aplicaciones o deja una sesión viva. También significa que el momento de la verdad se traslada a la primera petición que necesite la cookie de la otra aplicación, lo cual es impredecible y por tanto no testeable.

Una cookie da más trabajo al principio y es una cosa más pequeña sobre la que razonar después.

Dos detalles que no son detalles

Web.configProgram.cs−9  +15

El elemento machineKey desaparece por completo. Lo que lo sustituye no es una clave de configuración, sino dos líneas de arranque, y cada una costó un día.


          
          <machineKey
        
          
            validationKey="C1B4E5F3A0D8827E4C6B19A2F70D5E8C3B6A94F1D27E0C58B..."
        
          
            decryptionKey="8F3A0D5C7E92B4160A8D3F5C7E92B4160A8D3F5C7E92B416"
        
          
            validation="HMACSHA256"
        
          
            decryption="AES" />
        
          
          <authentication mode="Forms">
        
          
            <forms name=".MERIDIANAUTH" loginUrl="~/Account/LogOn" timeout="480"
        
          
                   slidingExpiration="true" protection="All" path="/" />
        
          
          </authentication>
        
          
          builder.Services
        
          
              .AddDataProtection()
        
          
              .SetApplicationName("Meridian")
        
          
              .PersistKeysToFileSystem(new DirectoryInfo(
        
          
                  builder.Configuration["DataProtection:KeyRingPath"]
        
          
                  ?? throw new InvalidOperationException("DataProtection:KeyRingPath is required.")));
        
          
           
        
          
          builder.Services
        
          
              .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
        
          
              .AddCookie(o =>
        
          
              {
        
          
                  o.Cookie.Name = ".MERIDIANAUTH";
        
          
                  o.ExpireTimeSpan = TimeSpan.FromMinutes(480);
        
          
                  o.SlidingExpiration = true;
        
          
              });
        

SetApplicationName tiene que coincidir en los dos lados. Data Protection deriva su cadena de propósito del nombre de la aplicación. Dos aplicaciones con nombres distintos producen claves que no pueden leer los payloads de la otra mientras parecen perfectamente configuradas: mismo directorio, mismos archivos, mismos permisos.

KeyRingPath no tiene valor por defecto y el arranque revienta sin él. Eso es deliberado, y es el más importante de los dos.

Qué se rompióUn día, y habría sido más sin el listado del directorio
Síntoma
Un usuario inicia sesión correctamente (se comprueban las credenciales, se escribe el ticket) y se le redirige inmediatamente de vuelta a la pantalla de login. Repetir para siempre. Sin error, sin excepción, nada en el log de ninguna de las dos aplicaciones por encima de Information.
Causa
La aplicación nueva no encontró una ruta de key ring configurada, así que Data Protection hizo aquello para lo que está diseñado y generó una efímera en memoria. Después emitió una cookie que la aplicación 4.8 no podía descifrar, que redirigía al login, que emitía una cookie que la aplicación nueva no podía descifrar.
Detectado por
Al notar que el directorio del key ring en disco tenía un archivo XML y una marca de tiempo más antigua que el despliegue.

El modo de fallo es lo que hace que esto merezca el párrafo. Una ruta de key ring ausente es invisible en desarrollo, donde solo hay una aplicación y habla felizmente consigo misma. Solo falla donde dos aplicaciones tienen que ponerse de acuerdo, que es el único entorno que nadie ejecuta en su portátil.

Así que el código revienta en el arranque en vez de asumir un valor por defecto. Un valor de configuración cuya ausencia produce una aplicación que parece funcionar y un sistema roto no debería tener valor por defecto.

Qué arregló además esta etapa, gratis

UseForwardedHeaders está en este commit. Es el mismo problema en el que la etapa 01 gastó dos días (una aplicación construyendo URLs absolutas a partir de su propia dirección interna), y aquí no costó nada, porque a estas alturas era una propiedad conocida de correr detrás del gateway y no un descubrimiento.

Ese es la mayor parte del argumento para hacer la infraestructura primero.

Costeno es temporal

La autenticación de las dos aplicaciones depende ahora de un directorio en un sistema de archivos. Es un punto único de fallo nuevo, hay que respaldarlo, y sus permisos son estructurales de una manera que no lo eran los de ningún archivo de configuración.

Compró

Una cookie, una sesión, un logout. Un usuario que se mueve entre las mitades vieja y nueva de la aplicación no puede notar que la frontera existe, que es toda la premisa de una migración strangler.

05Tarifarios

Tarifarios, la cosa más pequeña que merecía moverse

Una pantalla de solo lectura con lógica de dominio real y ninguna capacidad de costarle dinero a nadie, y el valor por defecto de EF Core que truncaba en silencio cada decimal.

El primer módulo que se mueve se elige casi por completo por lo que no puede hacer.

Los tarifarios son de solo lectura. Sin escrituras, sin cola, sin informes, sin dinero moviéndose. Lo que sí tienen es lógica de dominio real (búsqueda de banda, vigencia por fechas) y un camino que toca todas las partes de la stack nueva: enrutamiento en el gateway, la cookie compartida, EF Core contra el esquema en vivo, el proyecto compartido de tarificación.

Cobertura máxima del camino nuevo, radio de explosión mínimo. Ese es todo el criterio de selección.

El instinto que estaba equivocado

El instinto era empezar por la capa de datos, porque parece el cimiento y todo lo demás se apoya en ella.

Es la peor primera elección posible. La capa de datos es la más acoplada a APIs que solo existen en Framework: ConsignmentRepository lee su tenant de HttpContext.Current y no se puede llamar desde nada que no sea una petición web, cosa que el proceso nocturno de manifiestos descubrió en 2018 y sortea falsificando un contexto de petición. Empezar ahí pone la pieza de trabajo más difícil antes de que nadie tenga confianza alguna en la stack nueva, y antes de que haya evidencia de que el enfoque funciona siquiera.

Empezar por una pantalla que muestra números produce algo desplegable en días. Eso importa más que el orden arquitectónico en el primer mes de una migración, porque lo que más probablemente la mate no es un problema técnico.

Misma base de datos, sin migración de datos

Las mismas tablas, las mismas filas, el mismo esquema en el que la aplicación 4.8 sigue escribiendo. La migración mueve código, no datos.

Nada en el nuevo DbContext crea ni migra nada. Dos modelos code-first compitiendo por ser dueños de un esquema es como se pierde un fin de semana y luego un sábado restaurando desde copia de seguridad.

Qué se rompió

Qué se rompióMinutos, una vez que el arnés falló. Indefinido, sin él.
Síntoma
Todas las pantallas mostraban cifras correctas. Las lecturas eran perfectas. Las escrituras descartaban en silencio el cuarto decimal de cada columna de dinero, y nada en ningún sitio reportaba un error.
Causa
A EF6 se le había dicho explícitamente decimal(18,4). EF Core asume (18,2) por defecto para decimal. Como la aplicación siempre ha redondeado a dos decimales antes de escribir, cada valor que ya estaba en la tabla terminaba en dos ceros, así que el truncamiento no tenía nada que truncar y siguió invisible hasta que pasó una banda tarifaria con una tarifa genuina de cuatro decimales.
Detectado por
El arnés de paridad. Nada dentro de la aplicación lo habría hecho: sin excepción, sin aviso, sin ningún número visiblemente mal en ninguna pantalla.

Esta es la lección que llevarse de todo el diario. Es invisible, es silenciosa, afecta al dinero, y la causa un valor por defecto y no algo que escribiera nadie.

MeridianContext.cs (EF6)MeridianDbContext.cs (EF Core)−6  +6

El arreglo es un bucle sobre el modelo en lugar de una anotación por propiedad, porque el modo de fallo del enfoque con anotaciones es olvidarse de una.


          
          mb.Entity<Consignment>().Property(x => x.Carriage).HasPrecision(18, 4);
        
          
          mb.Entity<Consignment>().Property(x => x.FuelSurcharge).HasPrecision(18, 4);
        
          
          mb.Entity<Consignment>().Property(x => x.Net).HasPrecision(18, 4);
        
          
          mb.Entity<Consignment>().Property(x => x.Vat).HasPrecision(18, 4);
        
          
          mb.Entity<Consignment>().Property(x => x.Gross).HasPrecision(18, 4);
        
          
          mb.Entity<RateBand>().Property(x => x.RatePerKg).HasPrecision(18, 4);
        
          
          foreach (var property in b.Model.GetEntityTypes()
        
          
                       .SelectMany(t => t.GetProperties())
        
          
                       .Where(p => p.ClrType == typeof(decimal) || p.ClrType == typeof(decimal?)))
        
          
          {
        
          
              property.SetColumnType("decimal(18,4)");
        
          
          }
        

Seis líneas escritas a mano se convierten en un bucle, y la propiedad que se añada el año que viene queda cubierta sin que nadie tenga que acordarse.

El comportamiento que cambió a propósito

La aplicación vieja tenía dos búsquedas de tarifa. El SQL en línea de la pantalla de reserva usaba BETWEEN y ordenaba por FromKg descendente. Una consulta LINQ en la pantalla del tarifario usaba >= y <= y ordenaba igual. En un peso justo en la frontera (10,0kg en un tarifario cuyas bandas son 0-10 y 10-25) devolvían bandas distintas.

Las dos llevaban nueve años en producción. Nadie lo sabía.

Elegir entre ellas es una pregunta comercial disfrazada de técnica, y se resolvió sobre esa base: gana la versión en SQL, porque es la que tarificó nueve años de facturas. La versión LINQ nunca fue más que una vista previa en una pantalla desde la que nadie factura.

Un cambio de esquema

Los tres ids de cliente escritos a fuego en ConsignmentsController.Create se convirtieron en una columna.

ConsignmentsController.csdb/001_discount_applies_to_net.sql−4  +5

          
          if (customer.Id == 1104 || customer.Id == 1187 || customer.Id == 1203)
        
          
              net = net - (net * customer.DiscountPercent / 100m);
        
          
          else
        
          
              net = net - (carriage * customer.DiscountPercent / 100m);
        
          
          ALTER TABLE dbo.Customer
        
          
              ADD DiscountAppliesToNet BIT NOT NULL
        
          
                  CONSTRAINT DF_Customer_DiscountAppliesToNet DEFAULT (0);
        
          
           
        
          
          UPDATE dbo.Customer SET DiscountAppliesToNet = 1 WHERE Id IN (1104, 1187, 1203);
        

Es el único cambio que esta migración hizo sobre una tabla existente. Se gana su sitio dos veces: el motor de tarificación puede seguir siendo puro, y una cuarta cuenta con la misma cláusula de contrato pasa a ser una fila en vez de un despliegue.

Coste

Ahora existen dos implementaciones de la pantalla de tarifarios y las dos son alcanzables: la 4.8 directamente, la de .NET 8 por el gateway. Hasta que se borre la ruta vieja, un reporte de bug tiene que establecer cuál de las dos estaba mirando quien lo reportó.

Compró

Un camino completo y desplegable por la stack nueva, probado de punta a punta, sobre un módulo donde equivocarse no cuesta nada. Todo lo que viene después es una variación de una ruta que ya funciona.

06Traspaso al depósito

MSMQ, un outbox, y un proceso que se queda en 4.8 para siempre

La dependencia que no se puede migrar en absoluto, así que se encogió todo lo posible y se puso en cuarentena.

System.Messaging nunca se portó a .NET Core y no se hará. No hay shim, no hay paquete de compatibilidad, no hay port de la comunidad, no hay forma soportada de poner un mensaje en una cola MSMQ desde .NET 8.

Así que la pregunta no es cómo migramos MSMQ. Es qué hacemos con el hecho de que no podemos.

Qué se descartó

Reescribir la integración con el depósito sobre RabbitMQ o Service Bus. La cola la lee software propiedad de un tercero que fue adquirido en 2020. Cambiar el transporte significa una negociación con otra empresa sobre una interfaz que lleva contrato, en su calendario, con sus pruebas.

Eso es un proyecto de verdad con un presupuesto de verdad, y no es este. Una migración que absorbe en silencio la reescritura de una integración de terceros es una migración que no termina, y la forma en que falla es que deja de ser posible decir qué queda.

Dejar todo el flujo de reserva en 4.8 hasta que el depósito cambie. Esto convierte la única cosa que no se puede migrar en un bloqueante para todo lo que sí. El camino de escritura de envíos es el núcleo de la aplicación; congelarlo congela la migración.

Qué se eligió

La dependencia no desaparece. Se encoge todo lo que puede encogerse, y se dibuja una línea a su alrededor.

Traspaso al depósito, etapa 06
Antes
  1. Acción Create.NET 4.8
  2. SaveChangesEF6 · confirmado
  3. MessageQueue.SendSystem.Messaging
  4. Depósitotercero
Después
  1. POST /consignments.NET 8
  2. SaveChangesEF Core · una transacción
  3. DespatchOutboxtabla
  4. Sidecar.NET 4.8
  5. Depósitotercero
sin cambiosadaptadonuevoeliminado

La dependencia 4.8 sigue ahí. Ahora es un proceso de 200 líneas sin superficie web y sin lógica de negocio, cuyo trabajo entero es leer una tabla y escribir en una cola.

El sidecar es el rehén más pequeño que la migración pudo dejar atrás. Cuando el sistema del depósito se sustituya algún día, borrar sidecar/ es todo el cambio.

Un arreglo de corrección que no era el objetivo

El código viejo enviaba a MSMQ después de que SaveChanges() ya hubiera confirmado, fuera de cualquier transacción:

ConsignmentsController.csConsignmentEndpoints.cs + DespatchOutbox.cs−7  +3

Dos escrituras en una base de datos no pueden pasar a medias. Una escritura en la base y un envío a una cola sí pueden, y pasaba.


          
          _db.Consignments.Add(consignment);
        
          
          _db.SaveChanges();
        
          
           
        
          
          using (var queue = new MessageQueue(queuePath))
        
          
          {
        
          
              // No transaction. If SaveChanges above succeeded and this throws, the
        
          
              // consignment exists and the depot never hears about it.
        
          
              queue.Send(msg);
        
          
          }
        
          
          db.Consignments.Add(consignment);
        
          
          outbox.Enqueue(consignment.Reference, DespatchMessage.Format(...));
        
          
          await db.SaveChangesAsync(ct);
        

Cuando el envío reventaba, el envío existía y el depósito nunca se enteraba. La única forma en que alguien lo descubría era un cliente preguntando dónde había ido a parar su palé.

La expedición ahora es asíncrona por unos segundos, cosa que al depósito le da igual, y no se puede saltar en silencio, cosa que a todo el mundo no.

Qué se rompió

Qué se rompióCuarenta minutos en reproducirlo, una vez inyectado el fallo
Síntoma
El depósito registró la misma recogida dos veces. Dos furgonetas, un palé, y un contacto muy poco impresionado en el extremo receptor.
Causa
El drenador reintenta cuando un envío falla. Un envío que en realidad había funcionado y luego agotó el tiempo esperando la confirmación se reintentó, y MSMQ ya había aceptado el primero. No había manera de que el depósito distinguiera los dos.
Detectado por
Una prueba de saturación contra una cola con un 5% de timeout de confirmación artificial. No se reproduce en absoluto en condiciones normales, que es por lo que habría llegado a producción.

El arreglo es IdempotencyKey y un índice único. La clave se deriva del cuerpo del mensaje, así que un reenvío genuino de contenido cambiado es otra fila y un reintento ciego no lo es.

El orden que estaba mal al principio

OutboxDrain reclama un lote, envía cada mensaje, y después lo marca como enviado. La primera versión marcaba y después enviaba, que se lee de forma más natural y nunca deja una fila pareciendo no enviada cuando sí lo estaba.

Lo que hace en cambio es perder mensajes cuando el envío revienta.

La forma general: enviar dos veces es recuperable, no enviar nunca no lo es. Ante la elección entre el modo de fallo que duplica y el que descarta, coge el que duplica, y luego gasta el esfuerzo en hacer los duplicados detectables.

Costeno es temporal

Ahora hay en el parque un proceso .NET Framework 4.8 que sobrevivirá a la migración. Necesita un host Windows, su propio despliegue, su propia monitorización, y es una segunda cosa que puede estar caída cuando las expediciones dejan de moverse.

Compró

La aplicación .NET 8 puede ser dueña del camino de escritura de envíos (que es el núcleo del sistema) sin poder hablar con MSMQ en absoluto. Y el traspaso se volvió transaccional de paso.

07Informes

La etapa que no produjo código

Veinte informes RDLC sin renderizador en .NET 8. La decisión fue no mover ninguno, y escribir la regla de cuándo eso cambia.

Microsoft.ReportViewer.WebForms es un ensamblado de .NET Framework. No hay sucesor en .NET Core ni .NET 5+, y RDLC no tiene otro renderizador: el formato está atado al único control que lo lee.

Hay veinte de estos archivos, escritos en el diseñador de informes de Visual Studio entre 2011 y 2017. Uno tiene un subinforme que busca sus propias filas. Varios enlazan directamente con un DataTable devuelto por un procedimiento almacenado.

La estimación que mató la reescritura

Reescribir veinte informes en QuestPDF está entre tres y seis semanas, y la estimación es amplia porque los informes RDLC se maquetan visualmente y su comportamiento en un salto de página es emergente, no especificado. No sabes qué hace un informe al pie de la página dos hasta que renderizas uno.

Así que la pregunta útil no es cuánto cuesta. Es qué compra.

Veinte informes, por quién los lee y con qué frecuencia cambian
Dos documentos de cara al cliente
Factura y manifiesto. Los ven clientes, se imprimen en papel con membrete, y son los únicos en los que alguien notaría un cambio. Han cambiado tres veces entre los dos desde 2017.
Dieciocho informes operativos internos
Rendimiento de depósito, listas de excepciones, conciliación de cierre de mes. Los leen cuatro personas. Diecinueve de los veinte archivos no se editan desde 2017.

Migrar algo que no ha cambiado en ocho años no compra nada salvo la satisfacción de haberlo migrado. Los informes funcionan. Renderizan el mismo PDF que renderizaban en 2017, sobre un runtime con soporte hasta 2031.

Qué se decidió realmente

Los veinte se quedan. No se movió código de informes, y el diff lo enseña: no hay generación de PDF por ninguna parte de modern/.

La regla que se acordó en su lugar: un informe se reescribe la próxima vez que alguien pida un cambio en él, y no antes. La reescritura la paga entonces el cambio que la disparó, en un momento en que a alguien de verdad le importa la salida y puede decirte si está bien.

Con la evidencia actual eso es casi nunca para dieciocho de ellos, y esa es la respuesta correcta para dieciocho de ellos.

El commit que produjo esta etapa

Ninguno. El panel de metadatos lo dice en lugar de mostrar un cero, porque son hechos distintos y el sitio está construido para no poder confundirlos.

El único commit que tocó los informes en toda la historia es ecb860b, y no cambia ningún informe: corrige este diario. Un borrador anterior del README del repositorio afirmaba que dos de los veinte se habían reescrito. No era así.

Qué se rompióDos commits para corregirlo, y es lo más peligroso de este diario
Síntoma
La tabla de dependencias del README decía '18 congelados, 2 reescritos'. El historial de commits no contiene generación de PDF de ningún tipo.
Causa
Escribí el plan en el README como si fuera el desenlace. Se escribió antes de la estimación, y nunca se revisó después de que la decisión fuera en el otro sentido.
Detectado por
Releyendo el inventario contra el diff real mientras escribía esta página.

Esa corrección es la razón de que esta etapa exista siquiera, en lugar de ser un párrafo dentro de la etapa 06. Si no se puede confiar en el inventario de lo que sigue en 4.8, tampoco se puede confiar en el resto del diario: cualquier otra afirmación que contiene es exactamente del mismo tipo, y un lector no tiene manera independiente de comprobar ninguna.

El único activo real de un diario de migración es que sus malas noticias son fiables.

Costeno es temporal

Veinte informes RDLC y una dependencia de ReportViewer se quedan en .NET Framework 4.8 indefinidamente, lo que significa que la aplicación web 4.8 no se puede apagar, lo que significa que el key ring compartido y el parque con dos runtimes también se quedan.

Compró

Tres a seis semanas no gastadas en volver a dibujar documentos que leen cuatro personas y que nadie ha cambiado desde 2017.

08Envíos

Envíos, movidos los últimos

La acción de 272 líneas. Todo lo difícil converge aquí, que es por lo que todas las demás etapas fueron antes.

ConsignmentsController.Create tiene 272 líneas y hace nueve cosas. El dinero, la cola, el tenant, la comprobación de duplicados y el rastro de auditoría se encuentran todos dentro de ella.

Se mueve la última, y el orden de todas las etapas anteriores se eligió para que pudiera. Para cuando se escribió este endpoint, las reglas de tarificación ya estaban extraídas y demostradas (02, 03), la cookie ya era compartida (04), la stack nueva ya tenía un camino de punta a punta funcionando (05), y la cola ya tenía una respuesta que no pasaba por MSMQ (06). Lo que quedaba era orquestación.

La misma reserva, antes y después
Antes
Un método de acción, 272 líneas, 0 tests
Después
Un endpoint, 215 líneas, delegando en 4 colaboradores con nombre
Tarificación
Meridian.Domain.RatingEngine: 4.011 aserciones
Búsqueda de tarifa
IRateLookup: una implementación, eran dos
Expedición
IDespatchOutbox: transaccional, era dispara-y-olvida
Auditoría
No movida. Sigue siendo un módulo HTTP del lado 4.8.

Tres cosas que aquí empeoraron

Declaradas en lugar de escondidas, porque una etapa en la que todo mejoró es una etapa que no se ha mirado bien.

El motor de tarificación se llama dos veces. Una para obtener el peso facturable, otra para tarificarlo, porque la búsqueda de tarifa necesita el peso, y el peso es asunto del motor. La alternativa era duplicar la fórmula volumétrica en el punto de llamada, que es exactamente cómo la aplicación vieja acabó con dos de ellas. Una llamada redundante es más barata que una segunda copia de una regla, pero es genuinamente redundante y el siguiente lector se lo va a preguntar.

La asignación de referencia sigue siendo MAX + 1. Dos administrativos que registran en el mismo segundo siguen chocando.

ConsignmentsController.NextReferenceConsignmentEndpoints.NextReferenceAsync−4  +7

Portado, no arreglado. El bug se conserva exactamente, con un comentario que lo dice.


          
          var cmd = new SqlCommand(
        
          
              "SELECT ISNULL(MAX(CAST(SUBSTRING(Reference, 4, 10) AS INT)), 0) FROM Consignment", conn);
        
          
          var next = (int)cmd.ExecuteScalar() + 1;
        
          
          return "MER" + next.ToString("D8");
        
          
          var last = await db.Consignments
        
          
              .OrderByDescending(c => c.Id)
        
          
              .Select(c => c.Reference)
        
          
              .FirstOrDefaultAsync(ct);
        
          
           
        
          
          var next = last is { Length: > 3 } && int.TryParse(last[3..], out var n) ? n + 1 : 1;
        
          
          return $"MER{next:D8}";
        

Arreglarlo bien significa una secuencia, un cambio de esquema y una conversación con el depósito sobre formatos de referencia, porque el formato va por el cable en ese mensaje de ancho fijo. Eso es un trabajo de verdad y no es el de esta migración. Queda anotado como deuda en un comentario en lugar de colarse al amparo de una migración, que es como las migraciones adquieren alcance que nadie aprobó, y como un rollback deja de ser posible.

La declaración aduanera en WCF no se movió. Sigue en el lado 4.8, sigue tragándose sus excepciones desde una quincena mala de 2019, y este endpoint no hace la declaración en absoluto. Eso es un hueco, no una decisión. WCF del lado cliente sí tiene una historia en .NET 8, así que esta sencillamente aún no está hecha.

El ticket que se cerró como efecto colateral

MER-2213, abierto desde 2019: el buscador concatena en la cláusula WHERE.

ConsignmentRepository.SearchGET /consignments−3  +3

          
          sql += "AND (c.Reference LIKE '%" + q + "%' " +
        
          
                 "  OR cu.Name LIKE '%" + q + "%' " +
        
          
                 "  OR c.DeliveryPostcode LIKE '%" + q + "%') ";
        
          
          query = query.Where(c =>
        
          
              EF.Functions.Like(c.Reference, $"%{q}%") ||
        
          
              EF.Functions.Like(c.DeliveryPostcode, $"%{q}%"));
        

Estuvo abierto seis años no porque fuera difícil, sino porque arreglarlo significaba volver a probar una pantalla que nadie quería volver a probar. Reescribir la pantalla iba a pasar de todas formas, así que el arreglo no costó nada.

Vale la pena ser honesto sobre eso en lugar de reclamarlo como una victoria. La migración no priorizó un defecto de seguridad. Se topó con uno de camino.

El tenant deja de ser ambiental

ConsignmentRepository leía su tenant de HttpContext.Current.Items, lo que significaba que solo se podía llamar desde dentro de una petición web. El proceso nocturno de manifiestos lo descubrió en 2018 y lo sortea construyendo un HttpContext falso.

El tenant llega ahora como un claim. Ese único cambio es lo que permitirá al proceso de manifiestos dejar de fingir que es una petición web cuando se mueva, cosa que aún no ha pasado.

Coste

Las reservas pueden llegar ahora por dos caminos de código contra una base de datos: la pantalla 4.8 para humanos, este endpoint para clientes JSON. Hasta que las pantallas Razor se muevan, cualquier investigación sobre un precio equivocado empieza por establecer cuál de las dos mitades lo tarificó.

Compró

El método de 272 líneas ya no es el único sitio donde existen las reglas de precio, y las reglas que solía poseer están ahora cubiertas por 4.011 aserciones que corren en un tercio de segundo.

09Enrutamiento

Enrutar por Accept, no por ruta

Los escáneres del depósito y las pantallas humanas comparten una URL. Dividir por tipo de contenido movió uno sin mover el otro.

La etapa 08 construyó un endpoint JSON de envíos. El problema es que la aplicación 4.8 ya sirve /consignments: como vistas Razor, a personas, en un navegador.

Los dos públicos quieren la misma ruta. Las terminales del depósito y la app móvil hacen POST a /consignments queriendo JSON; un administrativo abre /consignments queriendo una pantalla. El espacio de URLs no los distingue, y no hay prefijo de versión tras el que esconderse porque estos clientes llevan llamando a esa ruta desde 2014.

Las opciones

Reescribir también las pantallas. Triplica el tamaño de la etapa, y no produce nada para la persona que sostiene un escáner. Las vistas Razor funcionan.

Darle a la API una ruta nueva: /api/consignments. Limpio, y significa cambiar firmware en terminales de depósito que se actualizan visitando físicamente los depósitos. Eso es una operación de flota, no un despliegue.

Dividir por la cabecera que los clientes ya envían.

appsettings.json (etapa 01)appsettings.json (etapa 09)−0  +7

Misma ruta, mismos verbos, clientes distintos. Emparejar la ruta por Accept es lo único que los separa.


          
          "consignments-api-new": {
        
          
          "ClusterId": "modern",
        
          
          "Match": {
        
          
            "Path": "/consignments/{**catch-all}",
        
          
              "Headers": [
        
          
                {
        
          
                  "Name": "Accept",
        
          
                  "Values": [ "application/json" ],
        
          
                  "Mode": "Contains"
        
          
                }
        
          
              ]
        
          
          },
        
          
          "Order": 2
        
          
          }
        

Los clientes máquina se mudaron a .NET 8 y nadie visitó un depósito. Las pantallas se quedaron exactamente donde estaban.

Por qué esto está escrito dos veces

Una ruta que empareja por cabecera es invisible en un stack trace. Una petición que cae en la mitad equivocada parece un bug en la mitad que la recibió, y ninguna de las dos mitades sabe que la cabecera existe: la aplicación 4.8 no tiene ni idea de que la están sorteando para algunos llamantes, y la de .NET 8 no tiene ni idea de que solo está viendo parte del tráfico.

Así que está en el README del gateway además de en la configuración. La configuración no es documentación: dice qué pasa, nunca por qué, y esta es una regla cuyo porqué es lo único que hace tratable el siguiente incidente.

Qué se rompióMedio día, casi todo pasado en la aplicación equivocada
Síntoma
Una terminal de depósito que había funcionado toda la mañana empezó a recibir HTML. Sus peticiones llegaban a la aplicación 4.8, que renderizaba una vista Razor dentro de un parser que esperaba JSON.
Causa
La terminal envía Accept: application/json, */* en la mayoría de las llamadas, pero Accept: */* en un camino de reintento de su cliente HTTP. Mode: Contains empareja la cadena literal, y */* no contiene application/json.
Detectado por
Comparando las peticiones que funcionaban con las que fallaban. Ninguna de las dos aplicaciones registró nada raro: las dos sirvieron exactamente lo que se les pidió.

El arreglo fue del lado del cliente, porque la alternativa (tratar */* como JSON en el gateway) enrutaría la petición de reserva de un navegador hacia la API y rompería las pantallas en su lugar. Una división por cabecera solo funciona cuando la cabecera de verdad discrimina, y averiguar cuáles de tus clientes son descuidados con Accept es parte del coste de elegirla.

Coste

Una URL la sirven ahora dos aplicaciones según una cabecera, lo cual es genuinamente sorprendente y volverá a sorprender a alguien. También significa que las dos mitades deben seguir de acuerdo sobre qué es un envío mientras ambas existan.

Compró

Una clase entera de cliente cambió de runtime sin ningún cambio en el cliente, sin release coordinado, y sin visitar ningún depósito.

10Etiquetas de expedición

El que compila y luego falla en producción

System.Drawing sigue en 4.8, y el motivo por el que está atascado es peor que "sin equivalente en .NET 8".

Label.ashx renderiza la etiqueta de expedición 4x6 que esperan las impresoras del depósito, a través de GDI+, incluido un código de barras dibujado a mano a partir de una tabla de anchos de barra Code 39, porque el componente con licencia se abandonó en 2012 cuando nadie pudo encontrar el archivo de licencia.

No se ha movido, y vale la pena ser preciso sobre por qué, porque “System.Drawing no funciona en .NET 8” es solo media verdad y la mitad que es falsa es la peligrosa.

Compila. Ese es el problema.

System.Drawing.Common sigue existiendo como paquete. La API está toda ahí. El código que lo usa compila sin un solo aviso, y funciona perfectamente en la máquina Windows de un desarrollador.

Desde .NET 6 en adelante lanza PlatformNotSupportedException en tiempo de ejecución sobre cualquier cosa que no sea Windows. Así que la primera vez que falla es dentro de un contenedor Linux, en el primer entorno que ejecute uno, que es un modo de fallo estrictamente peor que no compilar, porque pasa todas las puertas que lo habrían pillado antes.

Por qué esto es una reescritura y no un cambio de paquete
El código de barras es geometría, no una llamada a biblioteca
Los anchos de barra vienen de una tabla Code 39 y se dibujan como rectángulos rellenos con un ancho de barra estrecha calculado. Portarlo significa volver a derivar esa geometría contra SkiaSharp o ImageSharp, no cambiar un import.
La salida tiene una prueba de aceptación física
Los escáneres del depósito son quisquillosos con el DPI y las zonas de silencio. La única forma de saber si una etiqueta portada es correcta es imprimir una y escanearla, en un depósito, con el hardware real.
El endpoint es anónimo
Un bloque <location> en el Web.config lo permite sin cookie, porque el firmware del escáner no puede presentar una. Moverlo significa resolver eso también, y la respuesta actual es que la referencia es difícil de adivinar.

Por qué está listado siquiera

Porque un inventario que solo contiene problemas resueltos no es un inventario.

Esta etapa produjo un commit y ese commit solo edita un README: quita una línea que afirmaba que el renderizador de etiquetas se había migrado. No era así. La afirmación se escribió desde el plan en lugar de desde el diff, en el mismo borrador que exageró lo de los informes.

Dos de estas en un solo documento son un patrón, no un desliz, y el patrón es que el resumen se escribió antes del trabajo y nunca se volvió a contrastar con lo que realmente aterrizó. La corrección es barata. El hábito que la produjo no lo es, y es la razón de que cada etapa de este diario lleve ahora sus hashes de commit en el panel de la derecha: una afirmación con un hash al lado se puede comprobar en unos cuatro segundos, y una sin él no se puede comprobar en absoluto.

Coste

La aplicación web 4.8 tiene que seguir corriendo para servir peticiones de etiquetas, lo que contribuye al mismo coste de parque que los informes. Además es el único endpoint anónimo del sistema y está en el lado viejo, donde las nuevas políticas de autorización no llegan.

Compró

Nada todavía. Esto es trabajo no hecho, registrado como trabajo no hecho.

11Parque

Qué sigue corriendo sobre .NET Framework, y por qué

El inventario honesto. Un diario de migración que se detiene en la última etapa exitosa es un documento de ventas.

La migración no está terminada, y con los planes actuales partes de ella nunca lo estarán. Ese es el desenlace normal y vale la pena escribirlo con claridad, porque la versión de este documento que termina en la etapa 08 con todo en verde sería inútil para quien esté decidiendo si empezar una.

Aún en .NET Framework 4.8
El sidecar de expedición
MSMQ no tiene sucesor. Acotado a ~200 líneas, sin superficie web, sin lógica de negocio. Borrable el día que cambie el sistema del depósito, y no antes. Ver etapa 06.
Veinte informes RDLC
Microsoft.ReportViewer es solo de Framework. Congelados por decisión, reescritos uno a uno cuando alguno cambie. Diecinueve no han cambiado desde 2017. Ver etapa 07.
El renderizado de etiquetas
System.Drawing. Bloqueado en una reescritura de código de barras con prueba de aceptación física. Conocido, dimensionado, sin empezar. Ver etapa 10.
Las pantallas de envíos
Vistas Razor servidas por IIS. Los clientes JSON se mudaron en la etapa 09; las pantallas no tenían motivo para seguirlos ni presupuesto para hacerlo.
La declaración aduanera
Una llamada WCF, aún en el lado viejo, aún tragándose sus excepciones. WCF del lado cliente sí tiene una historia en .NET 8, así que esta genuinamente solo está sin hacer.
Los módulos de auditoría
Dos IHttpModules que escriben una fila por petición, de forma síncrona, en el hilo de la petición. Hoy cubren las dos mitades solo porque cada petición sigue pasando primero por IIS.

Qué empeoró y siguió peor

Cada etapa de arriba tiene un panel de coste. Varios están marcados como no es temporal, y esto es lo que suman.

Hay dos runtimes, dos pipelines de despliegue, y dos sitios donde mirar cuando algo va lento. La autenticación depende de un directorio en un sistema de archivos que las dos aplicaciones tienen que poder leer, lo que es un punto único de fallo que antes no existía. Una URL la sirven dos aplicaciones según una cabecera HTTP. Las reglas de tarificación están escritas con un estándar de lenguaje de 2015 porque tienen que compilar en net48.

Nada de eso es un impuesto de transición que desaparece al final. Es el coste corriente de haber elegido una migración strangler, y dura lo que duren el sidecar y los informes, es decir, indefinidamente.

Eso es lo que hay que pesar contra una reescritura big-bang. No “strangler es más seguro” en abstracto, sino: ¿estás dispuesto a ejecutar dos de cada cosa durante años, a cambio de no tener nunca un fin de semana en que la empresa entera no pueda registrar envíos?

Para un sistema que recibe reservas todo el día, ese intercambio merece la pena. Para una herramienta interna con cuarenta usuarios y una ventana de mantenimiento, muy a menudo no, y el patrón strangler acaba aplicándose a cosas a las que les habría venido mejor una reescritura y un día festivo.

La regla que produjo esta forma

Una regla, sostenida en cada commit: ningún commit puede dejar la aplicación sin poder desplegarse.

Por eso la forma de arriba queda desordenada. Una migración ordenada (una en la que el inventario final está vacío y el diagrama de arquitectura no tiene líneas punteadas) es una migración que tumbó el sistema para llegar ahí.

Catorce commits, y en todos ellos Meridian Despatch podía registrar una reserva.

Qué haría distinto

Nada del orden. Gateway primero, autenticación segundo, módulo más pequeño tercero estaba bien, y los dos días perdidos en la etapa 01 fueron el precio de aprender qué le hace el proxy a una aplicación que cree conocer su propia dirección.

Lo que sí cambiaría es la escritura. Dos veces en este diario (los informes en la etapa 07, las etiquetas en la etapa 10) el resumen se escribió desde el plan en lugar de desde el diff, y reclamó trabajo que no había ocurrido. Los dos se pillaron releyendo el inventario contra git log, lo que significa que la comprobación funciona, pero era una comprobación aplicada al final en vez de un hábito aplicado de principio a fin.

El arreglo es el panel de la derecha de cada etapa de esta página. Una afirmación con un hash de commit al lado se puede comprobar en cuatro segundos. Una sin él no se puede comprobar en absoluto, y en un documento cuyo único activo real es que sus malas noticias son fiables, esa diferencia lo es todo.