How to dynamically load different versions of an assembly in the same .NET application

Have you ever come across the need to use more than one version of an assembly within the same .NET application?

Assembly loading can be a somewhat tricky subject when there are multiple versions of the same assembly for a .NET application to choose from. This can result in problems for both static assembly references whenever the references are being resolved, and also when attempting to load assemblies dynamically due to nuances in how assemblies are loaded at runtime.

In the sections that follow I will be focusing on how to dynamically load different versions of an assembly into the same .NET process at runtime. I demonstrate how to achieve this in both .NET Framework and .NET Core / .NET 5.0+ applications.

Static assembly references

Before moving on to dynamic assembly loading, I want to briefly cover problems and solutions to some of the issues that you may encounter when different projects within your application solution are referencing different versions of the same assembly.

NuGet dependency hell

NuGet packages are a great way of adding library references to your projects and keeping those libraries up to date, but complex dependencies between packages can result in reference issues cropping up in .NET Framework applications.

While it is a great JSON serialization library, if you’ve ever used Newtonsoft.Json, you’ll more than likely have seen the following error message appear from time to time whenever you try to run your application.

System.IO.FileLoadException: Could not load file or assembly ‘Newtonsoft.Json, Version=13.0.1.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed’ or one of its dependencies. The system cannot find the file specified.

Newtonsoft.Json is a dependency of many other open-source projects and every package on NuGet that uses it typically targets a different version of the library. This causes problems in a .NET Framework solution that is made up of different projects that reference different versions of Newtonsoft.Json, as the correct version of the assembly to use cannot be easily determined.

Binding redirects

Whenever you need to ensure that a specific version of an assembly is loaded by your application, the .NET Framework offers a number of mechanisms to help you do this. The most common mechanism is binding redirects.

For a .NET Framework application, an error message like the one shown further above can usually be resolved by adding a binding redirect as follows within the configuration element of your app.config file.

<runtime>
  <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
    <dependentAssembly>
      <assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" />
      <bindingRedirect oldVersion="0.0.0.0-13.0.1.0" newVersion="13.0.1.0" />
    </dependentAssembly>
  </assemblyBinding>
</runtime>

The above bindingRedirect element is essentially saying that when our application is trying to resolve a reference to the Newtonsoft.Json library it should use version 13.0.1.0 of the assembly if a project is referencing an older version such as version 9.0.0.0.

Thankfully, since .NET Core, these kinds of issues with assembly loading are more or less a thing of the past. In .NET Core assembly loading, for the most part, “just works”.

Dynamic assembly loading

If we want to load a .NET assembly dynamically at runtime, the Assembly class provides us with a number of static methods, as follows.

Most of the above methods have several overloaded versions available, providing a lot of flexibility.

As an example, we can use the LoadFrom method to load an assembly from a specific file path.

const string pathToAssembly = @"C:\Program Files\My Company\My Product\MyAppLib.dll";

Assembly assembly = Assembly.LoadFrom(pathToAssembly);

Although we are now dynamically loading the assembly at runtime, the problem is that once we have loaded a particular version of an assembly there isn’t a straightforward way to ‘unload’ it. This prevents us from loading a different version of the assembly into our application.

By default, we can’t load two versions of the same assembly side-by-side either.

If we were to call the LoadFrom method as shown above again, but specify the path to a different version of the assembly, this would have no effect. Once a specific version of an assembly has been loaded we are stuck with this version of the assembly for the lifetime of the application.

Application domains

As stated in the previous section, by default, we can’t load multiple versions of the same assembly in a .NET application.

However, the .NET Framework has a trick up its sleeve that can help us; meet Application Domains.

Every .NET Framework application exists inside a process that is made up of one or more Application Domains.

By default, only one Application Domain exists, this is the default Application Domain where the assemblies that make up our application are loaded into.

You can think of an Application Domain as a mini-process inside a .NET application that is sandboxed and therefore has boundaries to prevent it from corrupting or interfering with other Application Domains within a .NET process.

The .NET Framework allows us to dynamically create multiple Application Domains on-demand.

This provides us with the ability to load one or more assemblies into a dynamically created Application Domain, in order to isolate a specific version of an assembly from our main application. The assemblies that are loaded into the default Application Domain will be unaffected by the assemblies which are loaded into the separate isolated Application Domain.

Dynamic side-by-side loading

While we cannot unload assemblies within a .NET application, we can unload an Application Domain. This allows us to create an Application Domain, load the assemblies we need into it, call methods inside those assemblies, and then unload the Application Domain when we are finished with it.

This is a very powerful concept and is best explained with an example, as shown below.

AppDomain domain = null;
 
try
{
// Create a new app domain.
string tempAppDomainName = Guid.NewGuid().ToString();
   domain = AppDomain.CreateDomain(tempAppDomainName);
// We must use a sandbox class to do work within the 
// new app domain, otherwise exceptions will be thrown.     Type assemblySandboxType = typeof(AssemblySandbox);
    var assemblySandbox = (AssemblySandbox)domain.CreateInstanceAndUnwrap(         Assembly.GetAssembly(assemblySandboxType).FullName, assemblySandboxType.ToString());
    // Call a function on the sandbox object to execute code inside the new app domain.     const string pathToAssembly = @"C:\Program Files\My Company\My Product\MyApp.exe";     bool success = assemblySandbox.DoFunction(pathToAssembly); } finally {     if (domain != null)     {
// Unload the app domain.         AppDomain.Unload(domain);     } }

In the above example, a new Application Domain is created by calling the static CreateDomain method belonging to the AppDomain class. A GUID is used as the name of the Application Domain to ensure that it is unique.

The CreateInstanceAndUnwrap method on the AppDomain object is then used to create an instance of the AssemblySandbox class and the code then calls the DoFunction method on the AssemblySandbox object, passing in the path to the assembly which should be loaded.

Finally, the Application Domain is unloaded when we are finished to free up resources.

Sandboxing

The AssemblySandbox class is a custom class containing the code which is to be executed within the context of the separate Application Domain, and it is defined as follows.

/// <summary>
/// Facilitates the execution of code within a separate Application Domain.
/// </summary>
public class AssemblySandbox : MarshalByRefObject
{
    /// <summary>
    /// Calls the 'DoFunction' method within the assembly located at the specified file path.
    /// </summary>
    /// <param name="pathToAssembly">The path to the assembly to load</param>
    /// <returns>True if the function was successful, otherwise false</returns>
    public bool DoFunction(string pathToAssembly)
    {
// Load the assembly we wish to use into the new app domain.        Assembly assembly = Assembly.LoadFrom(pathToAssembly);
// Create an instance of a class from the assembly.         Type classType = assembly.GetType("MyCompany.MyApp.MyClass");         dynamic classInstance = Activator.CreateInstance(classType);         
// Call a function on the object and return the result.          bool result = classInstance.DoFunction("Hello"
);         return result;     } }

The key thing to note in the above example is that the AssemblySandbox class inherits from the MarshalByRefObject class. This is what allows us to safely carry out work within the separate Application Domain and pass results across Application Domain boundaries.

Within the DoFunction method, the assembly that we wish to load into the separate Application Domain is loaded. The type of class we wish to use from within the assembly is then identified by its fully qualified name.

Note that you will need to update this code with the fully qualified name of the type you wish to target. There are different ways of getting the type that you need using Reflection. For example, instead of referring to a specific class, you could instead search for a class that implements a particular interface.

An instance of the class we have specified is then created using Reflection and a method is called dynamically on the object instance.

Finally, the result of the dynamic method invocation is passed back to the caller.

Application domain benefits

The code blocks shown in the previous sections are intended to be simple examples to illustrate the point. It’s possible to imagine many different scenarios for things that you could do within the isolated context of an Application Domain.

The key benefits of the approach I have demonstrated are that any assemblies that you load into an Application Domain will not affect the default Application Domain that your main application assemblies reside in, and you can safely unload additional Application Domains when they are no longer needed in order to free up resources.

Assembly load contexts

If you are using .NET Core, the code shown in the previous sections will not work.

Although .NET Core retains the Application Domain APIs in order to retain some measure of backwards compatibility, .NET Core does not actually support Application Domains.

The following exception will be thrown if you try to create an Application Domain within a .NET Core application.

System.PlatformNotSupportedException: ‘Secondary AppDomains are not supported on this platform.’

However, thankfully there is a much simpler way to achieve a similar result with .NET Core by using Assembly Load Contexts.

Below is an example of how to use an Assembly Load Context to dynamically load an assembly into a separate context from the assemblies that are loaded as part of our main application.

AssemblyLoadContext loadContext = null;
 
try
{
// Create a new context and mark it as 'collectible'.     string tempLoadContextName = Guid.NewGuid().ToString();    loadContext = new AssemblyLoadContext(tempLoadContextName, true);
// Load the assembly we wish to use into the new context.     const string pathToAssembly = @"C:\Program Files\My Company\My App\MyApp.exe";    Assembly assembly = loadContext.LoadFromAssemblyPath(pathToAssembly);
// Create an instance of a class from the assembly.    Type classType = assembly.GetType("MyCompany.MyApp.MyClass");
   dynamic classInstance = Activator.CreateInstance(classType);    
// Call a function on the object and get the result.      bool result = classInstance.DoFunction("Hello"); } finally {
// Unload the context.     loadContext.Unload(); }

The above example follows a similar pattern to the Application Domain example that was shown in the previous sections.

An instance of the AssemblyLoadContext class is created and is unloaded when our work has been completed.

However, the code in between is greatly simplified.

We can use the LoadFromAssemblyPath method on the AssemblyLoadContext object to dynamically load an assembly without having to deal with the complexities of Application Domains, ‘Marshal By Ref Objects’, and the associated boundaries that are imposed.

In short, Assembly Load Contexts offer the same benefits of isolation that Application Domains previously provided and there is much less ceremony involved in creating a new context and loading assemblies into it.

I’m sure you’ll agree that the .NET Core approach is much simpler and takes away much of the fuss associated with managing separate Application Domains.

Summary

In this article, I have discussed static and dynamic assembly loading and some of the associated problems with both.

I’ve demonstrated how to dynamically load different versions of the same assembly into a .NET Framework application by leveraging the power of Application Domains.

Lastly, I’ve shown how to achieve the same result using .NET Core with Assembly Load Contexts.

In closing, I highly recommend using .NET Core for new development. It features many improvements to assembly loading, and features like Assembly Load Contexts are a most welcome addition.

Comments

This site uses Akismet to reduce spam. Learn how your comment data is processed.