Powershell Modules – Best Practices

Powershell Modules – Best Practices

  • Post author:
  • Post category:Basics
  • Post comments:0 Comments

Writing PowerShell modules is an entirely different approach compared to scripting, even when working with scripts that are several hundred lines long. Modules unlock features that are exclusive to modular development, providing better organization, reusability, and maintainability.

In my experience, most blog posts focus on getting started with modules—explaining what they are and how to create a basic one. However, I haven’t found many that dive into best practices for writing well-structured, efficient modules. Since module development allows for a completely different coding style, I decided to write this post to fill that gap.

Module Manifest: Why You Should Always Use One

The first key component of a PowerShell module is the Module Manifest. While technically optional, I highly recommend using it. The manifest provides valuable metadata, such as:

  • Author Information – Who wrote the module
  • Creation Date – When it was built
  • Versioning – Helps track updates
  • Exported Functions – Defines which functions are available when the module is imported

Essentially, the manifest centralizes all important module information. And the best part? Creating one is easy—just run New-ModuleManifest -Path "MyModule.psd1"

The .psm1 File: The Core of a PowerShell Module

Every PowerShell module must have a .psm1 file—this is where all your functions live (I’ll go into detail about them later). And that’s it! That’s the basic knowledge found in most blog posts. So, by now, you should know what to do, right?

Well, let’s take it a step further.

Public vs. Private Functions in PowerShell Modules

Unlike some programming languages, PowerShell doesn’t let us explicitly define functions as public or private. However, in a module, we can imitate this functionality by controlling which functions are available to the user.

  • Public Functions – Exposed to the user when they import the module
  • Private Functions – Internal helper functions, not meant for direct use

To maintain a clean and scalable structure, we should also physically separate these functions into different folders. Each function should be in its own .ps1 file for better organization.

Some people say, “Why not just dump everything into the .psm1 file?”
Yes, you can—but once your module grows to 1,000+ lines, scrolling up and down becomes a nightmare (trust me).

PowerShell
MyModule/                # Name the module folder
  - MyModule.psm1        # The core script module (must-have)
  - MyModule.psd1        # Module manifest (optional but recommended) 
  - Private/ps1/         # Internal functions (not exposed to the user)
    + Get-MyFunctionASAP.ps1
    + Get-ASAPGetters.ps1    
  - Public/ps1/          # Exposed functions (available to the user)
    + Get-SomeCoolStuff.ps1  

I love automating things—so, of course, I automated the creation of this module structure. Why set up the folders manually when PowerShell can do it for you?

PowerShell
param(
    [Parameter(Mandatory)]
    [string]
    $ModuleName
)
$moduleName = $ModuleName
$author = "This could be your Name"
$year = (Get-Date).year
$version = "1.0"

New-Item -ItemType Directory -Name $moduleName

New-Item "$PWD\$moduleName\Private\ps1" -ItemType Directory -Force
New-Item "$PWD\$moduleName\Public\ps1" -ItemType Directory -Force

New-Item -Path "$PWD\$moduleName\$moduleName.psm1" -ItemType File

$moduleManifestValues = @{
    Path = "$PWD\$moduleName\$moduleName.psd1"
    Author = $author
    Copyright = "$year by $author"
    Description = ""
    Rootmodule = "$moduleName.psm1"
    ModuleVersion = $version
}

New-ModuleManifest @moduleManifestValues

Loading Functions in Your .psm1 File

Now that we have a structured module, we need to ensure that our .psm1 file properly loads all the functions from the Public and Private folders.

There are two common approaches to achieve this:

1. Full Version (With Error Handling)

This method ensures that if a function fails to load, you get a proper error message instead of silently failing.

PowerShell
#Get public and private function definition files.
$Public  = @( Get-ChildItem -Path $PSScriptRoot\Public\ps1 -Filter *.ps1 -Recurse -ErrorAction SilentlyContinue )
$Private = @( Get-ChildItem -Path $PSScriptRoot\Private\ps1 -Filter *.ps1 -Recurse -ErrorAction SilentlyContinue )

#Dot source the files
Foreach($import in @($Public + $Private))
{
    Try
    {
        . $import.fullname
    }
    Catch
    {
        Write-Error -Message "Failed to import function $($import.fullname): $_"
    }
}

2. Short Version (Simple & Clean)

If you prefer a minimal approach, this two-liner gets the job done:

PowerShell
Get-ChildItem -Path "$PSScriptRoot\Public\ps1" -Filter *.ps1 | ForEach-Object { . $_.FullName }
Get-ChildItem -Path "$PSScriptRoot\Private\ps1" -Filter *.ps1 | ForEach-Object { . $_.FullName }

You might have noticed $PSScriptRoot in both versions of the function-loading script. This is where we hit our first key difference between regular PowerShell scripts and modules.

  • In a regular script (.ps1 file): $PSScriptRoot returns the file path of the script.
  • In a module (.psm1 file): $PSScriptRoot returns the directory of the module.

This is crucial because when working with modules, you don’t want absolute file paths—you want to reference files dynamically relative to the module’s location. That’s why $PSScriptRoot is used to load functions from the Public and Private folders.

Persistent Module Variables

One of the biggest differences between PowerShell modules and scripts is how variables persist across function calls. Let’s compare both approaches.

In a regular script (.ps1), variables exist only during script execution. Once the script finishes, all variables are gone. I’m not talking about development. Save the script, and call it.

PowerShell
# Define a cache variable
$cache = @{}

function Set-CachedItem {
    param ($Key, $Value)
    $cache[$Key] = $Value
}

function Get-CachedItem {
    param ($Key)
    return $cache[$Key]
}

# Set a value
Set-CachedItem -Key "User1" -Value "Admin"

# Retrieve value (works within the same execution)
Write-Output (Get-CachedItem -Key "User1")  # Output: Admin

# Uncomment 'Set-CachedItem' - Run the script again -> $cache is empty!

Module Variables (Persist While the Module is Loaded)

PowerShell
# Module-level cache variable
$script:Cache = @{}

function Set-CachedItem {
    param ($Key, $Value)
    $script:Cache[$Key] = $Value
}

function Get-CachedItem {
    param ($Key)
    return $script:Cache[$Key]
}

PowerShell
Import-Module MyModule

Set-CachedItem -Key "User1" -Value "Admin"
Write-Output (Get-CachedItem -Key "User1")  # Output: Admin

# Run another command (variable still exists)
Set-CachedItem -Key "User2" -Value "Editor"
Write-Output (Get-CachedItem -Key "User2")  # Output: Editor

# Unload the module → Variable is lost
Remove-Module MyModule

Variable Scopes

You just saw $script:Cache in the module example, and you might be wondering, “What’s this $script: scope all about?”

Well, in PowerShell, the scope in which a variable is defined determines its visibility and lifespan. The $script: scope is one of those special scopes that’s used for variables you want to persist throughout the execution of the module or script, but keep it restricted to the module.

In this case, $script:Cache is defined at the module level and can be accessed by any function within the module, but it won’t interfere with other scripts or modules. It’s a way to keep the data private to your module, while still being able to store and reuse it as needed—without cluttering the global space.

The $global: scope is used for variables that are accessible from anywhere in the entire PowerShell session, whether inside functions, scripts, or even other modules. Once you define a variable in the $global: scope, it’s essentially open to the whole session, and any script or function can modify or read it.

Be careful with global variables!

Since any script or function can modify them, they might get overwritten unintentionally.

Using $script: is often a better approach for module development to keep variables encapsulated.

Auto-Loading Modules ($env:PSModulePath)

One of the advantages of PowerShell modules is auto-loading. If a module is installed in the $env:PSModulePath, PowerShell can automatically discover and load it when a function from the module is called—without needing Import-Module. You might have noticed that when you open PowerShell and immediately run Get-ADUser -Identity myIdentity, the Active Directory module loads automatically—even though you didn’t explicitly import it.

Summarize

1. Always Use a Module Manifest

Even though it’s optional, a .psd1 manifest centralizes metadata like author details, versioning, and exported functions. Use New-ModuleManifest to generate one.

2. Maintain a Clear Folder Structure

Organizing functions into Public and Private folders keeps your module clean and scalable. Each function should reside in its own .ps1 file.

3. Dynamically Load Functions

Use $PSScriptRoot in your .psm1 file to dynamically dot-source functions from the Public and Private folders, avoiding hardcoded paths.

4. Use Persistent Variables Wisely

  • $script: for module-level persistence while keeping data private to the module.
  • $global: for session-wide variables (use sparingly to avoid conflicts).

5. Keep Public and Private Functions Separate

Only expose necessary functions to users by exporting only public functions in your manifest. Keep internal helper functions private to avoid cluttering the user’s namespace.

Leave a Reply