How to migrate from Unity Container to MS.DI seamlessly

Over the course of 7 months, I successfully migrated a core service from Unity Container to Microsoft Dependency Injection (MS.DI).

This was a massive undertaking. The project had a giant Unity configuration file containing nearly 3,000 lines of XML, with more than 600 services registered in it. Because dependency injection is literally the backbone of the application, I had to ensure the migration was completely safe and wouldn’t break anything in production.

My core philosophy for this migration was simple: Adopt MS.DI’s IServiceCollection as the single source of truth for authoring registrations, and use an adapter layer to dynamically translate them into Unity while coercing the legacy container to mimic MS.DI’s resolution semantics. This decoupled our registration syntax from the underlying DI engine, preparing the app for a truly seamless container swap.

Here are the practices, challenges, and solutions I discovered along the way.

1. Bridging the Disparities Incrementally

The most obvious difference is the service registration method. We were using Unity’s XML-based service registration, while MS.DI only supports code-based registration. It is impossible to convert a 3,000-line XML file to code-based registration in one go.

Instead of a big bang, I came up with an incremental hybrid approach.

var serviceCollection = new ServiceCollection();
// 1. Register migrated services in MS.DI
serviceCollection.AddScoped<IService, Service>();
var unityContainer = new UnityContainer();
// 2. Load the MS.DI configurations into Unity
unityContainer.LoadConfiguration(serviceCollection);
// 3. Load the remaining legacy XML configurations
unityContainer.LoadXmlConfiguration("unity.config");

This allowed me to use the existing Unity container at runtime while gradually moving services into the ServiceCollection chunk by chunk, working on smaller pieces in parallel.

Handling Named vs. Keyed Services

Unity uses Named Services, while MS.DI recently introduced Keyed Services. They sound similar, but their behaviors differ drastically when resolving multiple services of the same type (IEnumerable<T>).

In MS.DI, registering multiple services of the same type is straightforward, and resolving them returns all implementations:

MS.DI
serviceCollection.AddTransient<IService, Service1>();
serviceCollection.AddTransient<IService, Service2>();
var services = serviceProvider.GetServices<IService>(); // Returns [Service1, Service2]

However, if we load these into Unity, Unity considers the service type and an empty name as a unique key, overriding the first registration:

// Unity
var container = new UnityContainer();
container.LoadConfiguration(serviceCollection);
var services = container.ResolveAll<IService>(); // Returns [Service2] only

To satisfy Unity’s requirement and avoid overriding, we might think to just use MS.DI Keyed Services (which translate to Unity named registrations). But doing so breaks things on the MS.DI side because GetServices<T>() separates keyed services from normal ones, returning an empty list:

MS.DI
serviceCollection.AddKeyedTransient<IService, Service1>(serviceKey: "service1");
serviceCollection.AddKeyedTransient<IService, Service2>(serviceKey: "service2");
var services = serviceProvider.GetServices<IService>(); // Returns []

Simply translating MS.DI keyed registrations to Unity named registrations will leave you with an empty list or missing dependencies when resolving IEnumerable<T>.

To bridge this, I wrote custom logic when loading the ServiceCollection into Unity:

public static void LoadConfiguration(this IUnityContainer unityContainer, IServiceCollection serviceCollection) {
var groups = GroupServiceDescriptorsByServiceType(serviceCollection);
foreach (var group in groups) {
var serviceType = group.Key;
var descriptors = group.Value;
// Wrap keyed services with a generic `Keyed<T>` to separate them from normal services
foreach (var keyedServiceDescriptor in descriptors.Where(x => x is { IsKeyedService: true })) {
RegisterWithKeyedServiceWrapper(keyedServiceDescriptor, unityContainer);
}
var unKeyedServices = descriptors.Where(x => x is { IsKeyedService: false }).ToList();
if (unKeyedServices.Count == 1) {
RegisterSingleService(unKeyedServices[0], unityContainer);
} else {
// Prevent Unity from overriding services by generating unique names automatically
RegisterMultipleServicesWithAutoGeneratedName(unityContainer, unKeyedServices, serviceType);
}
}
}

Here is exactly how this adapter logic forces Unity’s engine to behave like MS.DI:

  1. Isolating Keyed Services: If we simply mapped MS.DI Keyed Services to Unity Named Services, Unity’s ResolveAll<T>() would still pull them in, which breaks MS.DI’s strict separation rule (MS.DI completely hides keyed services from normal IEnumerable<T> resolution). By wrapping keyed services in a custom generic form (like Keyed<T>), we change their fundamental registration type in Unity. This cleanly hides them from standard IEnumerable<T> queries, flawlessly mirroring MS.DI’s isolation.
  2. Handling Single Unkeyed Services: If there is exactly one unkeyed registration for a type, it is registered plainly without a name, serving as the default implementation.
  3. Auto-naming Multiple Unkeyed Services: This is the magic trick. When the logic detects multiple unkeyed services for the same type, instead of letting Unity overwrite them, we secretly assign each of them an auto-generated unique name. Because Unity implements IEnumerable<T> by collecting all named registrations of T, injecting these unique names ensures that when the app resolves IEnumerable<T>, it gets the full list of implementations—just like calling .GetServices<T>() in native MS.DI.

2. Practices for Safe Migration

Construct Safety Nets: Migrate Tests First

Before the final migration, I spent some time constructing safety nets. I duplicated the existing integration tests for DI registration so they would run against both Unity and MS.DI simultaneously. This helped me identify behavioral differences early on and made the actual migration process much smoother.

Snapshot Diff

During the migration, the code review process became painful and error-prone. The changes were fragmented across many files. Reviewers had to jump between the legacy unity.config and the new C# registration files, meticulously comparing service lifetimes, implementation types, and keys.

This manual process led to easily missed mistakes—in my case, two defects slipped through because of tiny service registration typos.

To solve this, I created a Snapshot Diff Tool. When a PR is created, I generate a human-readable text snapshot of the container state before and after the migration, and then run a diff to highlight the differences. This snapshot includes all registered services, their lifetimes, implementation types, keys, and the dependencies they resolve.

With this tool:

  • All effective container changes are gathered in one place.
  • Reviewers can easily spot differences (e.g., a new keyed service, or a service name automatically changed) and focus their energy on high-risk changes.
  • Change-makers can verify their own PRs to ensure total correctness before requesting a review.
  • I could confidently skip product functional reviews for migrations that produced an identical container snapshot.

After introducing this tool, I caught every registration mistake and introduced zero new defects.

3. Modularizing Service Registration

Once you move away from a giant XML file, you don’t want to end up with a single giant C# file. To prevent making all implementation types public, I structured the registrations modularly. Each module provides an extension method that adds its own registrations to the IServiceCollection.

I structured them in a hierarchical tree:

  1. Actual Module: A DLL providing an extension method to register its internal services.
  2. Logical Module: A logical grouping that aggregates multiple Actual Modules or other Logical Modules.
  3. Composition Root: The entry point of the application that gathers all Logical Modules and registers them into the master container.

This structured approach makes the new code-based DI significantly more organized, readable, and easier to maintain long-term.

Conclusion

Migrating a core infrastructure tool like a dependency injection container in a massive, legacy application is never just about swapping out a library. By adopting MS.DI as our single source of truth, building a smart adapter to bridge runtime behaviors, constructing robust test nets, and innovating with tools like the Snapshot Diff, we turned a high-risk refactoring into a smooth, predictable process.

After 7 months of deliberate, chunk-by-chunk migration, the codebase was fully decoupled from its legacy roots. When the day finally came to pull the plug on Unity Container and flip the switch to pure MS.DI, the transition was completely seamless—and most importantly, zero new defects were introduced to production.