Have you ever had a situation where you needed to use more than one assembly version in a single.NET application?
When a.NET application has to choose between different versions of the same assembly, assembly loading can be a bit of a challenge. Due to nuances in how assemblies are loaded at runtime, this may cause issues for both static assembly references whenever the references are being resolved and when attempting to load assemblies dynamically.
I’ll be concentrating on how to dynamically load various assembly versions into the same.NET process at runtime in the sections that follow. In both.NET Framework and.NET Core/.NET 5.0+ applications, I show how to accomplish this.
Static assembly references
Before discussing dynamic assembly loading, I want to quickly go over some of the problems that you might experience if various projects within your application solution are referencing various versions of the same assembly, as well as some solutions to those problems.
NuGet dependency hell
NuGet packages are a great way to add library references to your projects and maintain the updates for those libraries, but complex dependencies between packages can cause reference problems to arise in.NET Framework applications.
Newtonsoft.Json is a fantastic JSON serialization library, but if you’ve ever used it, you’ve probably occasionally encountered the following error when attempting to run your program.
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.Numerous other open-source projects depend on Json, and each package on NuGet that uses it typically targets a different version of the library. In a.NET Framework solution made up of various projects that each reference a different version of Newtonsoft.Json, this makes it difficult to identify which version of the assembly should be used.
Binding redirects
The.NET Framework provides a number of mechanisms to assist you in ensuring that a specific version of an assembly is loaded by your application. Binding redirects are the most typical mechanism.
An error message like the one further above for a.NET Framework application can typically be fixed 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 aforementioned bindingRedirect
element essentially instructs our application to use version 13.0.1.0 of the assembly when attempting to resolve a reference to the Newtonsoft.Json library if a project is referencing an earlier version, such as version 9.0.0.0.
Thankfully, since the introduction of.NET Core, these assembly loading problems have largely disappeared. For the most part, assembly loading in.NET Core “just works”.
Dynamic assembly loading
The Assembly
class offers us a number of static methods that we can use to dynamically load a.NET assembly at runtime. They are as follows.
Many overloaded versions of the methods mentioned above are available, offering a great deal of flexibility.
For instance, we can load an assembly from a specific file path using the LoadFrom
method.
const string pathToAssembly = @"C:\Program Files\My Company\My Product\MyAppLib.dll"; Assembly assembly = Assembly.LoadFrom(pathToAssembly);
Even though we are now dynamically loading the assembly at runtime, the issue is that there isn’t an easy way to ‘unload’ an assembly once it has been loaded. As a result, we are unable to load an alternative assembly version into our application.
By default, we are also unable to load two different assemblies simultaneously.
It would be ineffective to use a different version of the assembly when calling the LoadFrom
method as demonstrated above. We are forced to use a particular version of an assembly for the duration of the application once it has been loaded.
Application domains
As mentioned in the previous section, by default, a.NET application can’t load more than one instance of the same assembly.
The.NET Framework does, however, have a helpful trick up its sleeve: welcome to Application Domains.
Each and every.NET Framework application runs within a process that consists of one or more Application Domains.
There is only one Application Domain by default, and it is this Application Domain that is used to load the assemblies that make up our application.
An Application Domain can be thought of as a miniature process inside a.NET application that has boundaries to prevent it from corrupting or interfering with other Application Domains within a.NET process.
We can instantly create a number of Application Domains using the.NET Framework.
We can use this to isolate a particular version of an assembly from our main application by loading one or more assemblies into an Application Domain that is generated dynamically. The assemblies loaded into the separate isolated Application Domain won’t have an impact on the assemblies loaded into the default Application Domain.
Dynamic side-by-side loading
We can unload an Application Domain but not assemblies within a.NET application. This enables us to create an application domain, load the assemblies we require into it, call the methods contained within those assemblies, and then unload the application domain once we are done with it.
The best way to explain this extremely potent idea is with an example, as is done in the example 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); } }
The static CreateDomain
method of the AppDomain
class is called to create a new Application Domain in the example above. To make sure the name of the Application Domain is distinct, a GUID is used.
The code then calls the DoFunction method on the AssemblySandbox
object, passing in the path to the assembly that needs to be loaded, after creating an instance of the AssemblySandbox
class using the CreateInstanceAndUnwrap
method on the AppDomain
object.
When we’re done, we unload the Application Domain to release resources.
Sandboxing
The code that will be executed within the context of the distinct Application Domain is contained in a custom class called AssemblySandbox
, 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; } }
In the example above, it’s important to keep in mind that the AssemblySandbox
class descended from the MarshalByRefObject
class. Because of this, we are able to work securely within each distinct Application Domain and pass results across Application Domain boundaries.
The assembly that we want to load into the separate Application Domain is loaded within the DoFunction
method. The fully qualified name of the class that we want to use from the assembly is then used to identify it.
Note that the fully qualified name of the type you wish to target must be added to this code. Using Reflection, you can obtain the type you require in a variety of ways. For instance, you could look for a class that implements a particular interface rather than referring to a specific class.
Then, using reflection, an instance of the class we’ve specified is created, and a dynamic method is called on the object instance.
The caller is then given the outcome of the dynamic method invocation.
Application domain benefits
The code blocks displayed in the earlier sections are meant to serve as straightforward examples to drive home the point. You can create a wide variety of scenarios for what you might be able to do in the solitary setting of an application domain.
The main advantages of the method I’ve shown are that you can safely unload additional Application Domains when they are not needed in order to free up resources and that any assemblies you load into an Application Domain will not affect the default Application Domain where your main application assemblies are located.
Assembly load contexts
The code displayed in the earlier sections will not function if you are using.NET Core.
Although the Application Domain APIs are still present in.NET Core to maintain some level of backwards compatibility, Application Domains are not supported by.NET Core.
If you attempt to create an Application Domain inside of a.NET Core application, the following exception will be thrown.
System.PlatformNotSupportedException: ‘Secondary AppDomains are not supported on this platform.’
Fortunately, using Assembly Load Contexts with.NET Core makes it much easier to accomplish the same thing.
The example that follows shows how to use an assembly load context to dynamically load an assembly into a different 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(); }
Similar to the Application Domain example that was demonstrated in the earlier sections, the example above also follows a pattern.
When our work is done, an instance of the AssemblyLoadContext
class is created and unloaded.
The code in between is considerably more straightforward.
To dynamically load an assembly without having to deal with the difficulties of Application Domains, “Marshal By Ref Objects,” and the boundaries that are imposed, we can use the LoadFromAssemblyPath
method on the AssemblyLoadContext
object.
Shortly put, Assembly Load Contexts provide the same isolation benefits that Application Domains once did, and there is a lot less formality involved in setting up a new context and loading assemblies into it.
You’ll probably agree that the.NET Core approach is much less complicated and eliminates most of the hassle involved in managing different Application Domains.
Summary
I’ve covered static and dynamic assembly loading in this article, along with some related issues.
I’ve shown how to use the power of Application Domains to dynamically load various versions of the same assembly into a.NET Framework application.
Finally, I’ve demonstrated how to use Assembly Load Contexts in.NET Core to achieve the same outcome.
Finally, I strongly suggest switching to.NET Core for new development. Assembly loading has been greatly improved, and features like Assembly Load Contexts are a very welcome addition.