The article Resolving PowerShell module assembly dependency conflicts contains great content about what this problem is, why it happens, and different ways for a module author to mitigate the issue.
The most robust solution described in the article leverages the AssemblyLoadContext
to handle the loading requests of all a module's dependencies,
which makes sure the module gets the exact version of the dependency assemblies that it requests for.
This technique presents a clean solution for a module to avoid dependency conflicts. It is used by the Bicep PowerShell module, and is also documented with a great example in Emanuel Palm's blog post: Resolving PowerShell Module Conflicts.
However, this technique requires the module assembly to not directly reference the dependency assemblies, but instead, to reference a wrapper assembly which then references the dependency assemblies. The wrapper assembly acts like a bridge, forwarding the calls from the module assembly to the dependency assemblies. This makes it usually a non-trivial amount of work to apply this technique --
- For a new module, this would add additional complexity to the design and implementation;
- For an existing module, this would require significant refactoring.
Here I want to introduce a simplified solution to mitigate the problem, which comes with two limitations comparing to the above solution but requires way less effort from the module author.
The use of the assembly resolving event is quite common for redirecting loading requests.
You can register an assembly resolving handler for the exact versions of your dependency assemblies,
and then leverage AssemblyLoadContext
in the handler to deal with the loading.
With this, there is no need to have a wrapper assembly,
and the handler is guaranteed to return the same assembly instance for all the loading requests it receives for the same assembly.
NOTE: Do not use
Assembly.LoadFrom
in the event handler.
That API always loads an assembly file to the defaultAssemblyLoadContext
, which is actually the source of this assembly-conflict problem.
NOTE: Do not use
Assembly.LoadFile
for the dependency isolation purpose.
This API does load an assembly to a separateAssemblyLoadContext
instance, but assemblies loaded by this API are discoverable by PowerShell's type resolution code (see code here). So, your module could run into the "Type Identity" issue when loading an assembly byAssembly.LoadFile
while another module loads a different version of the same assembly into the defaultAssemblyLoadContext
.
To leverage AssemblyLoadContext
,
you need to create a custom AssemblyLoadContext
class and directly use it to load assembly files.
We have the module SampleModule
to demonstrate this solution.
The whole sample is organized as follows:
- shared-dependency: it's a project to produce different versions of NuGet packages for
SharedDependency.dll
. Three such packages of the versions0.7.0
,1.0.0
, and1.5.0
are available under the folder nuget-packages. - SampleModule: it produces the
SampleModule
that uses "Resolving
event + customAssemblyLoadContext
" to handle the conflictingSharedDependency.dll
. See its README for details on the module structure and how it works. - ConflictWithHigherDeps: it's a module that depends on a higher version of
SharedDependency.dll
- ConflictWithLowerDeps: it's a module that depends on a lower version of
SharedDependency.dll
- scenario-demos: it contains the demos for five scenarios that
SampleModule
can run into with the modulesConflictWithHigherDeps
andConflictWithLowerDeps
.
To build and generate all the 3 modules needed for the demos,
run the .\build.ps1
within this folder.
The generated modules will be placed in .\bin
.
Please make sure .NET SDK 6
is installed and available in PATH
before building.
The version of the SDK should be 6.0.100
or newer.
Once the 3 modules are generated under .\bin
,
go ahead to scenario-demos to review the behaviors of SampleModule
for those five scenarios.
Comparing to technique adopted by the Bicep module, there are 2 limitations with this solution:
- If a higher version of the dependency is already loaded in the default
AssemblyLoadContext
, that version will be used by your module, and the resolving handler will never be triggered. - If another module uses the same technique to handle the same version of the same dependency, and it's loaded before your module, then your module's request for that dependency will be served by that module's resolving handler. This's OK as long as that module is still loaded, but could potentially be a problem if that module is removed and unregistered the resolving handler that served your previous loading request. This is because if your module happens to have a new request for the same dependency after that point, the new request might then be served by your module's resolving handler with a new assembly instance, which could cause the type identity issue.
Please make sure you evaluate the limitations before going forward with this solution:
- For the 1st limitation, it may be acceptable to depend on a higher version dependency assembly at run time for some modules. For those modules, this solution could be a good fit.
- For the 2nd limitation, it would be rare to happen in practice, given that most workflows don't involve removing a loaded module.