Migrar una herramienta legacy a .NET 8 y MAUI en seis meses: qué decidí y qué dejé fuera
Seis meses. Esa fue la ventana real, no la optimista, para sacar una herramienta legacy de escritorio de su base legacy y dejarla funcionando en producción sobre .NET 8 y .NET MAUI. La herramienta la usan técnicos de campo y personal de soporte para configurar dispositivos conectados, así que no había margen para un «ya lo arreglamos en la siguiente versión» en lo básico: tenía que arrancar, conectar con el hardware, y no romper flujos que la gente usa todos los días.
Cuando te dan seis meses para migrar y rediseñar la arquitectura de una aplicación que lleva años en producción, la primera pregunta no es «qué patrón uso», es «qué puedo permitirme no decidir ahora sin que me explote en la cara en el mes cuatro». Esa disciplina de secuenciar decisiones es lo que más me costó al principio y lo que más me sirvió después.
Migrar legacy a .NET 8 y MAUI: las decisiones de la primera semana
Fijé tres cosas antes de escribir una línea de producto: el framework de UI, la forma de separar capas, y cómo iba a convivir el código nuevo con los datos que ya existían en el sistema legacy. Todo lo demás lo dejé abierto a propósito.
MAUI, no WPF ni un rediseño solo-Windows. El cliente actual es Windows y Windows Server, pero apostar por una UI atada al framework de escritorio de turno significaba heredar el mismo problema que estábamos resolviendo: una base que en unos años vuelve a quedarse corta. MAUI con .NET 8 nos daba MVVM de serie, un solo lenguaje de UI compartible, y la posibilidad de no reescribir la capa de presentación si algún día hacía falta soportar otro entorno. No usamos esa portabilidad en los seis meses —y eso también fue una decisión, no un descuido— pero la arquitectura no la bloqueaba.
Clean Architecture con capas explícitas, no «todo en el code-behind». El legacy que migrábamos tenía lógica de negocio mezclada con acceso a datos y con la propia UI, así que cualquier cambio pequeño obligaba a tocar tres sitios a la vez y a probarlo todo a mano. Separar dominio, aplicación e infraestructura desde el primer commit no era un capricho académico: era la única forma de que el equipo pudiera tocar una capa sin tener que entender las otras dos enteras.
Un servicio de importación explícito para los datos legacy, en vez de migrar la base de un tirón. Esto fue la decisión que más tiempo nos ahorró. En vez de escribir un script de migración de base de datos que se ejecuta una vez y reza, construí un servicio de importación que la propia herramienta podía invocar, con reglas explícitas para cada estado de los registros legacy. Así, si un cliente tenía datos raros que no habíamos previsto, el fallo era controlado y localizado, no una migración de base de datos a medio terminar en producción.
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
builder.Configuration.AddJsonPlatformBundle();
builder.Services.Configure<DeviceSyncOptions>(
builder.Configuration.GetSection("DeviceSync"));
builder.Services.AddSingleton<IDeviceRepository, SqliteDeviceRepository>();
builder.Services.AddSingleton<ILegacyImportService, LegacyImportService>();
builder.Services.AddTransient<DeviceListViewModel>();
builder.Services.AddTransient<DeviceListPage>();
return builder.Build();
}
}
Ese IOptions<DeviceSyncOptions> no es decoración. La configuración del importador —ruta de la base legacy, tamaño de lote, si se ejecuta en modo simulación antes de escribir nada— tenía que poder cambiar por entorno de cliente sin recompilar, porque cada instalación de campo tenía particularidades que no conocíamos de antemano.
El servicio de importación en sí quedó así, con el estado del registro legacy decidiendo explícitamente qué pasa con cada uno en vez de dar por hecho que todos entran limpios:
public sealed record DeviceSyncOptions
{
public required string LegacyDatabasePath { get; init; }
public int BatchSize { get; init; } = 200;
public bool DryRun { get; init; }
}
public interface ILegacyImportService
{
Task<ImportResult> ImportAsync(CancellationToken cancellationToken);
}
public sealed class LegacyImportService(
ILegacyDeviceReader reader,
IDeviceRepository repository,
IOptions<DeviceSyncOptions> options,
ILogger<LegacyImportService> logger) : ILegacyImportService
{
private readonly DeviceSyncOptions _options = options.Value;
public async Task<ImportResult> ImportAsync(CancellationToken cancellationToken)
{
var legacyRecords = await reader.ReadAllAsync(_options.LegacyDatabasePath, cancellationToken);
var imported = 0;
var skipped = 0;
foreach (var batch in legacyRecords.Chunk(_options.BatchSize))
{
foreach (var record in batch)
{
var outcome = record.Status switch
{
LegacyDeviceStatus.Active => await repository.UpsertAsync(record.ToDevice(), cancellationToken),
LegacyDeviceStatus.Decommissioned => ImportOutcome.Skipped,
LegacyDeviceStatus.Unknown => throw new InvalidLegacyRecordException(record.Id),
_ => ImportOutcome.Skipped
};
if (outcome == ImportOutcome.Imported) imported++;
else skipped++;
}
}
logger.LogInformation(
"Importación legacy completada: {Imported} importados, {Skipped} omitidos, DryRun={DryRun}",
imported, skipped, _options.DryRun);
return new ImportResult(imported, skipped);
}
}
public sealed record ImportResult(int Imported, int Skipped);
El switch con LegacyDeviceStatus.Unknown lanzando excepción en vez de «seguir adelante y ya veremos» fue intencional: prefería que el importador se detuviera y avisara a que colara silenciosamente un registro que no sabíamos interpretar. Un dato mal importado en una herramienta que configura hardware en producción no es un bug cosmético.
Lo que dejé fuera a propósito
Con seis meses de reloj, cada «sí» a algo era un «no» implícito a otra cosa. Lo que quedó fuera del alcance, de forma consciente:
- Cobertura de tests exhaustiva desde el día uno. Cubrí con xUnit y Moq el servicio de importación y las reglas de dominio críticas —donde un fallo silencioso hace daño real—, pero no perseguí un porcentaje de cobertura global. Priorizar los tests donde el coste de fallar es alto, y dejar sin cubrir la capa de presentación en la primera entrega, fue una apuesta deliberada.
- Soporte multiplataforma real de MAUI. Elegimos MAUI por la arquitectura que habilita, no porque fuéramos a lanzar una versión para otro sistema en esos seis meses. Configuré el proyecto para no cerrar esa puerta, pero no invertí tiempo en probar ni optimizar nada fuera de Windows.
- Migración de la interfaz feature a feature paralela a la vieja. No hubo convivencia larga de dos interfaces mientras migrábamos. Fue un corte con fecha, apoyado en el modo
DryRundel importador para validar antes de cortar.
Lo que de eso duele después
El código del LegacyImportService sigue teniendo una responsabilidad que hoy separaría en dos: lee y transforma. En su momento fusionarlas ahorró una interfaz y una implementación que no teníamos tiempo de diseñar bien; hoy, cada vez que alguien añade un nuevo estado legacy, toca ese único método en vez de una pieza más pequeña y aislada. Es deuda técnica consciente, documentada, y todavía sin pagar.
Y la ausencia de tests en la capa de ViewModels se nota cuando alguien cambia una regla de UI y no hay una red que avise si rompió algo de la interacción con el usuario. Lo resolvimos con más disciplina de QA manual sobre esas pantallas, que es un parche razonable pero no gratis: cuesta tiempo de otra persona en cada entrega.
public sealed partial class DeviceListViewModel(
IDeviceRepository repository,
ILogger<DeviceListViewModel> logger) : ObservableObject
{
[ObservableProperty]
private ObservableCollection<DeviceItem> devices = [];
[ObservableProperty]
private bool isBusy;
[RelayCommand]
private async Task LoadDevicesAsync()
{
if (IsBusy) return;
IsBusy = true;
try
{
var items = await repository.GetAllAsync();
Devices = new ObservableCollection<DeviceItem>(items);
}
catch (Exception ex)
{
logger.LogError(ex, "No se pudo cargar la lista de dispositivos");
}
finally
{
IsBusy = false;
}
}
}
Este DeviceListViewModel es exactamente el tipo de pieza que hoy dejaría con un test que verifique que un fallo del repositorio no deja IsBusy en true para siempre. En su momento, con el reloj corriendo, confié en la revisión manual. Funcionó, pero fue una apuesta, no una garantía.
Lo que me llevo
Si algo aprendí de tener seis meses de verdad y no de PowerPoint, es que la arquitectura que sobrevive no es la más completa el día del lanzamiento, sino la que deja las costuras en los sitios correctos para que la deuda que inevitablement dejas fuera se pueda pagar después sin reescribir todo. Separar capas desde el principio, aislar el punto de fricción con el legacy en un único servicio, y ser explícito sobre qué no íbamos a cubrir con tests, fue lo que nos permitió llegar a producción en plazo sin que «en plazo» significara «roto». Es el mismo patrón que aplico en modernización de sistemas legacy cuando hay que convivir con datos legacy sin pararlo todo, y en desarrollo .NET a medida cuando el framework de UI es MAUI desde el primer commit.
