The problem
A large enterprise runs a portfolio of a few hundred .NET applications that accumulated over fifteen or more years. Most target .NET Framework 4.x. Some are ASP.NET Web Forms or MVC sites on IIS, some are WCF services, some are Windows services and scheduled console jobs, and a meaningful number are WinForms or WPF desktop tools. They share internal NuGet packages, a handful of common databases, and a lot of undocumented configuration.
The business wants to move this estate onto a supported modern runtime and a container platform. The real goal is not "be on .NET 10". It is: reduce operational and security risk, make the estate cheaper to change, and do it without a long freeze on feature delivery or a wave of production incidents.
The hard part is control. With hundreds of applications, a team can easily spend months migrating code before discovering that a shared library, a database, or a release process forces a different order — or that nobody can say which migrated applications are actually safe to release.
Illustrative scale and assumptions
| Dimension | Assumption used in this design |
|---|---|
| Applications | ~300 deployable units across ~40 business domains |
| Source | Several hundred repositories, mixed Git and migrated TFVC history |
| Shared code | 50–100 internal NuGet packages, some multi-targeted, some not |
| Runtime today | Mostly .NET Framework 4.6–4.8, IIS on Windows Server, some Windows services |
| Target | Current LTS .NET on Linux containers where viable; Windows hosting where not |
| Platform | A managed Kubernetes or OpenShift platform, plus a smaller Windows estate |
| Constraint | Feature teams keep shipping during the program |
These numbers are assumptions for reasoning, not measurements.
Constraints and non-functional requirements
- Behavior preservation is the primary quality attribute. Modernization that changes business behavior is a defect, even when the new behavior looks "better".
- No big-bang cutover. Each application must be releasable independently, with rollback.
- Shared dependencies set the order. A shared library cannot move to
net10.0alone until its consumers can handle it; multi-targeting is the bridge. - Security and compliance stay continuous. Identity, secrets handling and audit logging must be at least as strong after migration as before.
- Observability before migration, not after. Without a baseline of today's behavior and performance, "validated" means nothing.
- Cost must be visible per wave, so the program can be re-planned with evidence instead of optimism.
End-to-end approach
- 01.
Assess
Automated inventory of every repo: target frameworks, project style, packages, APIs used, hosting model, data access, config.
- 02.
Classify
Assign each app a modernization path and a difficulty rating from evidence, not interviews alone.
- 03.
Plan waves
Order work by the dependency graph: shared libraries first, leaf applications last.
- 04.
Define gates
Agree the evidence each app must produce before release: tests, parity checks, performance, security.
- 05.
Migrate
Repeatable playbooks per path, applied by domain teams with a central enablement team.
- 06.
Validate
Side-by-side comparison against the baseline; progressive rollout with rollback.
- 07.
Operate
Retire old hosting, keep the platform current, feed lessons back into the playbooks.
1. Assess: build the inventory from the code
Interviews and spreadsheets go stale on day one. The assessment is a pipeline that scans every repository and writes structured facts:
- Project format (legacy
.csprojvs SDK-style), target frameworks, and output types. - Package references with versions, flagged where a package has no compatible modern release.
- Use of APIs that do not exist on modern .NET:
System.Web, WCF server-side hosting, .NET Remoting, AppDomain creation, Windows Workflow Foundation, and code-access security. - Hosting model: IIS site, Windows service, scheduled task, desktop app.
- Data access: Entity Framework 6, LINQ to SQL, raw ADO.NET, stored-procedure heavy designs.
- Configuration:
web.config/app.configtransforms, machine-level config, registry and file-system dependencies. - Inbound and outbound dependencies: which applications call which services, which share databases or queues.
The output is a queryable dataset, not a report. Every later decision cites it.
2. Classify: one path per application
| Path | When it fits | Typical work |
|---|---|---|
| Retain / retire | Low value, low change rate, or already scheduled for replacement | Isolate, patch, document the exit |
| Rehost | Must keep .NET Framework for now (heavy Web Forms, WF, third-party constraints) | Move to supported Windows hosting; containerize on Windows only if the platform supports it well |
| Upgrade in place | Libraries, console jobs, services, MVC/Web API apps with modest System.Web coupling | Convert to SDK-style, retarget, replace incompatible packages, port hosting to the generic host / ASP.NET Core |
| Incremental migration | Large ASP.NET apps that cannot move in one release | Put ASP.NET Core in front with a reverse proxy, route endpoints across one at a time, share session/auth during the transition |
| Re-architect | Only where the domain needs a different shape (scaling, ownership, release independence) | Decompose behind stable contracts; justify per case |
| Replace | Commodity capability now better served by a product or platform service | Data migration and integration, not code migration |
The classification uses explicit rules over the inventory — for example, "Web Forms pages above a threshold → rehost or incremental, never upgrade in place". A human review confirms or overrides each result, and the reason is recorded.
3. Plan waves around the dependency graph
Shared code is the critical path. The wave planner builds a graph of project and package references and sorts it:
- Foundation wave: shared NuGet packages multi-target
net48;net10.0(or an intermediatenetstandard2.0where that is cheaper). Nothing breaks for existing consumers. - Pilot wave: five to ten applications that cover every migration path, chosen for representativeness rather than ease. The playbooks are tuned here.
- Domain waves: applications grouped by domain and by shared database, so data-layer changes are made once per group.
- Long tail: low-change applications, often rehosted or retired rather than upgraded.
Each wave has an explicit entry condition (its dependencies are migrated) and exit condition (all applications passed their gates or were re-planned).
4. Quality gates that make release decisions defensible
A migrated application is releasable only when it produces this evidence:
| Gate | Evidence |
|---|---|
| Build | Clean build on the target framework, analyzers enabled, no suppressed compatibility warnings without a recorded reason |
| Tests | Existing tests pass. Where coverage is thin, characterization tests are written before migration against the old system |
| Behavioral parity | Recorded production-like requests replayed against old and new; responses compared with known-acceptable differences listed |
| Performance | Latency and throughput within an agreed tolerance of the baseline under representative load |
| Security | Dependency scan clean or risk-accepted; identity flows verified; secrets moved out of config files |
| Operability | Health checks, structured logs, metrics and traces wired to the platform; runbook updated |
| Rollback | A tested way back: the previous deployment still runnable, data changes backward-compatible |
The gates are automated in CI/CD wherever possible. A release manager checks evidence — not a migration engineer's confidence.
Target architecture
Edge
- DNS & TLS
- WAF
- API gateway / reverse proxy
- Incremental-migration routing
Applications
- ASP.NET Core services
- Generic-host workers
- Migrated APIs
- Rehosted .NET Framework apps
State & data
- Existing SQL databases
- Schema changes backward-compatible
- Distributed cache
- Message broker
Security
- Central identity provider (OIDC)
- Workload identity
- Secret store
- Dependency & image scanning
Observability
- OpenTelemetry traces
- Structured logs
- Metrics & SLOs
- Parity dashboards
Delivery
- Pipeline templates per path
- Container image baseline
- Progressive rollout
- Automated gates
Platform
- Kubernetes / OpenShift (Linux)
- Windows hosting for rehost path
- Infrastructure as code
Key design decisions
Containers on Linux by default, Windows only by exception. Linux images are smaller, faster to start and better supported by most platforms. The exception list is explicit: anything still on .NET Framework, or with hard Windows dependencies (COM interop, certain printing or Office automation).
Incremental migration for large web applications. For a big ASP.NET application, a reverse proxy routes requests to either the legacy app or a new ASP.NET Core app, one route at a time. Microsoft documents this pattern and provides System.Web adapters to share session and authentication during the transition. This turns one risky cutover into many small, reversible ones.
Keep the database stable during code migration. Changing runtime and schema at the same time doubles the variables in every incident. Entity Framework 6 runs on modern .NET, so moving to EF Core is a separate, optional decision made per application.
Standardize the "paved road". Every migrated service starts from the same template: hosting, health checks, telemetry, configuration binding, authentication and a pipeline. The template is where platform standards are enforced, so teams don't re-decide them.
Alternatives considered
| Option | Why it was not the default |
|---|---|
| Rewrite applications on a new stack | Highest behavior risk, longest time to value, and it discards tested business logic. Kept only for the re-architect path, justified per application. |
| Rehost everything on Windows VMs first, modernize later | Lowers infrastructure risk but leaves runtime and security debt in place. Often becomes the permanent state. Used for the long tail, not as the strategy. |
| Big-bang migration per domain | Simpler to plan on paper. In practice one blocked application stalls a whole domain, and rollback is all-or-nothing. |
| Decompose into microservices while migrating | Mixes two hard problems. Decomposition is a design decision driven by ownership and scaling needs, not a side effect of a runtime upgrade. |
Technical risks and mitigations
| Risk | Mitigation |
|---|---|
| Hidden behavior change (serialization defaults, culture/formatting, date handling, case sensitivity on Linux file systems) | Characterization tests and replay-based parity checks before release; a catalogue of known differences maintained by the enablement team |
| Shared library blocks many apps | Multi-targeting in the foundation wave; dependency graph drives the plan |
| Unsupported technology (WCF server, Web Forms, WF) | Classified early into rehost, incremental or re-architect paths; community ports such as CoreWCF evaluated case by case, not assumed |
| Performance regression under real load | Baseline captured before migration; load test as a gate; progressive rollout with automatic rollback on SLO breach |
| Configuration and secrets drift | Configuration moved to environment-specific providers with a secret store; config differences diffed as part of the parity gate |
| Program fatigue: early waves go well, long tail stalls | Classify the long tail early; retire or rehost deliberately instead of leaving it "for later" |
| Teams bypass the paved road | Make the template the fastest path; review exceptions, don't forbid them |
Validation: what would show this is working
The program should define these measures up front and report them per wave:
- Share of applications in each path, and how often classification was overridden (high override rates mean the rules are wrong).
- Gate pass rate on first attempt, and which gate fails most.
- Production incidents attributed to migration, per wave, compared with the pre-migration baseline.
- Lead time from "wave started" to "all applications released or re-planned".
- Infrastructure and licensing cost before and after, per application, where it can be measured honestly.
Without these, a modernization program can report activity — repositories touched, pull requests merged — while risk quietly stays where it was.
What I would do differently, and open trade-offs
- Invest in characterization tests earlier than feels comfortable. It's tempting to start migrating code in week one. The applications with the weakest test coverage are exactly the ones where parity evidence matters most.
- Treat the inventory as a product. If the scanner is a one-off script, it decays. Kept current, it becomes the source of truth for security, ownership and future upgrades — the next LTS move becomes much cheaper.
- Unresolved trade-off: central team vs domain teams. A central team migrates faster at first; domain teams retain the knowledge. A hybrid — central enablement building playbooks and tooling, domain teams doing the work — is my default, but it needs strong product ownership to avoid stalling.
- Unresolved trade-off: where AI fits. AI assistance can speed up repetitive code changes and summarize unfamiliar code. It shouldn't decide classification or release readiness without evidence — the approach in the evidence-first AI architecture applies here directly.