How We Modernized a Legacy .NET Monolith Without a Full Rewrite
Note: This case study is based on a real enterprise engagement. Client identity, industry details, and selected non-material characteristics have been changed. The migration approach, categories of findings, sequencing decisions, and outcome ranges reflect the actual engagement. Metrics have been rounded to protect confidentiality.
A financial services SaaS platform had been running on .NET Framework 4.6 for eleven years. The internal team knew it needed to move to modern .NET. What they did not know was how to do it without stopping the product, and what they would find when they actually looked at what the system was doing.
At Blackthorn Vision, a Microsoft Solutions Partner helping enterprise teams modernize legacy .NET systems and build complex software products with AI and machine learning development, this engagement reflects the assessment-first modernization model Blackthorn Vision uses for complex legacy .NET platforms. The pattern below is consistent with what these systems typically require. What follows is how this one went.
What the System Looked Like Before We Touched It
The platform was a classic .NET Framework monolith: a single ASP.NET MVC application deployed to Windows Server, SQL Server as the database, several Windows Services handling background processing, and a deployment process that involved RDP sessions and manual steps that one senior developer had memorized but never documented.
The system had genuine business value. Eleven years of edge case handling, integration logic, and domain knowledge were encoded in it. The team's instinct was to rewrite it. Our first conversation was about why that instinct, in this case, would produce a worse outcome than the one they were trying to avoid.
Microsoft's incremental migration guidance recommends incremental extraction for exactly this type of system: a production platform that cannot go offline, with complexity that no initial estimate fully captures. The strangler fig pattern keeps the legacy system running throughout. Migration becomes a series of reversible steps rather than a single cutover.
Phase 1: The Assessment
Before we wrote a single line of migration code, we spent several weeks mapping what the system actually did. This is not overhead. It is the work that determines whether the migration plan is realistic.
The assessment covered four areas.
Running services and jobs
We pulled the complete list of Windows Services and SQL Server Agent jobs from every server in the environment and cross-referenced each against the documentation.
Four SQL Server Agent jobs had no documentation, no owner, and no alerting. One of them was running a nightly reconciliation process that calculated values the application read the following morning.
It had been running for several years. Nobody on the current team knew it existed. The migration plan, as originally sketched, would have broken it silently.
Integration surface
We mapped every outbound and inbound connection: API calls, SFTP transfers, direct database connections from external reporting tools, and a file-based integration with a third-party billing system that ran once a day at 2am.
The billing integration had no error handling and no monitoring. It had failed multiple times in the past year. Each failure was discovered by a customer, not by the team.
Business logic location
The domain logic was in four places it should not have been:
- Several stored procedures containing conditional business rules
- Two SQL Agent jobs writing intermediate calculation results to tables the application then read
- A Windows Service that had been extended with customer-specific pricing logic
- A number of
web.configvalues that controlled business behavior rather than infrastructure configuration
What could be changed safely
We categorized every module by test coverage, coupling, and business criticality.
About 30% of the codebase had reasonable test coverage and clear boundaries. Another 40% had no test coverage but low coupling.
The remaining 30% — the pricing engine, the reconciliation workflow, and the billing integration — had no test coverage and high coupling to the rest of the system. That 30% was treated as read-only until coverage was established.
The assessment produced a dependency map that the migration plan was built around rather than a migration plan that assumed the dependency map. That distinction determined whether the migration was on track at month six or stalled.
Phase 2: The YARP Routing Layer
Once the assessment was complete, we deployed YARP (Yet Another Reverse Proxy) as the routing layer between the legacy system and the new ASP.NET Core services.
Initially, YARP forwarded 100% of traffic to the legacy .NET Framework application. As each component was migrated and validated, a routing rule directed that component's traffic to the new service.
// appsettings.json, initial state: all traffic to legacy
{
"ReverseProxy": {
"Routes": {
"legacy-catch-all": {
"ClusterId": "legacy",
"Match": {
"Path": "{**catch-all}"
}
}
},
"Clusters": {
"legacy": {
"Destinations": {
"app": {
"Address": "https://legacy-app.internal/"
}
}
}
}
}
}
After the reporting module was validated in parallel-run:
// Added after parallel-run validation,
// reporting traffic now goes to new service
"reporting-route": {
"ClusterId": "new-service",
"Match": {
"Path": "/api/reports/{**remainder}"
}
}
From the perspective of users and external systems, nothing changed. All requests arrived at the same endpoint. Rollback for any component was a single routing rule removal.
The OWASP Application Security Verification Standard was used as the security assessment baseline for the new ASP.NET Core services, covering authentication, session management, and API access control requirements that the legacy system partially addressed and the new services needed to handle correctly from the start.
Phase 3: Parallel-Run Validation
For each migrated component, we ran both implementations simultaneously before routing production traffic to the new service.
The legacy response was returned to the caller. The new service response was compared in the background. Discrepancies triggered alerts.
Note: The following is simplified pseudocode illustrating the shadow comparison concept. Production shadow traffic requires request body buffering, side-effect controls, response normalization, timeout isolation, cancellation token support, and traffic sampling. Sending an
HttpRequesttwice without cloning is unsafe for requests with a body.
// Simplified pseudocode, not production-ready as shown
// Production implementation requires: request buffering, body cloning,
// side-effect controls, sampling, and response normalization
public class ShadowComparisonMiddleware(
IHttpClientFactory factory,
ILogger logger)
{
public async Task InvokeAsync(
HttpContext context,
RequestDelegate next)
{
if (!ShouldShadow(context.Request))
{
await next(context);
return;
}
// In production: clone the request body before reading it
var legacyResult = await ForwardToLegacy(context.Request);
var shadowResult = await ForwardToShadow(context.Request);
if (!ResultsMatch(legacyResult, shadowResult))
{
logger.LogWarning(
"Shadow discrepancy on {Path}",
context.Request.Path);
}
// Always return the legacy response to the caller
await WriteResponse(context, legacyResult);
}
}
This approach required Application Insights connected to both systems with correlation IDs that allowed a single request to be traced across the legacy and new implementations.
Without this observability at the seams, discrepancies were visible in user reports rather than in telemetry.
In this engagement, parallel-run validation allowed the team to reduce technical debt without replacing stable production behavior blindly.
The Database Problem
The hardest part of the migration was the database.
The legacy system and the new ASP.NET Core services could not write to the same tables simultaneously without coordination. Two systems writing the same rows without a coordination mechanism produces data corruption, not just downtime.
The approach we used:
Read-only shadow period
During parallel-run validation, the new service read data but did not write. Writes remained on the legacy system.
Change Data Capture for synchronization
Once validation was complete and production traffic was being routed to the new service for a component, SQL Server CDC captured changes from the legacy tables.
A migration worker consumed those changes and applied them to the new data model during the transition period, with checkpointing and reconciliation checks to confirm consistency between the two stores.
CDC introduced a replication lag, typically under 500ms under normal load but spiking during batch operations.
This required retry and polling logic on the read side for real-time workflows where a user could write to the legacy system and immediately read from the new service.
Workflows with strict read-after-write consistency requirements were kept on the legacy write path until the full migration was complete.
We also discovered that an unindexed staging table used by a nightly batch job caused CDC to fall significantly behind during the batch window in month four, requiring a schema fix and a replication catchup period before the next migration phase could proceed.
This allowed the new service to build its own data model without requiring a hard cutover of the write path.
Schema freeze
No schema changes until the application layer was consistent with the current schema state.
The billing integration, which read directly from three tables in the legacy schema, was a particular risk here. We mapped it during assessment and froze those tables until the integration had been updated to use the new API surface.
McKinsey estimates that technical debt can equal 20 to 40 percent of the value of an enterprise technology estate.
In this engagement, database coupling was one of the highest-risk forms of debt identified during assessment. The CDC-based transition reduced the risk of introducing data inconsistencies during the migration.
What the Migration Produced
After approximately fourteen months, the migration was complete.
The specific outcomes:
| Metric | Before | After |
|---|---|---|
| Runtime | .NET Framework 4.6 | .NET 8 |
| Hosting | Windows Server VMs | Azure App Service (Linux) |
| Deployment | Manual RDP + steps | CI/CD pipeline, zero-touch |
| Test coverage (critical paths) | ~12% | ~74% |
| Deployment frequency | Monthly | Weekly |
| Undocumented SQL jobs | 4 (discovered in assessment) | 0 |
| External integrations with monitoring | 2 of 7 | 7 of 7 |
| Windows Server licensing | Full fleet | Eliminated |
Metrics note: Metrics reflect the first six months after migration completion. Test coverage refers to line coverage across identified business-critical modules. Deployment frequency refers to successful production releases.
The Windows Server licensing reduction was not in the original business case.
It became visible when the team realized that moving to modern .NET removed the application tier's dependency on Windows Server.
The new services run on Linux-based Azure App Service plans. Database and integration infrastructure was evaluated separately.
What We Had to Revise Mid-Migration
One assumption from the assessment did not hold under production conditions.
The reporting module was selected as the first migration target because it had the clearest API boundaries and reasonable test coverage.
During parallel-run validation, response times for one report type consistently differed between the legacy and new implementations by more than the acceptable threshold.
The investigation revealed that the legacy implementation was reading from a SQL Agent job output table that was refreshed nightly. The new implementation was computing the same values on demand.
The behavior was functionally correct but the response time difference was enough to fail the validation gate.
The resolution was to replicate the pre-computation pattern in the new service rather than change the validation threshold.
This added two weeks to the reporting module migration and changed the sequencing of two subsequent components that had assumed reporting would be complete first.
The migration timeline was updated and communicated to the client before the delay materialized as a missed milestone.
This is representative of what assessment-based sequencing handles: not preventing surprises, but ensuring that surprises are discovered during a controlled validation phase rather than after production cutover.
Three Things That Would Have Gone Wrong Without the Assessment
Looking back, three specific findings from the assessment phase prevented production incidents during migration.
The undocumented SQL Agent jobs
The migration plan would have moved the application layer to Azure without migrating the reconciliation jobs.
They would have continued running on the legacy Windows Server, reading from a database that was no longer the production data source.
The first sign would have been incorrect financial data the morning after cutover.
The billing integration
The file-based integration with the third-party billing system read from legacy schema tables that were changed during the migration.
Without the assessment mapping it, those tables would have been renamed as part of the schema cleanup.
The billing integration would have failed at 2am and been discovered by a customer.
The pricing logic in the Windows Service
Customer-specific pricing rules had been added to a Windows Service over five years.
The service had no unit tests. Without identifying this during assessment, the migration would have missed it.
A customer would have received incorrect pricing after cutover.
Each of these was a recoverable incident. None of them would have been visible in staging. All three were preventable through assessment.
What This Has to Do With AI
The architecture produced by this migration shares the same prerequisites that reliable Azure OpenAI integration requires, and the reason is specific.
The legacy .NET Framework application used synchronous blocking I/O throughout.
Every controller action blocked a thread for the duration of the request. Under normal load this was manageable.
Under LLM workloads, where a single Azure OpenAI call holds a connection open for 5 to 30 seconds, a synchronous IIS thread pool would exhaust within seconds under moderate concurrent usage.
The async/await patterns introduced during migration to modern .NET mean the application can now hold thousands of parallel streaming connections without thread starvation.
That is not a coincidental benefit of modernization. It is a direct prerequisite for production AI feature reliability.
These are not coincidentally similar requirements. They are the same requirements.
The client later began evaluating a copilot feature based on Azure OpenAI.
The modernization did not implement that feature, but it removed several architectural blockers:
- Synchronous request paths that cannot handle LLM latency
- Tightly coupled business logic that would have made Semantic Kernel orchestration fragile, difficult to test, and hard to govern
- Insufficient observability to diagnose AI feature behavior in production
The modernization created the preconditions. The AI work is a separate program.
How We Approach This Work
Blackthorn Vision's .NET modernization and application modernization practice is built around assessment-first sequencing, strangler fig extraction using YARP, and Azure architecture that treats the target state as a platform for future capability, not just a modernized version of the current system.
Deloitte research identifies architectural ownership and governance as recurring factors in successful long-term technology programs.
For a .NET monolith modernization that runs approximately fourteen months, those factors determine whether the program maintains leadership confidence throughout or loses it when the first unexpected finding appears.
This engagement reflects Blackthorn Vision's core modernization model:
- Assess the real production system before proposing a migration sequence
- Migrate incrementally using the strangler fig pattern
- Preserve product delivery throughout
- Leave the client with an architecture the internal team can operate and extend
The modernized architecture also reduced the effort required for subsequent runtime upgrades.
With .NET 10 LTS released in November 2025 and .NET 8 reaching end of support in November 2026, the migration sequencing already accounts for an upgrade to .NET 10 as the next natural step.
If you are evaluating options for a legacy .NET modernization, the questions that reveal whether a partner has done this before are the same ones this engagement was built around: what does the assessment cover, how is the migration sequenced around the real dependency graph, and what happens to the architecture when migration is complete.

Top comments (0)