Dependency Pinning in PowerShell

TL;DR

If you want to use version pinning/freezing, do not opt for the standard #Requires statement. Instead, use Import-Module with the -RequiredVersion parameter and -Scope Local parameters:

Import-Module MyModule -RequiredVersion 1.0 -Scope Local

Better yet, put all your modules and versions in a list of hashmaps and iterate through:

$requiredModules = @(
  @{ModuleName="MyModule"; RequiredVersion="1.0"}
)
foreach ($module in $requiredModules){
  $moduleName = $module.ModuleName
  $version = $module.RequiredVersion
  try {
    Import-Module $moduleName -RequiredVersion $version -Scope Local
  } catch {
    throw "Error: Could now import $moduleName version $version"
  }
}

If you're on PowerShell 5 or above (especially if you're dealing with classes), you can use the using statement with a module specification:

using module @{ModuleName="MyModule"; RequiredVersion="1.5"}

Introduction

Recently, we've been working on a lot of internal PowerShell utilities, and one major design decision has constantly been plaguing us: how exactly do we organize our utilities so we can do atomic modules and dependency chains, while avoiding the global scoping that PowerShell uses by default. Basically, we had two options:

  1. We organize all our code and cmdlets into a single monolithic module. This would make dependencies easy, since every function and cmdlet is globally available to every other function. It also makes deployment easy, since there's only one module to package and install. The downside is that this would then need to be rigourously tested at every change made to it. Even the smallest of changes could effect the spaghetti of interwoven code in unexpected ways, making upgrading a much more delicate process.
  2. We organize our code in more atomic modules, each of which only handles a few functions or cmdlets, and other modules can import. This makes it more difficult to deploy (since each module version has to be individually packaged and installed), but the overall structure of the code more resilient and easier to troubleshoot.

We ended up opting for number 2, but quickly ran into a problem: how do we deal with versioning conflicts?

Versioning Conflicts

The problem can be illustrated as follows:

# Modules folder structure
/Modules # some folder included in the $PSModulesPath
 |-FirstScript
 | |-FirstScript.psd1
 | |-FirstScript.psm1
 |
 |-SecondScript
 | |-SecondScript.psd1
 | |-SecondScript.psm1
 | 
 |-MyModule
 | |-1.0
 | | |-MyModule.psm1
 | | |-MyModule.psd1
 | | 
 | |-2.0
 | | |-MyModule.psm1
 | | |-MyModule.psd1

Here, we have a two tools and one module, which will be imported to the tools. Because of the fancy side-by-side versioning introduced in PowerShell 5 (as shown here: Side-by-Side Version Support on PowerShell 5.0 or newer), we can have two versions of our MyModule package available for import simultaneously. In the case of our tools, let's assume each requires different versions of the MyModule module. We'll use the #requires statement, which can be used to specify module dependencies using the -Modules parameter and a module specification:

# 1.0/MyModule.psm1

function Get-Foo {
    Write-Host "Hello from MyModule:Get-Foo version 1.0"
}

Export-ModuleMember -Function "*-*"
# 2.0/MyModule.psm1

function Get-Foo {
    Write-Host "Hello from MyModule:Get-Foo version 2.0"
}

function New-FunctionInVersion2 {
    Write-Host "This is a new function introduced in version 2.0"
}

Export-ModuleMember -Function "*-*"
# FirstScript.psm1

#Requires -Modules @{ModuleName="MyModule"; RequiredVersion="1.0"}

function Invoke-FirstScript {
    Write-Host "Hello from MyFirstScript"
    Write-Host "Invoking MyModule:Get-Foo 1.0"
    Get-Foo
  }
  
  Export-Module -Function "*-*"
# MySecondScript.psm1

#Requires -Modules MyModule -RequiredVersion 2.0

function Invoke-MySecondScript {
  Write-Host "Hello from MySecondScript"
  Write-Host "Invoking Get-Foo 2.0"
  Get-Foo
  Write-Host "Invoking New-Function 2.0"
  New-Function
}

Export-Module -Functions "*-*"

Now let's try out our scripts. From a PowerShell prompt:

> Import-Module FirstScript
> Invoke-FirstScript

Hello from FirstScript
Invoking MyModule:Get-Foo 1.0
Hello from MyModule:Get-Foo version 1.0

It looks like everything worked as expected. Now let's try the other tool from the same prompt:

> Import-Module SecondScript
> Invoke-SecondScript

Hello from SecondScript
Invoking MyModule:Get-Foo 2.0
Hello from MyModule:Get-Foo version 2.0
Invoking MyModule:New-FunctionInVersion2:
This is a new function introduced in version 2.0

So far so good. But now let's try running the first tool again:

> Invoke-FirstScript

Hello from FirstScript
Invoking MyModule:Get-Foo 1.0
Hello from MyModule:Get-Foo version 2.0

It looks like the call to Get-Foo resulted in a call to the 2.0 version. What happened? Let's take a look at our imported modules

> Get-Module

ModuleType Version    Name                                ExportedCommands
---------- -------    ----                                ----------------
...
Script     1.0        FirstScript                         Invoke-FirstScript
Script     1.0        MyModule                            Get-Foo
Script     2.0        MyModule                            {Get-Foo, New-FunctionInVersion2}
Script     1.0        SecondScript                        Invoke-SecondScript

As you can see, MyModule has been loaded into the global namespace twice, with two different versions. This is not certainly not ideal.

One way we could get around this is via dot-sourcing our required dependencies, but then we would need to know the absolute or relative path to our other dependencies. This is also not ideal, since this defeats the purpose of keeping all our modules installed in the $PSModulesPath.

The Solution: -Scope Local to the Rescue

One of the built-in parameters available to all cmdlets is the -Scope parameter. This is also included in the Import-Module cmdlet. Let's swap this in for the #Requires module:

# FirstScript.psm1

Import-Module MyModule -RequiredVersion 1.0 -Scope Local
...	
# MySecondScript.psm1

Import-Module MyModule -RequiredVersion 2.0 -Scope Local
...

Now we can try using the two scripts again:

> Import-Module FirstScript
> Import-Module SecondScript

> Invoke-FirstScript
Hello from FirstScript
Invoking MyModule:Get-Foo 1.0
Hello from MyModule:Get-Foo version 1.0

> Invoke-SecondScript
Hello from SecondScript
Invoking MyModule:Get-Foo 2.0
Hello from MyModule:Get-Foo version 2.0
Invoking MyModule:New-FunctionInVersion2:
This is a new function introduced in version 2.0

It works! The modules have been loaded into separate namespaces, which prevent them from colliding each other when they are loaded by default into the global namespace. If we do a call to Get-Module, we can see that the MyModule module isn't loaded:

ModuleType Version    Name                                ExportedCommands
---------- -------    ----                                ----------------
...
Script     1.0        FirstScript                         {Get-Foo, Invoke-FirstScript}
Script     1.0        SecondScript                        {Get-Foo, Invoke-SecondScript, New-FunctionInVersion2}

What's up with "using"?

In PowerShell 5, the using statement was introduced, which serves as an alternative to the Import-Module statement. This is currently the only way to reliably import classes. Although there seems to be some weirdness in this statement (for example, as noted by Brandon Olin in his presentation "Developing with PowerShell Classes"), its default behavior is to import everything into the local, not global, namespace. Additionally, it also has support for the module specifications above, which allows it to specify particular versions of it (even though this feature is not referenced in the about_Using documentation).

Here is an example of this:

#FirstScript.psm1

using module @{ModuleName="MyModule"; RequiredVersion="1.0"}

...
#SecondScript.psm1

using module @{ModuleName="MyModule"; RequiredVersion="2.0"}

...

If we use these modules like we did above, they work exactly the same:

> Import-Module FirstScript
> Import-Module SecondScript

> Invoke-FirstScript
Hello from FirstScript
Invoking MyModule:Get-Foo 1.0
Hello from MyModule:Get-Foo version 1.0

> Invoke-SecondScript
Hello from SecondScript
Invoking MyModule:Get-Foo 2.0
Hello from MyModule:Get-Foo version 2.0
Invoking MyModule:New-FunctionInVersion2:
This is a new function introduced in version 2.0

Conclusion

In general, it seems the Import-Module with local scoping is going to be your best friend if you want to use dependency pinning/freezing in versions of PowerShell before 5, since the default behavior of other import mechanisms tend to just throw everything into the global namespace. If you want to specify many modules, you can throw everything into a hashmap and iterate over it:

$requiredModules = @(
  @{ModuleName="MyModule"; RequiredVersion="1.0"}
)
foreach ($module in $requiredModules){
  $moduleName = $module.ModuleName
  $version = $module.RequiredVersion
  try {
    Import-Module $moduleName -RequiredVersion $version -Scope Local
  } catch {
    throw "Error: Could now import $moduleName version $version"
  }
}

As long as you're dealing with at least version 5 of PowerShell, you can opt for the newer using statement to deal with pinned, local imports:

using module @{ModuleName="MyModule"; RequiredVersion="1.0"}

I am personally using the using statement in several of my projects, as this seems to be the simplest way of dealing with both dependencies and classes without having a mix of import strategies.

Show Comments