Chapter 15Lesson 02~190 minutes

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.

Script modules.psm1ExportsDesign

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.

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.

<#
.SYNOPSIS
Returns PowerShell runtime information as a structured object.
.DESCRIPTION
Reports platform, PowerShell edition/version, process identity, and UTC timestamps.
.EXAMPLE
Get-DaRuntimeInfo
.OUTPUTS
DevOpsAcademy.RuntimeInfo
#>
function Get-DaRuntimeInfo {
    [CmdletBinding()]
    param()
    # implementation...
}

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 utf8

9. 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 SilentlyContinue

The 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 SilentlyContinue

Be 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

FailureConsequenceRepair
Export every helperInternal names become accidental public API.Explicitly export the supported commands.
Use $global: for module stateImports mutate caller state and tests interfere with each other.Keep implementation state in module scope or pass state explicitly.
Write display-only stringsConsumers must parse presentation text.Emit structured objects; format at the edge.
Depend on current directoryImport behavior changes by launch location.Use $PSScriptRoot for module-relative files.
Assume -Force creates a fresh processClasses/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 -Force during 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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.