<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://qurrat2.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://qurrat2.github.io/" rel="alternate" type="text/html" /><updated>2026-04-28T10:06:13+00:00</updated><id>https://qurrat2.github.io/feed.xml</id><title type="html">Qurrat-ul-Ain Rubab</title><entry><title type="html">Migrating from .NET Framework 4.x to .NET 8: What Actually Changes</title><link href="https://qurrat2.github.io/2026/04/27/dotnet-framework-to-net8-migration/" rel="alternate" type="text/html" title="Migrating from .NET Framework 4.x to .NET 8: What Actually Changes" /><published>2026-04-27T00:00:00+00:00</published><updated>2026-04-27T00:00:00+00:00</updated><id>https://qurrat2.github.io/2026/04/27/dotnet-framework-to-net8-migration</id><content type="html" xml:base="https://qurrat2.github.io/2026/04/27/dotnet-framework-to-net8-migration/"><![CDATA[<p>I spent five years building enterprise apps on .NET Framework 4.x (WebForms, MVC 5, IIS, SQL Server, stored procedures, Crystal Reports) and the last year building new ones on ASP.NET Core 8. Here is what I gathered over the past year.</p>

<h2 id="what-actually-changed-at-the-architecture-level">What actually changed at the architecture level</h2>

<p>Stop thinking about .NET 8 as “a new version.” It is a new process model with many of the old names kept for continuity. Once you internalise the shape below, the rest of the migration clicks into place.</p>

<h3 id="1-process-model">1. Process model</h3>

<p><strong>Then:</strong> your app was a DLL loaded by IIS through an HTTP module pipeline. IIS owned the process, authentication, and thread management. You wrote ASPX pages or controllers; IIS handled everything around them.</p>

<p><strong>Now:</strong> your app is a console application that hosts Kestrel, a cross-platform HTTP server, inside a <code class="language-plaintext highlighter-rouge">WebApplication</code> built from <code class="language-plaintext highlighter-rouge">WebApplication.CreateBuilder</code>. You can still sit behind IIS as a reverse proxy, but the hosting contract inverted: you own the process, IIS is optional.
The single biggest consequence: your app is self-contained. You can run <code class="language-plaintext highlighter-rouge">dotnet run</code> on Linux, Windows, macOS, or inside a container. No more “works on my IIS.”</p>

<h3 id="2-configuration">2. Configuration</h3>

<p><strong>Then:</strong> <code class="language-plaintext highlighter-rouge">web.config</code> XML with <code class="language-plaintext highlighter-rouge">&lt;appSettings&gt;</code>, <code class="language-plaintext highlighter-rouge">&lt;connectionStrings&gt;</code>, and transforms for each environment.</p>

<p><strong>Now:</strong> <code class="language-plaintext highlighter-rouge">appsettings.json</code>, layered with <code class="language-plaintext highlighter-rouge">appsettings.Development.json</code>, <code class="language-plaintext highlighter-rouge">appsettings.Production.json</code>, environment variables, and user-secrets. All glued together through <code class="language-plaintext highlighter-rouge">IConfiguration</code>.</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// .NET Framework 4.x</span>
<span class="kt">var</span> <span class="n">conn</span> <span class="p">=</span> <span class="n">ConfigurationManager</span><span class="p">.</span><span class="n">ConnectionStrings</span><span class="p">[</span><span class="s">"Default"</span><span class="p">].</span><span class="n">ConnectionString</span><span class="p">;</span>
<span class="kt">var</span> <span class="n">timeout</span> <span class="p">=</span> <span class="kt">int</span><span class="p">.</span><span class="nf">Parse</span><span class="p">(</span><span class="n">ConfigurationManager</span><span class="p">.</span><span class="n">AppSettings</span><span class="p">[</span><span class="s">"RequestTimeout"</span><span class="p">]);</span>

<span class="c1">// .NET 8</span>
<span class="kt">var</span> <span class="n">conn</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="nf">GetConnectionString</span><span class="p">(</span><span class="s">"Default"</span><span class="p">);</span>
<span class="kt">var</span> <span class="n">timeout</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="n">GetValue</span><span class="p">&lt;</span><span class="kt">int</span><span class="p">&gt;(</span><span class="s">"RequestTimeout"</span><span class="p">);</span>
</code></pre></div></div>

<p>The payoff is bigger than it looks. Typed config, environment-aware overrides, and no XML transforms. Secrets stop living in source control the day you move to user-secrets locally and environment variables in prod.</p>

<h3 id="3-dependency-injection">3. Dependency injection</h3>

<p><strong>Then:</strong> third-party container (Autofac, Unity, Ninject) wired in <code class="language-plaintext highlighter-rouge">Global.asax</code> or <code class="language-plaintext highlighter-rouge">App_Start</code>. Every team picked a different one.</p>

<p><strong>Now:</strong> built in. <code class="language-plaintext highlighter-rouge">builder.Services.AddScoped&lt;IX, Y&gt;()</code>. Scoped, Transient, Singleton lifetimes are first-class.</p>

<p>This single change is responsible for more of .NET Core’s cleanliness than anything else. There is a standard default way to do DI now, even though third-party containers are still supported.</p>

<h3 id="4-pipeline">4. Pipeline</h3>

<p><strong>Then:</strong> HTTP modules and handlers in <code class="language-plaintext highlighter-rouge">web.config</code>, order defined by XML, cross-cutting concerns like auth or logging written as <code class="language-plaintext highlighter-rouge">IHttpModule</code>.</p>

<p><strong>Now:</strong> middleware. Ordered, explicit, and written in C#.</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// .NET Framework 4.x — HttpModule registered in web.config</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">RequestLoggingModule</span> <span class="p">:</span> <span class="n">IHttpModule</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">void</span> <span class="nf">Init</span><span class="p">(</span><span class="n">HttpApplication</span> <span class="n">context</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">context</span><span class="p">.</span><span class="n">BeginRequest</span> <span class="p">+=</span> <span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">e</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span> <span class="cm">/* log */</span> <span class="p">};</span>
    <span class="p">}</span>
    <span class="k">public</span> <span class="k">void</span> <span class="nf">Dispose</span><span class="p">()</span> <span class="p">{</span> <span class="p">}</span>
<span class="p">}</span>

<span class="c1">// .NET 8 — middleware in Program.cs</span>
<span class="n">app</span><span class="p">.</span><span class="nf">Use</span><span class="p">(</span><span class="k">async</span> <span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="n">next</span><span class="p">)</span> <span class="p">=&gt;</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">sw</span> <span class="p">=</span> <span class="n">Stopwatch</span><span class="p">.</span><span class="nf">StartNew</span><span class="p">();</span>
    <span class="k">await</span> <span class="nf">next</span><span class="p">();</span>
    <span class="n">logger</span><span class="p">.</span><span class="nf">LogInformation</span><span class="p">(</span><span class="s">"{Path} took {Ms}ms"</span><span class="p">,</span> <span class="n">context</span><span class="p">.</span><span class="n">Request</span><span class="p">.</span><span class="n">Path</span><span class="p">,</span> <span class="n">sw</span><span class="p">.</span><span class="n">ElapsedMilliseconds</span><span class="p">);</span>
<span class="p">});</span>
</code></pre></div></div>

<p>Order matters; you can see the order at a glance; you can unit-test each middleware. This is a quality-of-life jump.</p>

<h3 id="5-startup">5. Startup</h3>

<p><strong>Then:</strong> <code class="language-plaintext highlighter-rouge">Global.asax.cs</code> with <code class="language-plaintext highlighter-rouge">Application_Start</code>, plus <code class="language-plaintext highlighter-rouge">App_Start/RouteConfig.cs</code>, <code class="language-plaintext highlighter-rouge">App_Start/WebApiConfig.cs</code>, <code class="language-plaintext highlighter-rouge">App_Start/BundleConfig.cs</code>, and a dozen partial friends.</p>

<p><strong>Now:</strong> <code class="language-plaintext highlighter-rouge">Program.cs</code>. All of it. One file for small apps, refactored into extension methods for large ones.</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">builder</span> <span class="p">=</span> <span class="n">WebApplication</span><span class="p">.</span><span class="nf">CreateBuilder</span><span class="p">(</span><span class="n">args</span><span class="p">);</span>

<span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">AddControllers</span><span class="p">();</span>
<span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="n">AddDbContext</span><span class="p">&lt;</span><span class="n">AppDbContext</span><span class="p">&gt;(</span><span class="n">opt</span> <span class="p">=&gt;</span>
    <span class="n">opt</span><span class="p">.</span><span class="nf">UseSqlServer</span><span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="nf">GetConnectionString</span><span class="p">(</span><span class="s">"Default"</span><span class="p">)));</span>
<span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">AddAuthentication</span><span class="p">(</span><span class="n">JwtBearerDefaults</span><span class="p">.</span><span class="n">AuthenticationScheme</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">AddJwtBearer</span><span class="p">(</span><span class="cm">/* options */</span><span class="p">);</span>

<span class="kt">var</span> <span class="n">app</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">Build</span><span class="p">();</span>

<span class="n">app</span><span class="p">.</span><span class="nf">UseAuthentication</span><span class="p">();</span>
<span class="n">app</span><span class="p">.</span><span class="nf">UseAuthorization</span><span class="p">();</span>
<span class="n">app</span><span class="p">.</span><span class="nf">MapControllers</span><span class="p">();</span>

<span class="n">app</span><span class="p">.</span><span class="nf">Run</span><span class="p">();</span>
</code></pre></div></div>

<p>Every concern is in one readable file. A senior engineer can audit startup in ninety seconds.</p>

<h2 id="what-ports-well">What ports well</h2>

<p>Not everything is a rewrite. These are the parts that survive the trip.</p>

<h3 id="pure-c-business-logic">Pure C# business logic</h3>

<p>If your domain classes, services, and rules are plain C# with no dependency on <code class="language-plaintext highlighter-rouge">System.Web</code> or <code class="language-plaintext highlighter-rouge">System.Configuration</code>, they move across untouched. This is the biggest argument for writing your business logic as pure C# from day one: future-you gets a free upgrade path.</p>

<h3 id="adonet-with-stored-procedures">ADO.NET with stored procedures</h3>

<p><code class="language-plaintext highlighter-rouge">SqlConnection</code>, <code class="language-plaintext highlighter-rouge">SqlCommand</code>, <code class="language-plaintext highlighter-rouge">SqlParameter</code>, <code class="language-plaintext highlighter-rouge">DataReader</code>. The programming model is unchanged. The supported package is <code class="language-plaintext highlighter-rouge">Microsoft.Data.SqlClient</code>, which targets <code class="language-plaintext highlighter-rouge">.NET Framework 4.6.2</code> upward and every modern .NET; same code, same APIs, both sides. If your legacy app already uses it, the migration step here is zero. If you’re still on the BCL’s <code class="language-plaintext highlighter-rouge">System.Data.SqlClient</code>, swap to <code class="language-plaintext highlighter-rouge">Microsoft.Data.SqlClient</code> while you’re at it: new SQL Server features (Always Encrypted enclaves, Azure AD auth, newer TDS protocol behaviour) only land there. Your T-SQL is untouched.</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Runs identically on both .NET Framework 4.x and .NET 8</span>
<span class="k">using</span> <span class="nn">var</span> <span class="n">conn</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">SqlConnection</span><span class="p">(</span><span class="n">connectionString</span><span class="p">);</span>
<span class="k">using</span> <span class="nn">var</span> <span class="n">cmd</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">SqlCommand</span><span class="p">(</span><span class="s">"sp_GetAssetsByDepartment"</span><span class="p">,</span> <span class="n">conn</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">CommandType</span> <span class="p">=</span> <span class="n">CommandType</span><span class="p">.</span><span class="n">StoredProcedure</span>
<span class="p">};</span>
<span class="n">cmd</span><span class="p">.</span><span class="n">Parameters</span><span class="p">.</span><span class="nf">AddWithValue</span><span class="p">(</span><span class="s">"@DepartmentId"</span><span class="p">,</span> <span class="n">id</span><span class="p">);</span>
<span class="k">await</span> <span class="n">conn</span><span class="p">.</span><span class="nf">OpenAsync</span><span class="p">();</span>
<span class="k">using</span> <span class="nn">var</span> <span class="n">reader</span> <span class="p">=</span> <span class="k">await</span> <span class="n">cmd</span><span class="p">.</span><span class="nf">ExecuteReaderAsync</span><span class="p">();</span>
<span class="c1">// ...</span>
</code></pre></div></div>

<p>This matters for anyone coming from a heavy-SQL enterprise shop. Your SQL muscle does not atrophy; it transfers.</p>

<h3 id="json-serialisation-mostly">JSON serialisation (mostly)</h3>

<p><code class="language-plaintext highlighter-rouge">Newtonsoft.Json</code> still works on .NET 8 and will for a long time. <code class="language-plaintext highlighter-rouge">System.Text.Json</code> is the new default, and the migration is usually a find-and-replace plus a handful of custom converters. Plan for it, but do not fear it.</p>

<h3 id="entity-framework-with-asterisks">Entity Framework (with asterisks)</h3>

<p><strong>EF6 (6.4+)</strong> can run on .NET 8, but it’s not cross-platform-first and is not recommended for new development. EF Core is the long-term direction. The APIs look similar but the model under the hood is different.</p>

<h2 id="what-ports-with-work">What ports with work</h2>

<h3 id="aspnet-mvc-5--aspnet-core-mvc">ASP.NET MVC 5 → ASP.NET Core MVC</h3>

<p>Concepts are the same: controllers, actions, views, model binding, filters. The APIs moved. <code class="language-plaintext highlighter-rouge">System.Web.Mvc.Controller</code> is now <code class="language-plaintext highlighter-rouge">Microsoft.AspNetCore.Mvc.Controller</code>. <code class="language-plaintext highlighter-rouge">HttpGet</code>/<code class="language-plaintext highlighter-rouge">HttpPost</code> attributes live in a different namespace. Action results, filters, and <code class="language-plaintext highlighter-rouge">HttpContext</code> all have cleaner equivalents.</p>

<p>A reasonable rule of thumb is one week per developer per 20-30 controllers, assuming the business logic is already factored out.</p>

<h3 id="web-api-2--aspnet-core-web-api">Web API 2 → ASP.NET Core Web API</h3>

<p>Easier than MVC 5. The programming model is very similar, and ASP.NET Core unified MVC and Web API into one stack, so you lose a layer of confusion.</p>

<h3 id="forms-auth--cookies--jwt-or-cookies-on-core">Forms auth / cookies → JWT or cookies on Core</h3>

<p>ASP.NET Core still supports cookie authentication. If your enterprise app used forms auth on an internal intranet, you can keep cookies. If you are exposing APIs to JavaScript or mobile clients, JWT is the clean answer.</p>

<p>In HOP, the portfolio API I link to at the end, I went with <code class="language-plaintext highlighter-rouge">AddAuthentication().AddJwtBearer()</code> because the clients are HTTP API consumers. BCrypt for password hashing on the user entity, JWT issued by an <code class="language-plaintext highlighter-rouge">IJwtTokenService</code> in the Infrastructure layer. A couple hundred lines, all inspectable in one folder.</p>

<h2 id="what-does-not-port">What does not port</h2>

<p>Be honest with yourself about these. Mis-scoping a migration by pretending things will port is a classic project failure mode.</p>

<h3 id="webforms">WebForms</h3>

<p>There is no forward path. WebForms is not in .NET Core or .NET 8 and never will be. <code class="language-plaintext highlighter-rouge">Page_Load</code>, <code class="language-plaintext highlighter-rouge">ViewState</code>, server controls, code-behind: gone.</p>

<p>Your options for a WebForms app are:</p>
<ol>
  <li><strong>Stay on .NET Framework 4.8</strong> (still supported as part of the Windows OS lifecycle and receiving security updates).</li>
  <li><strong>Rewrite the UI</strong> in ASP.NET Core MVC or Blazor. Keep the business logic and database.</li>
  <li><strong>Blazor Server</strong> is the closest “feel” to WebForms (server-rendered components with stateful interactions) without the page lifecycle nightmare.</li>
</ol>

<h3 id="wcf-server-side">WCF server-side</h3>

<p>WCF service hosts do not run on .NET 8. Clients have a compatibility package. For server-side, your choices are:</p>
<ol>
  <li><strong>CoreWCF</strong> (community-driven, .NET Foundation project) if you need to keep existing contracts and bindings during the move.</li>
  <li><strong>gRPC</strong> if the clients are internal and you control both ends.</li>
  <li><strong>ASP.NET Core Web API</strong> (REST or JSON-RPC) for everything else.</li>
</ol>

<h3 id="windows-specific-apis">Windows-specific APIs</h3>

<p><code class="language-plaintext highlighter-rouge">System.Drawing</code>, certain <code class="language-plaintext highlighter-rouge">System.DirectoryServices</code> paths, COM interop, Crystal Reports: these either do not exist on non-Windows .NET 8 or require platform-specific NuGet packages. Crystal Reports specifically does not have a clean .NET 8 path; you either keep that module on .NET Framework 4.8 or replace the reporting stack.</p>

<h2 id="a-decision-framework">A decision framework</h2>

<p>When a stakeholder asks “should we migrate?” walk them through this ladder. Pick the first answer that matches.</p>

<ol>
  <li><strong>Does it use WebForms or self-host WCF heavily?</strong> → Rewrite is the honest answer. Typical scope: months, not weeks.</li>
  <li><strong>Is it MVC 5 / Web API 2 / EF6 / ADO.NET?</strong> → Incremental refactor. Move to ASP.NET Core MVC, upgrade to EF Core or keep EF6 behind the shim. Typical scope: 4 to 8 weeks for a medium app.</li>
  <li><strong>Is it a business-logic library plus a thin API veneer?</strong> → Port the library as-is, rewrite the API in ASP.NET Core. Typical scope: 1 to 3 weeks.</li>
  <li><strong>Is the app stable, in maintenance, and not blocking anyone?</strong> → Stay on .NET Framework 4.8. It is still supported. Migrating for the sake of migrating burns budget that could fund a new product.</li>
</ol>

<h2 id="what-the-other-side-looks-like">What the other side looks like</h2>

<p>In HOP I deliberately picked the layout I wish enterprise .NET Framework apps had used:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>src/
├── HOP.Api              ASP.NET Core host, controllers, Swagger, JWT
├── HOP.Application      Interfaces and service contracts
├── HOP.Domain           Pure C# entities, no external deps
├── HOP.Infrastructure   EF Core, persistence, auth services
└── HOP.Contracts        Request/response DTOs
</code></pre></div></div>

<p>Dependency direction points inward. The domain knows nothing about HTTP or EF Core. Five projects sounds heavy until you realise every modern .NET team converges on a version of this. The enterprise .NET Framework projects I worked on had the same concerns smeared across three folders inside one assembly; the layering was implicit and inconsistent between modules. Making it explicit is the biggest delta in readability.</p>

<h2 id="migration-checklist-a-saner-starting-point">Migration checklist (a saner starting point)</h2>

<ol>
  <li>Inventory your dependencies. Run <code class="language-plaintext highlighter-rouge">upgrade-assistant</code> (Microsoft’s tool) in analysis mode. It tells you which NuGet packages have .NET 8-compatible versions and which do not.</li>
  <li>Extract pure business logic into its own project first, on .NET Framework. This project becomes your bridge: it compiles against both frameworks via multi-targeting.</li>
  <li>Write the new .NET 8 host alongside the old one. Run them both against the same database in a staging environment.</li>
  <li>Migrate one endpoint at a time. Keep a reverse-proxy routing rule in front of both so the switch is per-route, not per-app.</li>
  <li>Kill the old host only after every route has a green-light period in production.</li>
</ol>

<p>This is the Strangler Fig pattern. It is boring, slow, and it does not fail spectacularly the way a big-bang rewrite does.</p>

<h2 id="closing">Closing</h2>

<p>Migration is not a technology decision. It is a business decision about which capabilities you need next and how much legacy friction you are willing to live with. The technology side is a solved problem.</p>

<p>If your app is on life support and nobody is asking for new features, staying on .NET Framework 4.8 is a perfectly professional answer. If your team is shipping new features and the framework is getting in the way, .NET 8 pays back the migration cost in months, not years.</p>

<p>Most shops are somewhere in the middle. The right answer there is almost always “incremental refactor, behind the Strangler Fig.” Boring and effective.</p>

<hr />

<p><em>I build .NET backends for enterprise and SaaS clients. If you are scoping a migration or need a second opinion on an architecture, <a href="https://linkedin.com/in/qurrat2">say hi on LinkedIn</a> or have a look at <a href="https://github.com/qurrat2/HOP-Healthcare-Ops-Platform">HOP</a>, the portfolio project this post draws on.</em></p>]]></content><author><name></name></author><category term="dotnet" /><category term="dotnet" /><category term="csharp" /><category term="aspnetcore" /><category term="migration" /><category term="webapi" /><summary type="html"><![CDATA[A practical field guide from someone who spent years on both sides of the divide. What ports well, what needs work, and what you rewrite from scratch.]]></summary></entry></feed>