Migração de .NET Framework 4.8 para .NET 10 em ERP: o guia que ninguém quer escrever
🔄 Por que migrar ERP é diferente de migrar qualquer outra coisa
ERP não é aplicação. ERP é o sistema nervoso financeiro da empresa. Quando o ERP cai, a operação para: o caminhão não sai do CD porque a nota não emitiu, o cliente não compra porque o estoque não responde, o RH não paga porque a folha não fechou.
Essa é a diferença que muda toda a estratégia de migração. Migrar um SaaS B2C com 1 milhão de usuários é um problema técnico. Migrar um ERP com 200 usuários internos é um problema organizacional disfarçado de técnico.
As três variáveis que tornam ERP único:
A primeira é a acoplagem com processo de negócio. Cada tela, cada relatório, cada job noturno está amarrado a um processo que alguém na empresa executa há anos. Mexer no comportamento, mesmo “para melhor”, quebra rotina humana antes de quebrar código.
A segunda é a densidade de integrações. Um ERP médio fala com banco, contabilidade, fiscal (SEFAZ, eSocial, EFD-Reinf), folha, e-commerce, marketplaces, transportadoras, bancos de cobrança, conciliação, BI. Cada integração é um contrato implícito que precisa continuar funcionando durante a migração inteira.
A terceira é o estado persistente. ERP tem dados quentes que não podem ser perdidos nem por cinco minutos. Pedido aberto, lançamento contábil em rascunho, nota em transmissão, lote em separação. Migração com perda de estado é evento de demissão.
⚠️ Se você está pensando em migrar ERP da mesma forma que migrou um microsserviço de catálogo, pare. Releia os três parágrafos acima e refaça o plano.
🔬 Avaliação pré-migração: o trabalho que ninguém quer fazer
A maior parte das migrações fracassadas começa aqui. O time pula a auditoria porque “já conhece o sistema” e descobre na semana 14 que existe uma DLL compilada em 2011 sem código-fonte que faz o cálculo de ICMS de substituição tributária para Minas Gerais.
A auditoria pré-migração tem que produzir cinco artefatos. Sem eles, não comece.
1. Inventário de assemblies e dependências
Use a ferramenta oficial da Microsoft, o .NET Upgrade Assistant (sucessor do antigo Portability Analyzer e do try-convert). Ela analisa solução, projeto e binários, e produz relatório de incompatibilidades por categoria.
bash
# Instalação
dotnet tool install -g upgrade-assistant
# Análise da solução completa
upgrade-assistant analyze ./MeuERP.sln --target-framework net10.0
# Relatório detalhado em SARIF para integrar no SonarQube
upgrade-assistant analyze ./MeuERP.sln \
--target-framework net10.0 \
--format sarif \
--output ./reports/migration-analysis.sarif
O output classifica cada API em quatro buckets: suportada, suportada com mudança de comportamento, substituída por equivalente e removida sem substituto. O quarto bucket é onde mora o medo.
2. Mapa de integrações
Liste literalmente toda integração: protocolo, contrato, frequência, responsável de cada lado, SLA, contingência. Planilha simples resolve. Sem este mapa, você vai descobrir integração quebrada por reclamação de cliente.
3. Matriz de bibliotecas third-party
Para cada NuGet package em uso no .NET Framework, três colunas: versão atual, versão compatível com .NET 10, status (compatível, substituível, abandonada). Pacotes abandonados são decisão de arquitetura: você reescreve, paga por alternativa, ou aceita risco.
Casos clássicos de dor que aparecem nessa matriz:
- System.Web (WebForms, HttpContext clássico): não existe no .NET 10. Tudo que dependia disso vira ASP.NET Core middleware ou desaparece.
- Crystal Reports: sem versão suportada para .NET 10. Migração para QuestPDF, IronPDF, ou um motor de relatório separado.
- WCF servidor: morto. Cliente WCF voltou via CoreWCF, servidor não. Quem expõe SOAP precisa replanejar.
- EntityFramework 6: roda no .NET 10, mas não é o caminho. Migração para EF Core 9+ é o esperado, e ela não é trivial — o modelo de tracking, o comportamento de lazy loading e o LINQ provider mudaram.
4. Inventário de dados e schema
Identifique stored procedures, triggers, jobs SQL Server Agent, views materializadas, índices customizados. Se o ERP usa Service Broker, filas SQL ou CDC, documente. O banco normalmente sobrevive à migração — mas quem conversa com ele muda completamente.
5. Baseline de performance
Antes de migrar qualquer linha, capture métricas do sistema atual em produção: latência P50/P95/P99 das principais operações, throughput de transações por segundo nos picos, consumo de memória e CPU por servidor, tempo dos jobs noturnos. Sem baseline, você não consegue provar que a versão nova está melhor (ou pior) — e essa prova é exigida pela diretoria no dia seguinte ao go-live.
🔑 Regra prática: se a fase de avaliação durou menos de 4 semanas para um ERP com mais de 5 anos em produção, ela foi mal feita.
🧩 Big Bang vs. Estrangulador: a decisão que define os próximos 18 meses
Existem duas estratégias macro. Não existe terceira. Híbridos são variações destas duas.
Big Bang
Você reescreve tudo em paralelo, congela mudanças no sistema legado, escolhe um final de semana e vira a chave. Funciona em casos muito específicos:
- Sistema pequeno (menos de 200 mil linhas de código relevante).
- Equipe dedicada full-time por mais de 6 meses.
- Janela de manutenção de pelo menos 48 horas tolerada pelo negócio.
- Tolerância a risco alta da liderança.
Vantagem: simplicidade arquitetural. Desvantagem: se quebrar, quebra tudo, e o rollback é traumático. Para ERP de empresa real, raramente é a escolha certa.
Strangler Fig (Padrão do Estrangulador)
Cunhado por Martin Fowler em 2004, é o padrão de fato para migrações de longo prazo. A ideia: você coloca um proxy reverso na frente do sistema legado e, módulo por módulo, redireciona tráfego para implementações novas em .NET 10. O legado vai sendo “estrangulado” pela versão nova até morrer.
Funciona porque:
- Permite aprender com cada módulo migrado antes de partir para o próximo.
- Permite rollback granular (volta um módulo, não o sistema inteiro).
- Permite migrar com o sistema em produção, sem janela longa.
- Distribui risco no tempo.
Custo: convivência prolongada com duas plataformas. Time precisa manter duas stacks por meses. Custo de infraestrutura e cognitivo é real.
A topologia mínima de Strangler Fig em ERP fica assim:
┌─────────────────┐
Cliente HTTP ───▶│ Reverse Proxy │
│ (YARP / NGINX) │
└────────┬────────┘
│
┌────────────┼────────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ ERP Legado │ │ ERP .NET 10 │
│ .NET FW 4.8 │ │ Módulos novos │
│ IIS / Windows │ │ Linux / Docker │
└────────┬─────────┘ └────────┬─────────┘
│ │
└───────────┬─────────────┘
▼
┌────────────────┐
│ SQL Server │
│ (compartilh.) │
└────────────────┘
O YARP (Yet Another Reverse Proxy) é a escolha natural quando o time já é .NET. Ele é mantido pela própria Microsoft, roda no .NET 10 nativamente e tem APIs de roteamento programáticas que facilitam migração gradual.
Configuração mínima de roteamento por rota no YARP:
csharp
// Program.cs no proxy YARP
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
var app = builder.Build();
app.MapReverseProxy();
app.Run();
json
// appsettings.json
{
"ReverseProxy": {
"Routes": {
"modulo-novo-financeiro": {
"ClusterId": "erp-net10",
"Match": { "Path": "/api/financeiro/{**catch-all}" }
},
"modulo-legado-todo-resto": {
"ClusterId": "erp-legacy",
"Match": { "Path": "/{**catch-all}" }
}
},
"Clusters": {
"erp-net10": {
"Destinations": {
"d1": { "Address": "http://erp-net10-svc:8080/" }
}
},
"erp-legacy": {
"Destinations": {
"d1": { "Address": "http://erp-legacy-iis:80/" }
}
}
}
}
}
A ordem das rotas importa. A regra mais específica vai primeiro; o catch-all do legado fica por último. Cada novo módulo migrado entra como nova rota acima do catch-all.
💡 Erro comum: deixar o roteamento no código da aplicação em vez de no proxy. Isso amarra a estratégia de migração ao deploy da aplicação. Mantenha o roteamento na infraestrutura — você vai mudar essa configuração toda semana.
⚙️ Coexistência de versões e sincronização de dados
O calcanhar de Aquiles do Strangler Fig em ERP é o estado compartilhado. Os dois sistemas precisam enxergar a mesma realidade do banco, ao mesmo tempo, sem corromper.
Existem três padrões aceitáveis. Escolha um e seja consistente.
Padrão 1: Banco compartilhado, schema único
Mais simples. As duas aplicações apontam para o mesmo SQL Server, mesmas tabelas. Funciona quando o esquema do banco está estável e bem normalizado.
Vantagem: zero sincronização. Desvantagem: zero liberdade para evoluir o modelo no .NET 10. Toda mudança de schema precisa funcionar nos dois lados.
Padrão 2: Banco compartilhado, schemas separados por módulo
O .NET 10 cria suas próprias tabelas (em outro schema do mesmo banco) para módulos novos, e usa views ou stored procedures para integrar com tabelas legadas quando necessário. Boa transição para quem quer evoluir o modelo sem migrar dado.
Padrão 3: Banco separado com sincronização via CDC
O .NET 10 usa banco próprio. Change Data Capture (CDC) do SQL Server, Debezium, ou SQL Server Replication mantém os dados sincronizados nas duas direções. É o padrão mais robusto e o mais caro.
Recomendação prática para a maior parte dos ERPs: comece pelo Padrão 1, evolua módulo a módulo para o Padrão 2 quando a maturidade permitir, e só vá para o Padrão 3 se houver justificativa concreta (latência intolerável, isolamento regulatório, escala diferente entre módulos).
Independente do padrão, três regras inegociáveis:
A primeira é idempotência em toda operação distribuída. Se a mesma transação chega duas vezes (e vai chegar), o resultado tem que ser igual a chegar uma vez só.
A segunda é chaves de correlação em toda chamada cross-system. Quando der problema em produção, você precisa rastrear a mesma transação atravessando os dois sistemas.
A terceira é transações distribuídas devem ser evitadas. MSDTC entre .NET Framework e .NET 10 é receita para dor. Use saga, outbox pattern, ou compensação manual.
🐳 Containerização: o ponto onde a infra se reorganiza
.NET 10 roda em Linux. Isso não é detalhe — é mudança de paradigma operacional. Quem vinha de IIS, Windows Server e deploy via MSI agora vive de Docker, Kubernetes e GitOps.
A imagem base recomendada para ERP em produção é a mcr.microsoft.com/dotnet/aspnet:10.0-alpine quando peso importa, ou mcr.microsoft.com/dotnet/aspnet:10.0 (Debian) quando precisar de bibliotecas nativas. Alpine é ~80MB, Debian fica em ~210MB.
Dockerfile de produção mínimo, com multi-stage e usuário não-root:
dockerfile
# Stage 1: build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ["MeuERP.Api.csproj", "./"]
RUN dotnet restore "MeuERP.Api.csproj"
COPY . .
RUN dotnet publish "MeuERP.Api.csproj" \
-c Release \
-o /app/publish \
--no-restore \
/p:UseAppHost=false
# Stage 2: runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS runtime
WORKDIR /app
# Cultura pt-BR no Alpine exige icu
RUN apk add --no-cache icu-libs tzdata
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
ENV TZ=America/Sao_Paulo
# Usuário não-root
RUN addgroup -S app && adduser -S app -G app
USER app
COPY --from=build --chown=app:app /app/publish .
EXPOSE 8080
ENTRYPOINT ["dotnet", "MeuERP.Api.dll"]
Para o módulo legado em .NET Framework 4.8, a opção é a imagem mcr.microsoft.com/dotnet/framework/aspnet:4.8, que roda em Windows containers. Isso significa que seu cluster Kubernetes precisa ter node pools mistos: nós Linux para o .NET 10 e nós Windows para o .NET Framework 4.8 durante o período de coexistência.
⚠️ Custo escondido: nó Windows no Kubernetes (AKS, EKS, GKE) custa significativamente mais que nó Linux equivalente, e tem mais limitações operacionais. Planeje a saída do .NET Framework com agressividade — cada mês a mais de Windows nodes é dinheiro queimado.
🔁 CI/CD para ambiente híbrido
Pipeline precisa suportar três realidades simultaneamente: build do legado .NET Framework 4.8, build do novo .NET 10, e deploy coordenado dos dois.
A estrutura que funciona, exemplificada com Azure DevOps mas válida para GitHub Actions ou GitLab CI:
yaml
# azure-pipelines.yml
trigger:
branches:
include: [main, develop]
stages:
- stage: BuildLegacy
condition: contains(variables['Build.SourceBranchName'], 'legacy/')
jobs:
- job: BuildNetFramework
pool:
vmImage: 'windows-2022'
steps:
- task: NuGetCommand@2
inputs: { command: 'restore', restoreSolution: 'src/Legacy/MeuERP.Legacy.sln' }
- task: VSBuild@1
inputs:
solution: 'src/Legacy/MeuERP.Legacy.sln'
configuration: 'Release'
msbuildArgs: '/p:DeployOnBuild=true /p:WebPublishMethod=Package'
- stage: BuildModern
condition: not(contains(variables['Build.SourceBranchName'], 'legacy/'))
jobs:
- job: BuildNet10
pool:
vmImage: 'ubuntu-latest'
steps:
- task: UseDotNet@2
inputs: { version: '10.0.x' }
- script: dotnet test src/Modern/MeuERP.Modern.sln --collect "Code coverage"
- task: Docker@2
inputs:
command: buildAndPush
repository: meuerp/api
tags: |
$(Build.BuildId)
latest
- stage: DeployStaging
dependsOn: [BuildLegacy, BuildModern]
condition: succeeded()
jobs:
- deployment: DeployToStaging
environment: 'staging'
strategy:
runOnce:
deploy:
steps:
- task: KubernetesManifest@1
inputs:
action: deploy
manifests: 'k8s/staging/*.yaml'
Dois detalhes que economizam noites mal dormidas:
A primeira é branch naming convention que diferencia escopo. Branches começando com legacy/ ativam só o pipeline do .NET Framework. Branches normais constroem o moderno. Isso evita rebuild de tudo em todo commit.
A segunda é artefatos com versionamento idêntico nos dois lados. Mesma tag, mesmo build ID. Quando você precisa fazer rollback coordenado, ter versões alinhadas evita confusão de “qual versão do legado é compatível com qual versão do novo”.
💾 Migração de camada de dados
A camada de dados é onde mais código se reescreve. Padrões clássicos da transição:
De ADO.NET clássico para Dapper ou EF Core
Código legado tipicamente tem SqlConnection, SqlCommand, DataReader. O código funciona em .NET 10 (a API existe), mas perde-se a oportunidade de modernização. Dapper é a alternativa pragmática quando se quer manter SQL puro com binding leve. EF Core 9+ é a escolha quando vale o trade-off de abstração.
Antes (ADO.NET no .NET Framework):
csharp
public List<Pedido> BuscarPedidosPorCliente(int clienteId)
{
var pedidos = new List<Pedido>();
using (var conn = new SqlConnection(_connectionString))
{
conn.Open();
var cmd = new SqlCommand(
"SELECT Id, Numero, ValorTotal FROM Pedido WHERE ClienteId = @cid", conn);
cmd.Parameters.AddWithValue("@cid", clienteId);
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
pedidos.Add(new Pedido {
Id = reader.GetInt32(0),
Numero = reader.GetString(1),
ValorTotal = reader.GetDecimal(2)
});
}
}
}
return pedidos;
}
Depois (Dapper no .NET 10):
csharp
public async Task<IReadOnlyList<Pedido>> BuscarPedidosPorClienteAsync(
int clienteId,
CancellationToken ct = default)
{
await using var conn = new SqlConnection(_connectionString);
var sql = "SELECT Id, Numero, ValorTotal FROM Pedido WHERE ClienteId = @clienteId";
var pedidos = await conn.QueryAsync<Pedido>(
new CommandDefinition(sql, new { clienteId }, cancellationToken: ct));
return pedidos.AsList();
}
A diferença não é só estética. O código novo é assíncrono nativo (libera thread durante I/O), tem CancellationToken propagável, parametrização tipada que evita SQL injection por construção, e retorna IReadOnlyList que documenta a intenção.
Stored procedures
Continuam funcionando. SQL Server não muda. O que muda é a forma de chamar — async, parametrizada, com timeout configurado em nível de comando. Para ERP, o conselho é não tocar nas stored procedures durante a migração. Migre as chamadas, deixe o T-SQL para depois. Misturar refactor de SQL com troca de plataforma multiplica risco sem ganho proporcional.
🌐 Migração de APIs e serviços web
Casos que aparecem em quase todo ERP:
ASMX (Web Services clássicos): morreu no .NET Core. Reescreva como REST com ASP.NET Core ou, se mandatório manter SOAP, use CoreWCF (servidor SOAP no .NET moderno, mantido pela comunidade com apoio da Microsoft).
WCF servidor: idem ASMX. CoreWCF ou reescrita.
WebAPI 2 / OData: migra para ASP.NET Core. OData tem pacote oficial para .NET moderno (Microsoft.AspNetCore.OData).
SignalR: o Microsoft.AspNet.SignalR (clássico) e o Microsoft.AspNetCore.SignalR são bibliotecas diferentes com protocolos incompatíveis. Migração obriga atualização sincronizada de cliente e servidor. Em ERP com clientes desktop antigos, isso é projeto separado.
Padrão recomendado para módulos novos: Minimal APIs do .NET 10. Menos cerimônia que controllers, performance superior, integração nativa com OpenAPI.
csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
app.MapOpenApi();
app.MapGet("/api/financeiro/contas-pagar/{id:int}",
async (int id, IContaPagarService svc, CancellationToken ct) =>
{
var conta = await svc.ObterPorIdAsync(id, ct);
return conta is null ? Results.NotFound() : Results.Ok(conta);
})
.WithName("ObterContaPagar")
.Produces<ContaPagarDto>()
.Produces(StatusCodes.Status404NotFound);
app.Run();
🔐 Autenticação e autorização
Aqui mora um campo minado. O ERP legado provavelmente usa um destes esquemas:
- Forms Authentication com cookie do .NET Framework: cookie tem formato proprietário, não decodificável pelo .NET 10 sem adaptador.
- Windows Authentication (Active Directory): funciona nos dois mundos, mas a configuração muda completamente.
- Identity Server 3.x ou 4.x: descontinuado. Migração para Duende IdentityServer (pago) ou OpenIddict (open source).
A estratégia que sobrevive ao período de coexistência é cookies compartilhados via Data Protection API. O .NET 10 consegue ler cookies emitidos pelo .NET Framework se ambos compartilharem chaves de proteção. Configuração no .NET 10:
csharp
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(@"\\fileserver\dpkeys"))
.SetApplicationName("MeuERP")
.UseCryptographicAlgorithms(new AuthenticatedEncryptorConfiguration
{
EncryptionAlgorithm = EncryptionAlgorithm.AES_256_CBC,
ValidationAlgorithm = ValidationAlgorithm.HMACSHA256
});
E no .NET Framework 4.8, configuração equivalente via Microsoft.AspNet.DataProtection. Compartilhar ApplicationName e diretório de chaves é o que permite o login no legado valer no novo, e vice-versa.
Para projetos novos, a recomendação é abandonar cookies em favor de JWT com refresh token, principalmente se houver clientes mobile ou SPA Angular consumindo as APIs.
🖼️ Frontend Angular: a transição da interface
Quem migra ERP do .NET Framework normalmente vem de WebForms, MVC com Razor server-side, ou WinForms/WPF. Modernizar a interface costuma ser parte do escopo, e Angular é a escolha frequente para ERP por causa da maturidade dos componentes corporativos (PrimeNG, Kendo UI, Syncfusion).
A estratégia que funciona é Angular consumindo API .NET 10, integrado via reverse proxy junto com o legado. Dois padrões de transição:
Padrão A: Angular como nova SPA, legado intocado
Angular é uma aplicação separada, servida estaticamente, que consome as APIs novas em .NET 10. O usuário acessa app.empresa.com para o novo, legado.empresa.com para o que ainda não migrou.
Vantagem: separação total. Desvantagem: o usuário sente a divisão entre dois sistemas.
Padrão B: Angular embarcado em rota do mesmo domínio
A SPA Angular é servida pelo proxy reverso em um path específico (/app/). O legado continua nas rotas antigas. Para o usuário, é o mesmo sistema; para a arquitetura, são dois.
Configuração mínima do Angular consumindo API .NET 10 com interceptor para token JWT:
typescript
// auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const token = auth.getAccessToken();
if (token) {
req = req.clone({
setHeaders: { Authorization: `Bearer ${token}` }
});
}
return next(req);
};
typescript
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withInterceptors([authInterceptor]))
]
};
Detalhe de produção: configure CORS no .NET 10 com whitelist explícita, nunca AllowAnyOrigin em ambiente real. ERP costuma rodar em rede corporativa, mas isso não dispensa controle.
csharp
builder.Services.AddCors(options =>
{
options.AddPolicy("ErpFrontend", policy =>
{
policy.WithOrigins("https://app.empresa.com.br")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
app.UseCors("ErpFrontend");
Para migração gradual de telas, o padrão mais limpo é migrar por módulo de negócio inteiro, nunca por tela isolada. Se você migra a tela de “cadastro de cliente” mas deixa “consulta de cliente” no legado, o usuário troca de paradigma visual no meio da operação. Isso é receita para reclamação.
🧪 Testes, canary e feature flags
Migração de ERP em produção sem feature flags é roleta russa. Microsoft.FeatureManagement é o pacote oficial; alternativas robustas incluem LaunchDarkly (pago, recomendado para empresas), Unleash (open source self-hosted) e Flagsmith.
Caso de uso típico: o módulo financeiro novo está em .NET 10 atrás do proxy. Você quer ativar para 5% dos usuários internos antes de liberar geral.
csharp
// Configuração no Program.cs
builder.Services.AddFeatureManagement();
// Uso no controller ou endpoint
app.MapPost("/api/financeiro/lancar",
async (LancamentoDto dto, IFeatureManager features, ILegacyService legacy, IModernService modern) =>
{
if (await features.IsEnabledAsync("FinanceiroNovoBackend"))
return await modern.LancarAsync(dto);
return await legacy.LancarAsync(dto);
});
Com isso, você ativa, observa, e desativa via configuração — sem deploy. Combinado com observabilidade séria (OpenTelemetry → Prometheus/Grafana, ou Application Insights, ou Datadog), você consegue comparar latência, erro e throughput entre as duas implementações em tempo real.
Métricas obrigatórias para acompanhar durante migração:
A primeira é latência por endpoint, comparada com o baseline do legado. Se P95 do novo está pior que do legado, isso bloqueia o avanço da migração até resolver.
A segunda é taxa de erro 5xx por módulo migrado, segregada de erros do legado. Misturar dificulta diagnóstico.
A terceira é divergência de resultado quando rodando em paralelo. O padrão shadow traffic (proxy envia a mesma requisição para legado e novo, retorna a resposta do legado, compara as duas) é o método mais seguro de validar paridade funcional. Custa o dobro de processamento durante a fase de validação, e vale cada centavo.
⚠️ Riscos, rollback e o plano que ninguém quer executar
Plano de rollback não é opcional. É item de aprovação para colocar a migração em produção.
Para Strangler Fig, o rollback é reverter a configuração do proxy para apontar a rota de volta ao legado. Em YARP, é alterar o JSON e recarregar (ou via API administrativa). O tempo de rollback deve ser menor que 60 segundos para qualquer módulo. Se demora mais, o desenho está errado.
Cenários que acionam rollback automático:
- Taxa de erro 5xx do módulo novo passa de 2% por mais de 60 segundos.
- Latência P95 do módulo novo fica acima de 2x o baseline por mais de 5 minutos.
- Divergência de resultado (em shadow mode) passa de 0,1%.
Implementação mínima de rollback automatizado via Kubernetes liveness/readiness + alerta de Prometheus que dispara webhook para o sistema de configuração do proxy. Argo Rollouts ou Flagger automatizam canary com rollback baseado em métricas.
Lições de migrações que deram errado:
A primeira: subestimar dependências invisíveis. Aquele job que roda às 3h da manhã chamando uma DLL antiga, que ninguém lembra que existe. Inventário tem que cobrir Windows Services, Scheduled Tasks, jobs SQL Agent, integrações por arquivo, FTPs.
A segunda: migrar relatórios por último é um erro. Relatório é o que o usuário mais usa e mais reclama. Se você deixa para o fim, descobre tarde que o motor escolhido não suporta um caso específico.
A terceira: não envolver o time de operação cedo. Quem opera o ERP (analistas funcionais, suporte, contadoria) tem conhecimento que o time técnico não tem. Migração feita sem eles vira sistema tecnicamente correto e operacionalmente quebrado.
📈 Otimizações pós-migração
Depois que o módulo está estável em .NET 10, vale extrair o que a plataforma nova oferece e que o Framework 4.8 não dava:
Native AOT (Ahead-of-Time compilation): para microsserviços pequenos, reduz tempo de startup de segundos para milissegundos e diminui consumo de memória em ~50%. Limitação: nem toda biblioteca é compatível (qualquer coisa que use reflexão pesada quebra). Para ERP, faz sentido em jobs e workers, raramente na API principal.
Minimal APIs com source generators: serialização JSON via System.Text.Json source generator elimina reflexão em runtime. Ganho de performance mensurável em endpoints com payload grande.
HttpClient com IHttpClientFactory: substitui o anti-padrão do new HttpClient() repetido. Resolve esgotamento de sockets, problema crônico em integrações de ERP.
Channels e BackgroundService: substituem padrões antigos de filas in-memory (BlockingCollection, MSMQ local) com primitivas modernas, type-safe e async-first.
OpenTelemetry nativo: o .NET 10 tem instrumentação automática para HTTP, SQL, gRPC. Plugue exportador para o backend de observabilidade da empresa e ganhe traces distribuídos sem mudar código de negócio.
A consolidação de dependências também é hora de fazer: pacotes que existiam só para suprir lacunas do Framework 4.8 (Newtonsoft.Json, Polly em casos básicos, Autofac quando o DI nativo basta) podem sair, reduzindo superfície de manutenção.
💼 Estudo de caso: migração do módulo de contas a pagar
Para concretizar, um cenário composto baseado em padrões repetidos de projetos reais.
Contexto: ERP com 12 anos em produção, .NET Framework 4.8, ~600 mil linhas de código C#, SQL Server 2019, IIS em três servidores Windows, ~150 usuários simultâneos no pico. Módulo financeiro responde por ~40% das transações.
Decisão arquitetural: Strangler Fig com banco compartilhado (Padrão 1), migração começando pelo submódulo de contas a pagar — alta criticidade, escopo bem delimitado, integrações concentradas em poucos pontos (banco emissor, sistema bancário para envio de remessa CNAB).
Sequência de execução:
Semanas 1 a 4: auditoria, inventário, baseline de performance, desenho da topologia.
Semanas 5 a 8: setup de infraestrutura (cluster Kubernetes com node pools Linux e Windows, YARP em produção apenas roteando 100% para o legado, observabilidade com OpenTelemetry exportando para Application Insights).
Semanas 9 a 16: reescrita do submódulo de contas a pagar em .NET 10, com Minimal APIs, Dapper para acesso ao banco compartilhado, Angular 18 para a tela. Testes unitários cobrindo regras fiscais, testes de integração contra réplica de produção, shadow mode comparando resultados com o legado por 2 semanas.
Semanas 17 a 18: rollout gradual via feature flag — 5% dos usuários, depois 25%, depois 50%, depois 100%. Cada salto exige 48 horas de estabilidade nas métricas.
Semanas 19 em diante: o submódulo está 100% no novo, próximo módulo entra em planejamento.
Resultado mensurado após estabilização:
Latência P95 das operações de contas a pagar caiu de ~480ms para ~120ms (proporção típica quando se migra ADO.NET síncrono com IIS clássico para Minimal APIs com Kestrel). Memória por instância caiu pela metade. Tempo de deploy do módulo passou de 25 minutos (build + publicação MSI + restart de IIS) para 90 segundos (build de imagem Docker + rolling update no Kubernetes).
Custo: ~9 meses de calendário, 4 desenvolvedores em rotação parcial, 1 arquiteto dedicado, 1 SRE part-time. Investimento real entre R$ 600 mil e R$ 1,2 milhão dependendo de senioridade da equipe e geografia. Quem promete fazer migração equivalente por menos disso está vendendo otimismo, não engenharia.
🏁 O que fica
Migrar ERP do .NET Framework 4.8 para .NET 10 não é projeto de plataforma. É reorganização operacional com componente técnico. Quem trata como upgrade de runtime falha. Quem trata como redesign de processo, com a plataforma como meio, entrega.
Os princípios que sobrevivem a qualquer cenário:
A migração acontece em fases mensuráveis, nunca em evento único. Strangler Fig é o padrão; Big Bang é exceção rara para sistemas pequenos.
O plano de rollback é tão importante quanto o plano de avanço. Se você não consegue voltar em menos de um minuto, você não está pronto para avançar.
Observabilidade vem antes de funcionalidade nova. Você não migra o que não consegue medir.
A decisão sobre dados define a complexidade total. Banco compartilhado é o caminho da menor resistência inicial; pague o preço da liberdade futura conscientemente.
Frontend Angular é decisão paralela à migração de backend, com cronograma próprio. Misturar os dois projetos amplia risco sem ganho de eficiência.
A arquitetura serve ao negócio, não o contrário. Se o módulo legado funciona e não está bloqueando capacidade nova, ele pode esperar. Migração por modismo é desperdício de capital.
💬 Para reflexão: seu ERP atual está rodando hoje, agora, processando pedido e gerando nota. Ele tem 5, 8, 12 anos. Ele já sobreviveu a três trocas de gestão, duas crises econômicas e uma pandemia. A pergunta não é se ele precisa ser migrado. É se a empresa entende que migrá-lo é projeto de transformação organizacional, e está disposta a pagar esse preço com a seriedade que ele merece.
