Build a Script Module with .psm1 and Explicit Exports
Refactor related PowerShell functions into a safe script module with module-private state, explicit public exports, comment-based help, structured output, and a repeatable reload workflow.
Learning objectives
- Explain script modules and module-specific scope.
- Create and import a .psm1 module from a disposable workspace.
- Keep helper functions and internal state private to the module.
- Define a deliberate public API with Export-ModuleMember.
- Add help and stable structured output contracts to exported functions.
- Use a safe import/reload/cleanup development workflow.
1. A module starts when copied helper code becomes a maintenance problem
Imagine three scripts that each define the same runtime-report function, platform-label helper, and endpoint test. Fixing a bug means editing three copies and hoping they stay identical. A script module moves that related behavior behind one importable boundary.
A script module is primarily a .psm1 file. Code in that file runs in a module-specific session state. Consumers see only what the module exports; internal helpers and state can remain implementation details.
2. Create a disposable development workspace
The lab uses the user temporary directory, not a system module path. Importing by full path lets you develop safely without “installing” anything.
$workspace = Join-Path ([IO.Path]::GetTempPath()) 'ps-academy-ch15-module'
$moduleRoot = Join-Path $workspace 'DevOpsAcademy.Tools'
$moduleFile = Join-Path $moduleRoot 'DevOpsAcademy.Tools.psm1'
Remove-Item -LiteralPath $workspace -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $moduleRoot -Force | Out-Null
[pscustomobject]@{
Workspace = $workspace
ModuleRoot = $moduleRoot
ModuleFile = $moduleFile
}3. Module scope is private session state for the module
PowerShell modules do not simply run as child scopes of the caller. A module has its own session state and scope hierarchy. Variables and helper functions inside that module are therefore a natural place for implementation state that should not become a global variable.
# This variable will live in module scope, not the caller's global scope.
$script:ModuleLoadedAt = [datetime]::UtcNow
function Resolve-DaPlatformLabel {
if ($IsWindows) { return 'Windows' }
if ($IsLinux) { return 'Linux' }
if ($IsMacOS) { return 'macOS' }
return 'Other'
}The $script: modifier inside a .psm1 refers to the module's script scope. That is very different from $global:, which would leak mutable state into the user's session.
4. Public functions should emit stable objects
Module commands should follow the same output discipline as Chapter 9: return data objects on the Success stream and use diagnostic streams for diagnostics. Do not make consumers parse display text.
function Get-DaRuntimeInfo {
[CmdletBinding()]
param()
[pscustomobject]@{
PSTypeName = 'DevOpsAcademy.RuntimeInfo'
Platform = (Resolve-DaPlatformLabel)
PSVersion = $PSVersionTable.PSVersion.ToString()
PSEdition = $PSVersionTable.PSEdition
ProcessId = $PID
ModuleLoadedAt = $script:ModuleLoadedAt
CheckedUtc = [datetime]::UtcNow
}
}5. A second public command can reuse private helpers
The consumer does not need to know how platform labeling or timeout conversion works. Those details stay internal while the public function exposes a small, documented parameter contract.
function Test-DaTcpEndpoint {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateRange(1,65535)]
[int]$Port,
[string]$HostName = 'localhost',
[ValidateRange(100,10000)]
[int]$TimeoutMs = 1000
)
$client = [System.Net.Sockets.TcpClient]::new()
$watch = [System.Diagnostics.Stopwatch]::StartNew()
try {
$task = $client.ConnectAsync($HostName,$Port)
$connected = $task.Wait($TimeoutMs) -and $client.Connected
[pscustomobject]@{
PSTypeName = 'DevOpsAcademy.EndpointTest'
HostName = $HostName
Port = $Port
Reachable = $connected
DurationMs = $watch.ElapsedMilliseconds
}
}
catch {
[pscustomobject]@{
PSTypeName = 'DevOpsAcademy.EndpointTest'
HostName = $HostName
Port = $Port
Reachable = $false
DurationMs = $watch.ElapsedMilliseconds
}
}
finally {
$client.Dispose()
}
}The lab intentionally uses localhost by default. A failed connection is valid diagnostic data; it does not mean the module itself failed.
7. Explicit exports turn internal names into an API boundary
If a script module contains no Export-ModuleMember, its functions and aliases are exported by default. That is convenient for experiments but risky for team modules: adding a helper function can accidentally become a public API.
Export-ModuleMember -Function @(
'Get-DaRuntimeInfo',
'Test-DaTcpEndpoint'
)Once Export-ModuleMember is present, only the specified members are exported. Resolve-DaPlatformLabel remains available to module code but is not a normal consumer command.
8. Write the complete .psm1 file
The following lab combines private state, a private helper, two public functions, comment-based help, and explicit exports into one reviewable module file.
$moduleSource = @'
$script:ModuleLoadedAt = [datetime]::UtcNow
function Resolve-DaPlatformLabel {
if ($IsWindows) { return 'Windows' }
if ($IsLinux) { return 'Linux' }
if ($IsMacOS) { return 'macOS' }
return 'Other'
}
<#
.SYNOPSIS
Returns PowerShell runtime information.
.OUTPUTS
DevOpsAcademy.RuntimeInfo
#>
function Get-DaRuntimeInfo {
[CmdletBinding()]
param()
[pscustomobject]@{
PSTypeName = 'DevOpsAcademy.RuntimeInfo'
Platform = (Resolve-DaPlatformLabel)
PSVersion = $PSVersionTable.PSVersion.ToString()
PSEdition = $PSVersionTable.PSEdition
ProcessId = $PID
ModuleLoadedAt = $script:ModuleLoadedAt
CheckedUtc = [datetime]::UtcNow
}
}
<#
.SYNOPSIS
Tests whether a TCP connection can be established within a bounded timeout.
.PARAMETER HostName
DNS name or IP address. Defaults to localhost.
.PARAMETER Port
TCP port from 1 through 65535.
.PARAMETER TimeoutMs
Maximum wait in milliseconds.
.OUTPUTS
DevOpsAcademy.EndpointTest
#>
function Test-DaTcpEndpoint {
[CmdletBinding()]
param(
[string]$HostName = 'localhost',
[Parameter(Mandatory)][ValidateRange(1,65535)][int]$Port,
[ValidateRange(100,10000)][int]$TimeoutMs = 1000
)
$client = [System.Net.Sockets.TcpClient]::new()
$watch = [System.Diagnostics.Stopwatch]::StartNew()
try {
$task = $client.ConnectAsync($HostName,$Port)
$connected = $task.Wait($TimeoutMs) -and $client.Connected
[pscustomobject]@{
PSTypeName='DevOpsAcademy.EndpointTest'; HostName=$HostName; Port=$Port
Reachable=$connected; DurationMs=$watch.ElapsedMilliseconds
}
} catch {
[pscustomobject]@{
PSTypeName='DevOpsAcademy.EndpointTest'; HostName=$HostName; Port=$Port
Reachable=$false; DurationMs=$watch.ElapsedMilliseconds
}
} finally { $client.Dispose() }
}
Export-ModuleMember -Function Get-DaRuntimeInfo,Test-DaTcpEndpoint
'@
Set-Content -LiteralPath $moduleFile -Value $moduleSource -Encoding utf89. Import by path and inspect the public surface
Import-Module $moduleFile -Force
Get-Command -Module DevOpsAcademy.Tools |
Select-Object Name,CommandType,Source
Get-DaRuntimeInfo
Test-DaTcpEndpoint -Port 65535 -TimeoutMs 300
# The helper is not part of the public command surface.
Get-Command Resolve-DaPlatformLabel -ErrorAction SilentlyContinueThe endpoint test may report Reachable = False; that is an expected, safe outcome when nothing listens on the chosen local port.
10. Development reloads can hide stale-state bugs
PowerShell caches the loaded module in the session. Editing the .psm1 file does not automatically replace the already-loaded command definitions. During script-module development, Import-Module -Force is a convenient reload mechanism.
Get-Module DevOpsAcademy.Tools |
Select-Object Name,Version,Path
Import-Module $moduleFile -Force
Get-DaRuntimeInfo
# Cleanly remove the module when the development session is finished.
Remove-Module DevOpsAcademy.Tools -ErrorAction SilentlyContinueBe more cautious once a module loads binary assemblies or defines classes: process-level types can remain loaded even after Remove-Module. A fresh pwsh process is the most reliable clean-room test.
11. Split files only when the structure earns its complexity
A single .psm1 is easier to understand at first. Larger modules often split public and private functions into files, then dot-source those reviewed files from the root module. Dot-sourcing is justified here because the module author intentionally assembles one module scope from known source files—not because consumers should dot-source arbitrary modules.
# Conceptual root-module pattern for a larger codebase.
$private = Join-Path $PSScriptRoot 'Private'
$public = Join-Path $PSScriptRoot 'Public'
Get-ChildItem -LiteralPath $private -Filter '*.ps1' -File |
Sort-Object Name |
ForEach-Object { . $_.FullName }
Get-ChildItem -LiteralPath $public -Filter '*.ps1' -File |
Sort-Object Name |
ForEach-Object { . $_.FullName }
Export-ModuleMember -Function 'Get-DaRuntimeInfo','Test-DaTcpEndpoint' Do not recursively dot-source unknown files or use user-controlled paths. Every sourced file executes in the module process with the caller's privileges.
12. Design failures to catch before a module spreads
| Failure | Consequence | Repair |
|---|---|---|
| Export every helper | Internal names become accidental public API. | Explicitly export the supported commands. |
Use $global: for module state | Imports mutate caller state and tests interfere with each other. | Keep implementation state in module scope or pass state explicitly. |
| Write display-only strings | Consumers must parse presentation text. | Emit structured objects; format at the edge. |
| Depend on current directory | Import behavior changes by launch location. | Use $PSScriptRoot for module-relative files. |
| Assume -Force creates a fresh process | Classes/assemblies can remain loaded. | Test clean imports in a new PowerShell process. |
13. Verification checklist
- You created a module in a disposable workspace, not a system module directory.
- You can explain module scope versus caller/global scope.
- Your public commands emit structured objects.
- Private helpers remain usable inside the module but are not exported.
- You can inspect the public API with
Get-Command -Module. - You understand the purpose and limitations of
Import-Module -Forceduring development.
14. Knowledge check
Question 1. What file extension identifies a PowerShell script module?
Question 2. Why use Export-ModuleMember explicitly?
Question 3. Where should hidden mutable module state normally live instead of $global:?
Question 4. Why use $PSScriptRoot for module-relative files?
Question 5. Why can a fresh pwsh process be better than Import-Module -Force for some tests?
15. Summary and next bridge
A .psm1 turns related functions into a reusable command boundary with its own session state. Explicit exports protect the public API, private helpers reduce namespace noise, and structured outputs keep consumers composable. The lab also established a clean development loop that avoids copying files into system locations.
Lesson 3 adds the manifest that gives this module an identity, version, compatibility metadata, dependency declarations, tags, and publication-ready metadata.
16. Authoritative references
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this address.
6. Put help next to the command contract
Comment-based help belongs immediately before the function or at another supported help location. The help should describe behavior that actually exists, not promises the code does not honor.