TB

MoppleIT Tech Blog

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

Clean PowerShell Modules: Public/Private Functions, Dot-Sourcing, and Predictable Exports

When you build a PowerShell module, the difference between a joy to use and an unmaintainable tangle often comes down to your public surface area. Clean modules expose only what users need while keeping internal helpers private. In this guide, you will structure your module with Public and Private folders, dot-source functions in a predictable order, and centralize exports to create a stable, discoverable API that is safe to evolve.

Why split Public and Private functions?

Separating your commands is about establishing a contract. Public commands are your API; private functions are implementation details you can change without breaking consumers.

  • Discoverability: One function per file under Public with filenames matching function names makes commands easy to find and document.
  • Safety: Private helpers can change freely. Public exports define your versioned, supported surface.
  • Predictability: Centralized exports guarantee users only see what you intend.
  • Testability: You can unit test private helpers internally while keeping them hidden from users.
  • Maintenance: Clear boundaries make refactors less risky and reviews faster.

Implementing the pattern

1) Project layout

Create a module folder with matching .psd1 and .psm1, and split functions into Public and Private folders. Keep filenames aligned with function names.

MyModule/
  MyModule.psd1
  MyModule.psm1
  Public/
    Get-Greeting.ps1
  Private/
    ConvertTo-TitleCase.ps1
  tests/
    Public.Tests.ps1
    Private.Tests.ps1

2) Module entry point (dot-source and export)

Dot-source Private first, then Public, and export only Public functions. This ensures helpers are available to Public commands but not exported to users.

# MyModule.psm1
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$here = Split-Path -Parent $PSCommandPath

# Load private helpers first
Get-ChildItem -Path (Join-Path $here 'Private') -Filter '*.ps1' -ErrorAction SilentlyContinue |
  ForEach-Object { . $_.FullName }

# Load public commands
Get-ChildItem -Path (Join-Path $here 'Public') -Filter '*.ps1' -ErrorAction SilentlyContinue |
  ForEach-Object { . $_.FullName }

# Export only public functions (names derived from filenames)
$public = Get-ChildItem -Path (Join-Path $here 'Public') -Filter '*.ps1' | Select-Object -ExpandProperty BaseName
Export-ModuleMember -Function $public -Alias *

Tip: Keep aliases rare and purposeful. If you export aliases, make them point to public functions only.

3) Example public command

Place one advanced function per file. Match filename to function name exactly for straightforward discovery.

# Public/Get-Greeting.ps1
function Get-Greeting {
  [CmdletBinding()] param(
    [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Name,
    [ValidateSet('Formal','Casual')][string]$Style = 'Casual'
  )

  $value = switch ($Style) {
    'Formal' { "Hello, $Name." }
    'Casual' { "Hello, $Name" }
  }

  # Example use of a private helper
  ConvertTo-TitleCase -InputObject $value
}

4) Example private helper

# Private/ConvertTo-TitleCase.ps1
function ConvertTo-TitleCase {
  [CmdletBinding()] param(
    [Parameter(Mandatory)][string]$InputObject
  )

  $Culture = [System.Globalization.CultureInfo]::CurrentCulture
  $TextInfo = $Culture.TextInfo
  return $TextInfo.ToTitleCase($InputObject)
}

5) Module manifest and autoloading

The .psd1 tells PowerShell how to load and discover your module. There are two common export strategies:

  1. Centralize exports in .psm1 (recommended for iteration): Set FunctionsToExport to "*" or an empty array and let Export-ModuleMember control the surface. This keeps exports in one place and aligns with the folder-based pattern.
  2. Lock down exports in the manifest (recommended for large modules with strict autoload discovery): Maintain an explicit list in FunctionsToExport. This can speed up command discovery and is ideal when you have a build step to generate the list from the Public folder.

Example minimal manifest snippet:

@{
  RootModule        = 'MyModule.psm1'
  ModuleVersion     = '1.0.0'
  CompatiblePSEditions = @('Desktop','Core')
  GUID              = '00000000-0000-0000-0000-000000000000'
  Author            = 'You'
  CompanyName       = 'Contoso'
  PowerShellVersion = '5.1'
  Description       = 'Clean, predictable PowerShell module with public/private split.'

  # Option A: centralize exports in .psm1
  FunctionsToExport = '*'
  CmdletsToExport   = @()
  AliasesToExport   = @()

  # Option B (alternative): enforce explicit list generated at build time
  # FunctionsToExport = @('Get-Greeting')
}

Practical approach: in development, use FunctionsToExport = '*'. In CI, generate an explicit list from the Public folder and update the manifest for best autoload behavior.

Packaging, automation, and quality gates

Generate exports in CI

Automate manifest updates from your Public folder so the manifest always mirrors your API surface.

# build.ps1
$moduleRoot = Split-Path -Parent $PSCommandPath
$public = Get-ChildItem -Path (Join-Path $moduleRoot 'Public') -Filter '*.ps1' | Select-Object -ExpandProperty BaseName
$manifest = Join-Path $moduleRoot 'MyModule.psd1'

Update-ModuleManifest -Path $manifest -FunctionsToExport $public -AliasesToExport @()

Unit testing with Pester

Test that only Public functions are exported and that private helpers remain internal.

# tests/Public.Tests.ps1
Import-Module (Join-Path $PSScriptRoot '..' 'MyModule.psd1') -Force
Describe 'Public surface' {
  It 'exports only public functions' {
    $mod = Get-Module MyModule
    $exports = $mod.ExportedFunctions.Keys
    $exports | Should -Contain 'Get-Greeting'
    $exports | Should -Not -Contain 'ConvertTo-TitleCase'
  }
}

Versioning and publishing

Use semantic versioning to communicate changes. Bump patch for bug fixes, minor for additive features, and major for breaking changes. Publish from CI with a token to the PowerShell Gallery after tests pass.

# .github/workflows/ci.yml (excerpt)
name: ci
on:
  push:
    tags:
      - 'v*.*.*'
jobs:
  build:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install PowerShell modules
        run: pwsh -NoLogo -Command "Install-Module Pester -Scope CurrentUser -Force"
      - name: Test
        run: pwsh -NoLogo -Command "Invoke-Pester -CI"
      - name: Generate manifest exports
        run: pwsh -NoLogo -File build.ps1
      - name: Publish
        env:
          NUGET_API_KEY: ${{ secrets.PSGALLERY_API_KEY }}
        run: pwsh -NoLogo -Command "Publish-Module -Path . -NuGetApiKey $env:NUGET_API_KEY -Verbose"

Practical tips and pitfalls

  • One function per file: Match filenames to function names exactly (case-insensitive). This simplifies search, code reviews, and export generation.
  • Prefer approved verbs: Use Get-Verb to choose standard verbs (e.g., Get, Set, New) for consistent UX.
  • Use advanced functions: Add [CmdletBinding()], parameter attributes, validation, and support for pipeline input where appropriate.
  • Comment-based help: Put help in each public function file so Get-Help Get-Greeting -Full is useful from day one.
  • Avoid wildcard exports at runtime: Explicit exports via filenames avoid surprising users and help with breaking-change detection.
  • Strict mode and failures early: Set-StrictMode -Version Latest and $ErrorActionPreference = 'Stop' in your .psm1 catch mistakes during load.
  • Don’t leak internals: Keep helper aliases and functions in Private; never export them. Use Export-ModuleMember to whitelist only Public files.
  • Avoid global state: Scope variables locally within functions; prefer parameters and return values for testable, reusable code.
  • Performance: Keep module import time snappy. Do not perform heavy work at import; defer to first command execution.
  • Security: Sign your module, avoid dot-sourcing untrusted paths, and pin external dependencies with checksums if you must fetch during build.

Putting it all together

With a clean folder split, dot-sourcing in your .psm1, and centralized exports, you get clear APIs, easier discovery, safer changes, and better reuse. Users see a predictable set of commands, while you retain freedom to refactor internals. Wire this into a lightweight CI that generates manifest exports, runs Pester, and publishes on tags, and you will have a professional-grade module that scales with your codebase and your team.

Build maintainable modules for PowerShell and keep leveling up your scripting craft. Further reading: PowerShell Advanced Cookbook.

← All Posts Home →