DEV Community

itysu tur
itysu tur

Posted on

5 Surprises Migrating .NET Framework to .NET 9 with Claude Opus

5 Surprises Migrating .NET Framework to .NET 9 with Claude Opus

My workflow before this little experiment was pretty standard for a .NET Framework to .NET 9 migration: open Visual Studio 2026, create a new SDK-style project, copy over files, then hit a wall of red squiggles. I'd spend hours manually updating csproj files, hunting down modern equivalents for System.Web or ConfigurationManager, and constantly flipping between documentation, Stack Overflow, and my IDE. It was a grind of context switching and often, just plain guessing.

Now, my initial pass looks wildly different. I'm still in Visual Studio 2026 or Rider 2026, but the first step involves feeding the entire legacy project structure and relevant code files into Claude Opus 4.7. I'm prompting it to generate the new csproj, suggest initial code changes, and even outline a migration plan. The red squiggles still appear, but they're a starting point for refinement, not a blank canvas of despair. It’s like having a hyper-competent, slightly over-confident junior dev do the grunt work, and I get to be the architect, reviewing and correcting.

Honestly, I was skeptical. Our team has a decent-sized .NET Framework 4.8 web service – nothing ancient, but definitely showing its age with some custom configuration and a reliance on System.Web for various utility functions. The goal was to bring it to .NET 9 (with C# 13, naturally) to leverage modern performance and features. I figured this was a prime candidate to see if ai migration dotnet could actually deliver, or if it was just hype.

What Claude Opus 4.7 Got Right (and Wrong) About the Initial Lift

My first real test was feeding Claude Opus 4.7 an entire solution structure, including a csproj file and a few core C# files, asking for a net framework to net 9 conversion. What surprised me was how well it handled the basic csproj upgrade. It correctly identified target frameworks, package references, and even most of the ItemGroup changes. Honestly, I expected this to fail completely on the first pass, but the generated SDK-style project file was a solid foundation.

<!-- Initial prompt to Claude Opus 4.7 -->
<prompt>
I have a .NET Framework 4.8 C# project. Please convert this csproj to an SDK-style .NET 9 csproj. 
Also, review the provided C# code files and suggest initial changes for .NET 9 compatibility, focusing on common System.Web and ConfigurationManager replacements.

<csproj_content>
  <Project ToolsVersion="15.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
    <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
    <PropertyGroup>
      <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
      <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
      <ProjectGuid>{ABCDEF-1234-5678-90AB-CDEF12345678}</ProjectGuid>
      <OutputType>Library</OutputType>
      <AppDesignerFolder>Properties</AppDesignerFolder>
      <RootNamespace>MyLegacyService</RootNamespace>
      <AssemblyName>MyLegacyService</AssemblyName>
      <TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
      <FileAlignment>512</FileAlignment>
    </PropertyGroup>
    <ItemGroup>
      <Reference Include="System" />
      <Reference Include="System.Configuration" />
      <Reference Include="System.Web" />
      <!-- ... other references ... -->
    </ItemGroup>
    <ItemGroup>
      <Compile Include="MyService.cs" />
      <!-- ... other compiles ... -->
    </ItemGroup>
    <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
  </Project>
</csproj_content>

<file_content name="MyService.cs">
  using System.Configuration;
  using System.Web;
  using System.Collections.Generic;

  namespace MyLegacyService
  {
      public class MyService
      {
          public string GetConfigValue()
          {
              return ConfigurationManager.AppSettings["MyKey"];
          }
          public Dictionary<string, string> GetRequestHeaders()
          {
              var headers = new Dictionary<string, string>();
              if (HttpContext.Current != null)
              {
                  foreach (string key in HttpContext.Current.Request.Headers.AllKeys)
                  {
                      headers.Add(key, HttpContext.Current.Request.Headers[key]);
                  }
              }
              return headers;
          }
      }
  }
</file_content>
</prompt>

<!-- Claude's (edited for brevity) initial response for MyService.cs -->
Enter fullscreen mode Exit fullscreen mode


csharp
// Claude's suggested MyService.cs changes
// Note: This is a starting point, manual integration with IConfiguration and DI is needed.
using Microsoft.Extensions.Configuration; // Added
using Microsoft.AspNetCore.Http; // For HttpContext replacement (assuming ASP.NET Core)
using System.Collections.Generic;

namespace MyLegacyService // Namespace retained, assuming project structure
{
public class MyService
{
// Placeholder for IConfiguration injection
private readonly IConfiguration _configuration;
private readonly IHttpContextAccessor _httpContextAccessor; // For HttpContext access

    public MyService(IConfiguration configuration, IHttpContextAccessor httpContextAccessor) // Added constructor
    {
        _configuration = configuration;
        _httpContextAccessor = httpContextAccessor;
    }

    public string GetConfigValue()
    {
        // Replaced ConfigurationManager.AppSettings
        return _configuration["MyKey"]; 
    }

    public Dictionary<string, string> GetRequestHeaders()
    {
        var headers = new Dictionary<string, string>();
        if (_httpContextAccessor.HttpContext != null)
        {
            foreach (var header in _httpContextAccessor.HttpContext.Request.Headers)
            {
                headers.Add(header.Key, header.Value.ToString());
            }
        }
        return headers;
    }
}
Enter fullscreen mode Exit fullscreen mode

}

Where it stumbled, predictably, was on the deeper integration patterns. It correctly identified `ConfigurationManager.AppSettings` needed to go but replaced it with a direct `IConfiguration` access without considering dependency injection or how that `IConfiguration` instance would actually *get* there in a new ASP.NET Core project. Similarly, for `HttpContext.Current`, it pointed to `IHttpContextAccessor`, which is the right path, but again, didn't provide the full DI setup. It was a strong hint, but not a drop-in solution. The **context of *how* an ASP.NET Core app is structured** was missing from its initial understanding, which is fair given I only fed it code, not architecture.

## The Iterative Dance: Prompting for Nuances and Dependencies

This is where the real `claude migration` magic, or rather, the iterative process, began. Instead of asking for a full conversion, I started focusing on specific problem areas. For instance, a common pattern in our legacy code was accessing `ConfigurationManager` in static helper classes. I couldn't just inject `IConfiguration` into a static class. Took me an embarrassing amount of time to figure out the right prompt to get Claude to suggest a factory pattern or a service locator *only for that specific legacy scenario* that I could then refactor later.

Enter fullscreen mode Exit fullscreen mode


prompt
My .NET 9 project uses Dependency Injection. I have a static utility class 'LegacyConfigHelper' that currently accesses ConfigurationManager.AppSettings. How can I refactor this static class to use IConfiguration from DI, or provide a suitable pattern for accessing configuration in a static context without violating DI principles in a modern ASP.NET Core 9 application? Provide a code example.

// Claude's suggested pattern for static configuration access (simplified)
using Microsoft.Extensions.Configuration;
using System;

namespace MyLegacyService
{
    public static class LegacyConfigHelper
    {
        private static IConfiguration _configuration;

        // This method should be called once at application startup (e.g., in Program.cs)
        public static void Initialize(IConfiguration configuration)
        {
            _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
        }

        public static string GetLegacySetting(string key)
        {
            if (_configuration == null)
            {
                throw new InvalidOperationException("LegacyConfigHelper has not been initialized. Call Initialize() at startup.");
            }
            return _configuration[key];
        }
    }
}

// In Program.cs (or similar startup file for an ASP.NET Core app):
// var builder = WebApplication.CreateBuilder(args);
// // ... other services ...
// var app = builder.Build();
// LegacyConfigHelper.Initialize(app.Configuration); // Call this after build, before run
// app.Run();
Enter fullscreen mode Exit fullscreen mode

This pattern, while not ideal long-term, gave me a bridge. It allowed me to get the application compiling and running with dotnet build quickly, deferring a full refactor of those static helpers. I found that providing a small, focused code block and a very specific problem description yielded far better results than dumping large files. Your mileage may vary, but for me, this granular approach to ai migration dotnet was key. I also started using Copilot Edits within Visual Studio 2026 for smaller, in-file refactors after Claude had done the heavy lifting.

The Setup I Settled On for Debugging and Refinement

My final setup involved Visual Studio 2026 as the primary IDE for debugging and running tests, especially with the new C# 13 features. For larger refactors or when I needed to give Claude more context than a single file, I'd use Cursor 0.42+. Cursor's ability to "chat with your codebase" by indexing multiple files and providing a larger context window (leveraging MCP, Model Context Protocol, for Claude Opus 4.7) was invaluable. I could ask it to find all usages of a particular System.Web method and suggest replacements across the entire project, which was far more efficient than manual searching.

When I had a chunk of code that Claude had transformed, my process was to paste it into Visual Studio, let Copilot Edits suggest minor syntax fixes, and then immediately run the existing unit tests (which I was also migrating and updating with Claude's help).

# Example of running tests to validate a migration step
# Assuming you've created a test project for your migrated service
cd MyMigratedService.Tests
dotnet test --filter "Category=MigrationTests & Priority=High"
Enter fullscreen mode Exit fullscreen mode

This rapid feedback loop was critical. I screwed this up by initially trusting Claude's output too much without immediate validation. Last Tuesday, I was debugging a seemingly correct IConfiguration access that was always returning null, only to realize Claude had suggested a GetSection call that didn't match my appsettings structure. A quick dotnet test would have caught that immediately if I'd written a test for it (which I then did). I'm still figuring out the best way to leverage Copilot for Workspaces alongside Claude's larger refactors, but the combination of broad strokes from Claude and fine-grained assistance from Copilot is proving powerful.

The service is now mostly running on .NET 9, albeit with a few LegacyConfigHelper instances I'm still planning to eliminate. The biggest remaining challenge is a complex custom serialization library that relies heavily on reflection patterns that changed between .NET Framework and .NET 9.


If you've tried using AI models like Claude or Copilot for Workspaces to migrate a substantial .NET Framework project to .NET 9, I'd love to hear about your biggest gotchas or unexpected wins.

Top comments (0)