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:
- Centralize exports in .psm1 (recommended for iteration): Set FunctionsToExport to "*" or an empty array and let
Export-ModuleMembercontrol the surface. This keeps exports in one place and aligns with the folder-based pattern. - 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-Verbto 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 -Fullis 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 Latestand$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-ModuleMemberto 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.