TB

MoppleIT Tech Blog

Welcome to my personal blog where I share thoughts, ideas, and experiences.

Predictable PowerShell Modules with Solid Manifests: Clean Loads, Clear Exports

When you ship a PowerShell module, your users expect it to import cleanly, expose only supported commands, and behave consistently across environments. The key to that predictability is a solid module manifest (.psd1) paired with a focused module script (.psm1). In this guide, you’ll create a minimal, production-ready module, explicitly declare your exports, set minimum PowerShell versions and compatible editions, and verify your public surface. You’ll also see how to version, test, and automate releases so consuming your module is effortless and reliable.

Build a Minimal, Predictable Module

A PowerShell module has two essential pieces:

  • .psm1: The module implementation (functions, private helpers, initialization).
  • .psd1: The manifest that declares metadata and what the module publicly exports.

Start with a simple module that exports exactly one function. Import from the manifest (not the .psm1) so all metadata, compatibility, and exports apply consistently.

# Create a minimal module with clear exports
$root = Join-Path -Path (Get-Location) -ChildPath 'MyTools'
New-Item -ItemType Directory -Path $root -Force | Out-Null

$psm1 = Join-Path -Path $root -ChildPath 'MyTools.psm1'
Set-Content -Path $psm1 -Encoding utf8 -Value 'function Get-Greeting { param([string]$Name) "Hello, $Name" }'

$psd1 = Join-Path -Path $root -ChildPath 'MyTools.psd1'
New-ModuleManifest -Path $psd1 `
  -RootModule 'MyTools.psm1' `
  -ModuleVersion '1.0.0' `
  -Author 'Morten Elmstrøm Hansen' `
  -Description 'Utility commands with clear exports.' `
  -FunctionsToExport @('Get-Greeting') `
  -CompatiblePSEditions @('Core','Desktop') `
  -PowerShellVersion '5.1'

# Always import from the manifest so metadata/exports apply
Import-Module -Name $psd1 -Force

# Verify the public surface area
Get-Command -Module MyTools

Why import via manifest? The manifest is the source of truth for exports, minimum engine version, and compatible editions. Users get a predictable load no matter how the environment is configured.

Files and folders that scale

For real-world usage, install your module in a versioned folder on a module path the shell knows about, such as:

# Windows PowerShell (Desktop):
$env:USERPROFILE\Documents\WindowsPowerShell\Modules\MyTools\1.0.0\

# PowerShell (Core):
$env:USERPROFILE\Documents\PowerShell\Modules\MyTools\1.0.0\

Place MyTools.psd1 and MyTools.psm1 in that versioned directory. Users can then simply run Import-Module MyTools or specify versions precisely.

Lock Down Your Public Surface and Compatibility

Declare exports explicitly

Replace wildcard exports with explicit lists. This avoids accidental leakage of helper functions and keeps your API surface stable. Use the manifest keys FunctionsToExport, CmdletsToExport, VariablesToExport, and AliasesToExport.

# Inside MyTools.psm1
function Get-Greeting {
  [CmdletBinding()]
  param([Parameter(Mandatory)][string]$Name)
  "Hello, $Name"
}

# Private helper not exported
function Write-Trace {
  param([string]$Message)
  Write-Verbose "[trace] $Message"
}
# Inside MyTools.psd1 (excerpt)
@{
  RootModule           = 'MyTools.psm1'
  ModuleVersion        = '1.0.0'
  GUID                 = '00000000-0000-0000-0000-000000000001'
  Author               = 'Your Name'
  Description          = 'Utility commands with clear exports.'

  # Explicitly exported members
  FunctionsToExport    = @('Get-Greeting')
  CmdletsToExport      = @()            # none
  VariablesToExport    = @()            # none
  AliasesToExport      = @()            # none

  # Dependencies
  RequiredModules      = @()            # add module specs if needed
  RequiredAssemblies   = @()

  # Compatibility controls
  PowerShellVersion    = '5.1'
  CompatiblePSEditions = @('Core', 'Desktop')
}

With explicit exports, a new helper function won’t suddenly become public and break users who rely on your module’s stable surface.

Set minimum versions and editions

Use PowerShellVersion to set the minimum engine version and CompatiblePSEditions to restrict to Desktop (Windows PowerShell) and/or Core (cross-platform). This prevents your module from loading in unsupported environments, saving your users from confusing runtime errors.

  • PowerShellVersion: Gate features that require newer engines (e.g., PS 7+ pipeline binding behavior or APIs).
  • CompatiblePSEditions: Avoid platform pitfalls (e.g., Windows-only APIs) by explicitly supporting only Desktop when necessary.

Complement the manifest with #requires in your .psm1 for critical checks:

#requires -Version 5.1
#requires -PSEdition Core

#requires provides immediate feedback during dot-sourcing or development; the manifest enforces at import time for users.

Validate your public surface

Ensure only expected commands are exported and supported:

# Confirm exports match the manifest
Get-Command -Module MyTools | Select-Object Name, CommandType

# Validate manifest
Test-ModuleManifest -Path $psd1

# Try a real call
Get-Greeting -Name 'Ada'

For automated enforcement, add a small Pester test to your CI pipeline:

# In tests\PublicSurface.Tests.ps1
Describe 'Public surface' {
  BeforeAll {
    Import-Module MyTools -Force
  }

  It 'exports the expected functions' {
    $expected = @('Get-Greeting')
    $actual   = (Get-Command -Module MyTools -CommandType Function).Name | Sort-Object
    $actual | Should -BeExactly $expected
  }
}

This test will fail if a helper leaks into the public API or a public function goes missing.

Versioning, Distribution, and Automation

Use SemVer and versioned folders

Adopt semantic versioning (MAJOR.MINOR.PATCH):

  • Increment PATCH for bug fixes that don’t change the API.
  • Increment MINOR for new backward-compatible features.
  • Increment MAJOR for breaking changes to the public API.

Install modules under MyTools\1.0.0\ to support side-by-side installs. Consumers can pin precisely:

# Pin to an exact version
Import-Module -FullyQualifiedName @{ ModuleName = 'MyTools'; RequiredVersion = '1.0.0' }

# Or specify minimum/maximum when using RequiredModules in your own manifest
# RequiredModules = @(@{ ModuleName = 'MyTools'; ModuleVersion = '1.0.0' })

Package and publish with confidence

When you're ready to share:

  • Local install: Copy to your user or system module path ($env:PSModulePath).
  • Private feeds: Use an internal NuGet feed or Artifactory.
  • PowerShell Gallery: Use Publish-Module for public distribution.
# Prepare and publish
Update-ModuleManifest -Path .\MyTools\1.0.0\MyTools.psd1 -ModuleVersion '1.0.1'
Publish-Module -Path .\MyTools\1.0.0 -NuGetApiKey $env:PSGALLERY_KEY

Consumers can install or cache versions predictably:

# End-users
Install-Module MyTools -Scope CurrentUser

# CI environments that pin versions
Save-Module -Name MyTools -RequiredVersion 1.0.1 -Path .\Modules
Import-Module -Name .\Modules\MyTools\1.0.1\MyTools.psd1

Automate quality gates

Build a small but effective pipeline that enforces module hygiene:

  1. Static analysis: Invoke-ScriptAnalyzer to catch style and correctness issues.
  2. Unit tests: Pester tests for function behavior and exported surface.
  3. Manifest validation: Test-ModuleManifest on every build.
  4. Signing (optional but recommended): Code-sign the .psm1 and any scripts for constrained environments.
  5. Version bump + changelog: Automate SemVer increments and release notes.
# Example CI step (PowerShell 7)
$ErrorActionPreference = 'Stop'
Invoke-ScriptAnalyzer -Path . -Recurse -Severity Warning, Error
Invoke-Pester -CI
Test-ModuleManifest -Path .\MyTools\1.0.0\MyTools.psd1 | Out-Null

Real-world tips

  • Prefer manifest imports: Always import via .psd1 to apply exports and metadata.
  • Keep helpers private: Prefix helpers with _ or place them in a Private folder and dot-source inside the .psm1.
  • Don’t export everything: Set *ToExport lists explicitly; avoid '*'.
  • Set clear compatibility: PowerShellVersion and CompatiblePSEditions prevent unsupported loads.
  • Lock dependencies: Use RequiredModules with version bounds to stabilize transitive behavior.
  • Use -Force judiciously: It’s fine in dev/test; avoid in production automation unless you know why you need it.

Putting it together

With a disciplined manifest and explicit exports, your modules load predictably, keep public APIs tight, and support reliable automation. Versioned releases and CI checks ensure your consumers get consistent behavior—no surprises from accidental exports or environment differences.

Strengthen your module hygiene further with deep dives, patterns, and advanced techniques in the PowerShell Advanced CookBook: https://www.amazon.com/PowerShell-Advanced-Cookbook-scripting-advanced-ebook/dp/B0D5CPP2CQ/

What you get: clear exports, versioned releases, easier installs, and predictable loads.

← All Posts Home →