- 📆 Publicado el
Migrando una solución legacy de .NET Framework 4.7.2 a .NET 10
- Autor

- Name
- Iker Ocio Zuazo
- X
- @0x10z
Migrar 185 proyectos de .NET Framework 4.7.2 a .NET 10… sin parar producción
Hace más de 10 años nació nuestra solución en .NET Framework 4.7.2. Hoy tiene más de 185 proyectos, múltiples árboles de dependencias y años de evolución encima. Lo mantenemos un equipo de 2-3 desarrolladores + team lead, y sigue siendo usado por cientos de clientes en todo el mundo.
Y el producto no está “legacy y abandonado”.
Está en mantenimiento activo. Sigue sacando nuevas funcionalidades. Sigue teniendo releases en producción.
Y aun así, decidimos empezar la migración hacia .NET moderno.
No voy a decir qué producto es ni a dar nombres: no es lo importante. Esto es algo que le pasa a miles de productos hoy en día donde la gente no se atreve a tocar lo que “funciona”, muchas veces sin ser conscientes de ello, de que ese miedo puede meterlos en un callejón sin salida y que solucionarlo después será mucho más doloroso.
Spoiler: no es un proyecto de meses. Es un proyecto de años.
Contexto real: migrar sin detener el negocio
Este es probablemente el punto más importante:
Teníamos una serie de condicionantes totalmente entendibles dada la naturaleza del producto, y la estrategia se construyó en base a ellos.
Mientras modernizábamos la base técnica:
- Seguíamos sacando nuevas versiones
- Seguíamos desarrollando nuevas features
- Seguíamos corrigiendo bugs
- Seguíamos manteniendo compatibilidad con clientes
No hubo freeze. Era inviable pedirle eso a los stakeholders: al final del día nuestro salario lo paga el producto, y nadie acepta parar el negocio por una migración (y en el fondo es totalmente entendible).
No hubo “branch de migración” aislada. Eso jugaba en nuestra contra: la forma más fiable de validar que no rompíamos nada fue iterar en pequeños pasos, dejando que los tests y el comportamiento en producción confirmaran cada cambio.
No hubo big-bang rewrite. Sí, hubo un mini‑big‑bang para refactorings aislados provocados por breaking changes de librerías, pero siempre controlando lo que se hacía y aislándolo al mínimo para poder revertir rápido si algo dejaba de funcionar.
Todo convivió.
Y eso cambia completamente la estrategia.
Fase 1 (2 años): Preparar el terreno sin cambiar el runtime
Durante más de dos años, el foco no fue .NET 10. De hecho, cuando esto empezó, la última versión LTS era .NET Core 3.1, a punto de llegar a .NET 5.
Fue preparar los 185 proyectos para que algún día pudieran migrarse.
1. Migrar todos los proyectos a SDK-Style
Hay varias herramientas, pero la que nosotros usamos fue CsprojToVs2017.
Prefiero huir de utilidades estilo “wizard” de Visual Studio que hacen cosas en segundo plano sin que realmente entiendas qué están cambiando. Por eso intentamos hacer cada conversión a conciencia: revisando los cambios, entendiendo los targets y evitando magia oculta.
La herramienta funcionó bastante bien en la mayoría de los proyectos; solo tuvimos que retocar a mano casos puntuales con targets personalizados, build props heredados o imports raros. En general, nos permitió convertir 185 .csproj en poco tiempo sin rehacer todo a mano.
Seguíamos en net472. Pero cambiamos todos los .csproj a formato SDK.
Este fue el desbloqueador principal.
Con esto conseguimos hacer un poco más compatible nuestra solución basada en msbuild con dotnet CLI y abrir la puerta a muchas mejoras.
¿Qué nos permitió esto que un proyecto clásico no permite igual?
Elevar el nivel de lenguaje
SDK-style facilita usar una versión fija de C# (por ejemplo
<LangVersion>11</LangVersion>) en todos los proyectos. Generalmente prefiero evitar la incertidumbre delatest. Nos permitió usar pattern matching moderno, records y file-scoped namespaces sin tener que tocar configuraciones específicas en cada proyecto.Activar Nullable Reference Types
Pasar a SDK-style nos permitió habilitar
nullablede forma consistente en toda la solución. Esto nos ayudó a reducir NRE históricos y a tener avisos tempranos sobre posibles nulls sin cambiar el runtime. En varios casos nos llevó a limpiar APIs antiguas que asumían objetos no nulos por defecto.Integrar analizadores modernos
Poder referenciar analizadores desde un
Directory.Build.propshizo viable el uso de Roslyn analyzers, reglas de calidad y reglas de seguridad.Mejor pipeline de compilación
El SDK-style es mucho más consistente con
dotnet buildy nos permitió tener builds incrementales más fiables. Antes, algunos proyectos sufrían de build incremental roto (compilaba todo de nuevo o no detectaba cambios), y esto mejoró bastante. Al usar herramientas comodotnet publishydotnet test, nuestra pipeline se volvió más predecible y reproducible.Multi-targeting real
Gracias al SDK-style pudimos escribir proyectos con
TargetFrameworkscomonet472;netstandard2.0y crear “puentes” de compatibilidad. Esto nos permitió compartir librerías entre el mundo legacy y el futuro .NET sin duplicar código.Estas adaptaciones a
netstandardnos obligaron a refactorizar deuda técnica y mejorar el diseño de arquitectura que teníamos en algunos sitios, porque muchas APIs antiguas no encajaban bien en un esquema compartido.Centralización con Directory.Build.props
Configurar reglas comunes en un solo lugar nos permitió aplicar políticas homogéneas a los 185 proyectos. Usamos paquetes NuGet centralizados (CentralPackageVersions) para manejar versiones de dependencias desde un único punto, evitando drift de paquetes entre proyectos. Además facilitó la gobernanza técnica y el onboarding de nuevos desarrolladores, porque las reglas estaban en un único lugar.
Mejor integración con CI/CD moderno
Al tener proyectos en formato SDK, las pipelines se simplificaron y pasamos a usar
dotnet build/dotnet testde forma consistente en todos los entornos. Eso nos permitió dejar atrás cadenas de herramientas legacy (nunit3-console, msbuild.exe con scripts personalizados, etc.) y reducir la superficie de mantenimiento. Además, el formato SDK facilita empaquetar artefactos y mantener el mismo proceso en local, en CI y en producción, sin tener que mantener scripts distintos para cada entorno.
Y todo esto sin abandonar todavía .NET Framework.
SDK-Style no fue solo estética. Pudimos desbloquear capacidades modernas dentro de un entorno legacy.
2. Pasar de DLLs sueltas a NuGets versionados
Al revisar los .csproj ya convertidos, lo que más llamaba la atención era la cantidad de referencias a DLLs directas: algunas eran paquetes open source que ya tenían NuGets, otras eran librerías internas que nadie mantenía y había que retomar, y algunas simplemente estaban ahí porque así había sido siempre.
Esto no fue solo un cambio técnico: fue un cambio de mentalidad. En lugar de compartir artefactos por correo o metiéndolos en carpetas compartidas, empezamos a generar paquetes con versión explícita, publicar a un feed interno y consumirlos como dependencias bien definidas. Y avisar a otros equipos para que hicieran lo mismo. Nadie debía seguir utilizando estas DLLs si nos tomábamos el trabajo de publicar NuGets.
Automatizamos la creación y gestión de versiones: dejamos de depender de lo que cada quien “recuerda haber subido” y pasamos a un modelo donde el proceso es reproducible y auditado. Esto nos obligó a refactorizar código para dejar de usar ciertas librerías, o a retomar proyectos abandonados para poder distribuirlos como NuGet.
Nuestro producto hasta ese momento funcionaba por inercia, pero esa inercia estaba perdiendo velocidad y, tarde o temprano, habría colapsado. Cada paso que damos en esta dirección son años de vida que le otorgamos al producto, y es nuestra responsabilidad ayudar a que los managers lo entiendan.
3. Crear puentes con .NET Standard 2.0
Una vez migrados los .csproj a SDK-style, el siguiente salto fue identificar qué partes del sistema podían vivir en .NET Standard 2.0.
La idea de “puentes” era simple: tener bibliotecas que podían ser referenciadas desde net472 (el mundo legacy) y desde net10 (el mundo moderno) sin tener que duplicar código.
Esto nos obligó a:
- Revisar qué APIs estábamos usando (muchas eran específicas de .NET Framework y no existían en
netstandard). - Mover lógica común a proyectos con
TargetFrameworks="net472;netstandard2.0". - Siempre intentamos evitar compilaciones condicionales (
#if NET472,#if NETSTANDARD2_0) porque manchan el código y dificultan saber qué funciona dónde. Preferíamos mantenerlo lo más estándar, explícito y simple posible, sin abrir más caminos de los necesarios. - Y tampoco había que volverse loco: si algo no se podía migrar a
netstandard, estaba bien. Cada cambio tenía que pasar el filtro esfuerzo/beneficio.
Beneficios clave:
- Compatibilidad simultánea con net472 y .NET moderno.
- Empezar a desacoplar el core, porque las dependencias cruzadas al runtime legacy se volvieron explícitas.
- Forzar limpieza de APIs no compatibles: si algo no compilaba en
netstandard2.0, teníamos que replantearlo o reemplazarlo.
No fue una solución mágica, pero sí una estrategia de transición que nos permitió avanzar paso a paso sin tener que migrar todo de golpe.
4. Actualizar dependencias legacy (Unity y otras)
Esta fue la parte más costosa.
Trabajábamos con paquetes antiguos y versiones que ya no se mantenían, y uno de los mayores bloqueos era el miedo a actualizar NuGets porque podían romper en runtime.
Al final, optamos por un esquema pragmático:
- Cada release intentábamos tener todo lo más actualizado posible.
- Lo que no se podía actualizar lo documentábamos (¿por qué? ¿qué fallaba? ¿qué dependencias colaterales tenía?) para poder planificar acciones concretas.
- En algunos casos, eliminar la dependencia era la solución más limpia: refactorizar un poco y conseguir que el código funcionara sin ese paquete.
- En otros, creamos tareas específicas para adaptar el proyecto a la nueva versión del paquete y prevenir regresiones.
Esto fue, en esencia, pagar deuda técnica acumulada durante más de una década. No es “migración de framework”, es dar soporte a lo que ya había y preparar el terreno para avanzar.
5. Identificar proyectos que podían migrar directamente a .NET 10
Mientras trabajábamos en limpiar dependencias y crear puentes netstandard, nos dimos cuenta de que había proyectos que no necesitaban pasar por ese puente: eran servicios Windows aislados, con pocas dependencias cruzadas hacia el resto de la solución.
En esos casos migramos el proyecto principal directamente a .NET 10, y movimos contratos/abstracciones a netstandard para que siguieran sirviendo como puente.
El beneficio fue doble:
- Nos quitamos una dependencia deprecada (Topshelf) que usábamos para ejecutar Windows Services en .NET Framework.
- .NET 10 soporta Windows Services de forma nativa, así que matábamos varios pájaros de un tiro.
Estos proyectos funcionaron como laboratorios de pruebas: nos permitieron ver cómo se comportaba código antiguo bajo nuevas versiones de .NET sin tener que migrar todo el ecosistema de una vez.
Además, nos daban confianza para seguir en esta dirección: muchas veces nos topábamos con caminos muertos que nos obligaban a borrar ramas y empezar de cero, pero tener proyectos ya corriendo en .NET 10 nos daba un extra de seguridad y una base desde la cual continuar la migración.
6. Resolver diferencias en carga de assemblies
.NET moderno no resuelve DLLs igual que .NET Framework.
En .NET Framework, el runtime tiene un mecanismo de probing, binding redirects y GAC que hace que muchos Assembly.Load "magícamente" encuentren lo que necesitan, incluso en escenarios muy heterogéneos (bin, subcarpetas, cargas dinámicas desde plugins, etc.). En .NET 10 el modelo es distinto: cada proceso tiene un AssemblyLoadContext que no hace probing a las mismas rutas ni respeta binding redirects de la misma forma. Cuando convivían ambos mundos, ese cambio nos generaba errores como:
FileNotFoundException/FileLoadExceptional resolver assemblies que sí existían enbinpero no estaban en el contexto correcto.- Conflictos de versión entre assemblies cargados desde el runtime compartido (shared framework) y assemblies locales.
- Módulos cargados dinámicamente (por reflection, plugins, etc.) que no encontraban sus dependencias.
Para evitar que estos fallos detuvieran el runtime, implementamos un handler de AppDomain.CurrentDomain.AssemblyResolve que hace un probing muy similar al que hacía el .NET Framework clásico y nos da un punto único de control.
En esencia el handler hacía tres cosas:
- Comprobar si el assembly ya está cargado en el
AppDomain. - Buscar en
bin(o en la carpeta de salida) un*.dllcon el mismo nombre y cargarlo. - Si había un mismatch de versión con el shared framework, intentar cargarlo solo por nombre para dejar que el runtime elija.
⚠️ Esta solución estuvo pensada como temporal: la idea era tener este “parche” activo mientras convivían componentes .NET Framework y .NET 10 en el mismo proceso. En realidad es la parte que menos me gusta de toda la migración: son ñapas que deben tener fecha de caducidad. Una vez que toda la solución migrara a .NET 10, esto debería poder eliminarse y volver al comportamiento estándar de
AssemblyLoadContext.
¿Qué hacía este handler y por qué?
- Evita recursion infinita con un flag de reentrada.
- Ignora assemblies de recursos (
*.resources) que nunca van a estar en el bin. - Reusa assemblies ya cargados, lo que evita que el runtime cargue múltiples versiones del mismo ensamblado.
- Busca en el
bin, que era donde los proyectos legacy dejaban las DLLs sueltas. - Cae a
Assembly.Load(Name)cuando hay mismatch de versiones con el shared framework: así dejamos al runtime decidir si debe usar la versión del framework o la local. - Cachea fallos para no repetir trabajo innecesario en cada resolución.
Este enfoque nos permitió tener una transición gradual sin tener que rediseñar toda la carga de plugins/assemblies desde el día cero, y fue especialmente útil en escenarios en los que la misma app tenía partes .NET Framework y partes .NET 10 cargadas en el mismo proceso.
Punto de inflexión: migrar el proyecto principal a .NET 10
Este es el punto en el que estamos ahora.
Llegar hasta aquí nos llevó muchas horas de trabajo: preparar 185 proyectos, limpiar deuda técnica, estabilizar builds y construir los puentes necesarios para que coexistieran .NET Framework y .NET 10 en el mismo repositorio.
Migrar el proyecto base fue el primer paso real de la migración a .NET 10. A partir de aquí:
- Generamos artefactos compilados en .NET 10
- Validamos pipelines reales
- Probamos compatibilidades
- Confirmamos que la estrategia era sólida
Pero esto no es el final, ni de cerca. Es el punto de partida de la migración que viene.
Fase 2 (lo que viene — estimación: al menos 2 años más)
Hasta aquí hemos llegado migrando el proyecto principal a .NET 10 y dejando todos los demás listos para el salto. A partir de este punto, empieza la segunda fase: es la parte que aún no hemos recorrido, pero que ya tenemos planificada.
Una vez que todos los proyectos son potencialmente compatibles para migrarse, el trabajo deja de ser “mover un switch” en un .csproj y se convierte en un proceso de toma de decisiones.
- Migrar de adentro hacia afuera. Empezamos por el núcleo, donde hay más dependencias, y avanzamos hacia los bordes. Esto reduce la probabilidad de romper cadenas de referencias que aún no se han tocado.
- Respetar el árbol de dependencias. No migramos proyectos solo porque estén “listos”: migramos proyectos que no obliguen a migrar primero a muchos otros. Tener claro qué depende de qué evita sorpresas en los builds.
- Documentar las dependencias y tener una hoja de ruta. Mapear cómo están conectados los proyectos (qué depende de qué, qué librerías son compartidas, qué proyectos son “puentes”) nos da visibilidad para decidir el siguiente paso y no hacerlo a ciegas.
- Filtros de migración por proyecto. Cada proyecto migrado tiene que pasar por varios checkpoints: compilación y tests locales, pruebas en pipeline, validación en entornos de preproducción y monitoreo en producción.
- Mantener releases activos en paralelo. Aunque estemos migrando, seguimos entregando valor y cerrando issues. Eso significa que cualquier cambio debe poder coexistir con el código que todavía corre en producción.
- Evitar regresiones. Priorizamos la estabilidad: cualquier migración que introduzca regresiones es un paso atrás, no un avance.
Por eso estimamos que esta segunda fase llevará, como mínimo, otros dos años: no estamos migrando un repositorio, estamos migrando un producto vivo con cientos de clientes encima.
Si has llegado hasta aquí con un problema similar, espero que esto te sirva de referencia. No hay una fórmula mágica: hay decisiones, compromisos y mucha paciencia. Lo que sí es cierto es que el mejor momento para empezar fue hace años, y el segundo mejor momento es ahora.