Function skeleton
Standard
function Update-Config {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]$Path,
[Parameter(Mandatory)]
[string]$Key,
[Parameter(Mandatory)]
$Value,
[ValidateRange(1, 300)]
[int]$TimeoutSec = 30,
[int]$RetryMs = 500
)
<setup>
try { <work>; <result> }
catch { <cleanup>; throw }
finally { <release> }
}Script instead of a function
param(
[Parameter(Mandatory)][string]$Path,
[int]$TimeoutSec = 30
)
$ErrorActionPreference = 'Stop'
<work>
exit 0param() must be the first statement in the file.
Pipeline input
function Get-Large {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[int]$Number,
[int]$Threshold = 5
)
begin { $count = 0 }
process { if ($Number -gt $Threshold) { $count++; $Number } }
end { Write-Verbose "passed: $count" }
}Without process the function only sees the last item.
Verb-Noun, noun singular. Verb list: Get-Verb.
Define the function above the line that calls it.
Reading and writing JSON
Read
$config = Get-Content $Path -Raw | ConvertFrom-Json-Raw is optional in PS 7, required in 5.1.
With an existence check:
if (-not (Test-Path $Path)) { throw "File does not exist: $Path" }As a hashtable, when keys are arbitrary (PS 6+):
$config = Get-Content $Path -Raw | ConvertFrom-Json -AsHashtable
$config["newKey"] = "value"Change a field
Existing field only:
$config.$Key = $ValueNew or existing — default choice:
$config | Add-Member -NotePropertyName $Key -NotePropertyValue $Value -ForceCheck whether a field exists:
if ($null -eq $config.PSObject.Properties[$Key]) { <missing> }Nested field:
$config.app.db.host = "new-host"Write
$config | ConvertTo-Json -Depth 20 | Set-Content $PathWithout -Depth anything below 2 levels becomes text like "@{host=localhost}".
Without a BOM, when a non-PowerShell tool reads the file:
[System.IO.File]::WriteAllText($Path, $json, [System.Text.UTF8Encoding]::new($false)).NET methods need a full path.
After Get-Content it is always ConvertFrom-Json.
\n and \" in the output mean you used ConvertTo-Json on text.
Set-Content overwrites the whole file.
Writing without risk of corruption
Writing content can be interrupted halfway. Renaming a file cannot.
So: write next to the target, then swap.
Paths
$full = (Resolve-Path $Path).Path
$tmp = "$full.$PID.tmp"
$bak = "$full.bak"The temporary file must sit in the same directory. The swap is only atomic within one filesystem, and /tmp is often separate.
With a backup
$config | ConvertTo-Json -Depth 20 | Set-Content $tmp
[System.IO.File]::Replace($tmp, $full, $bak, $true)Throws when the target does not exist. Needs full paths.
Without a backup
$config | ConvertTo-Json -Depth 20 | Set-Content $tmp
Move-Item -Path $tmp -Destination $full -Force-Force is required. Works with relative paths.
Target may not exist
if (Test-Path $full) {
[System.IO.File]::Replace($tmp, $full, $bak, $true)
} else {
Move-Item -Path $tmp -Destination $full
}Cleanup
catch {
Remove-Item $tmp -ErrorAction SilentlyContinue
throw
}Out loud: Git, Terraform and SQLite do the same. It is the only mechanism the OS gives.
Locking between processes
Lock file — default choice
$lockPath = "$full.lock"
$lock = [System.IO.File]::Open($lockPath, 'OpenOrCreate', 'ReadWrite', 'None')
try { <work> }
finally { $lock.Dispose() }'None' means nobody else may open it while you hold the handle.
Visible in the directory. Works over a network share.
Named mutex
$mutex = New-Object System.Threading.Mutex($false, "Global\MyLockName")
if (-not $mutex.WaitOne([TimeSpan]::FromSeconds($TimeoutSec))) {
throw "Could not acquire the lock within $TimeoutSec s"
}
try { <work> }
finally { $mutex.ReleaseMutex(); $mutex.Dispose() }WaitOne waits on its own — no retry loop needed.
Local to one machine. Reentrant inside one process, so a single-script test gives a false positive.
Do not
Lock the data file itself — it would block your own read, and a file swap bypasses the lock anyway.
Delete the .lock afterwards — the lock is the open handle, not the file. Deleting creates a race where two processes hold two different files with the same name.
Out loud: a lock is an agreement between processes, not physical protection. A process that ignores it will overwrite the file regardless.
Retrying
Fixed interval with a deadline — default choice
$deadline = (Get-Date).AddSeconds($TimeoutSec)
$lock = $null
while (-not $lock) {
try {
$lock = [System.IO.File]::Open($lockPath, 'OpenOrCreate', 'ReadWrite', 'None')
}
catch [System.IO.IOException] {
if ((Get-Date) -ge $deadline) { throw "Could not acquire the lock: $lockPath" }
Start-Sleep -Milliseconds $RetryMs
}
}The loop condition watches the goal, not an attempt counter.
The deadline check goes before the sleep.
Exponential backoff with jitter
For network calls, or when many clients retry at once.
$maxAttempts = 6
$baseMs = 200
for ($i = 1; $i -le $maxAttempts; $i++) {
try { <operation>; break }
catch {
if ($i -eq $maxAttempts) { throw }
$delay = [math]::Min($baseMs * [math]::Pow(2, $i - 1), 5000)
$jitter = Get-Random -Minimum 0 -Maximum 100
Start-Sleep -Milliseconds ($delay + $jitter)
}
}Gives 200, 400, 800, 1600, 3200, 5000 ms. Jitter stops everyone hitting at the same instant.
Via mutex — no loop
if (-not $mutex.WaitOne([TimeSpan]::FromSeconds($TimeoutSec))) { throw "..." }Typed catch
catch [System.IO.IOException] fences off errors outside the I/O family, e.g. a permission error, which would otherwise burn the whole timeout.
It does not protect against a wrong path — DirectoryNotFoundException inherits from IOException.
Out loud: a busy resource is a normal state under concurrency, not a failure.
Error handling
Full set
try {
<work>
}
catch {
Remove-Item $tmp -ErrorAction SilentlyContinue
throw
}
finally {
$lock.Dispose()
}Typed catch
try { <work> }
catch [System.IO.FileNotFoundException] { <file missing> }
catch [System.UnauthorizedAccessException] { <no permission> }
catch { throw }First match wins — most specific first.
Own message, cause kept
catch { throw "Failed to update $Path : $($_.Exception.Message)" }throw
| Form | Effect |
|---|---|
throw | re-throws the original error with its type |
throw "text" | new error, cause lost |
no throw in catch | swallowed, caller assumes success |
-ErrorAction Stop
Most commands only print on error and carry on, so catch never fires.
Get-Item $Path -ErrorAction Stop
$ErrorActionPreference = 'Stop' # whole script.NET methods always throw — no -ErrorAction there.
Order
On error: catch, then finally.
With throw in catch: finally runs before the error leaves.
Nested functions: finally fires at every level, innermost first.
Also runs when try exits through return.
Input validation
Attributes go on the line above the parameter. Several may be combined.
[ValidateSet("dev","test","staging","prod")]
[string]$Environment
[ValidateSet("prod","dev", IgnoreCase = $false)]
[string]$Exact
[ValidateRange(1, 300)]
[int]$TimeoutSec
[ValidateNotNullOrEmpty()]
[string]$Path
[ValidatePattern('^\d+\.\d+\.\d+$')]
[string]$Version
[ValidateScript({ Test-Path $_ })]
[string]$ExistingFile
[ValidateLength(3, 50)]
[string]$Name
[ValidateCount(1, 10)]
[string[]]$ServersValidateSet lists the allowed values in the error message and gives Tab completion. Case is ignored by default.
ValidateScript gets the value as $_ and must return true or false.
Manual, in the body
When the condition spans several parameters or system state.
if (-not (Test-Path $Path)) { throw "File does not exist: $Path" }Out loud: attribute validation runs before the body, so with a bad value not one line of the function executes.
ValidateSet on a config key removes the ability to add new keys — a deliberate trade-off between a closed list and extensibility.
Function documentation
<#
.SYNOPSIS
<one sentence>
.DESCRIPTION
<longer description, design decisions>
.PARAMETER Path
<description>
.EXAMPLE
Update-Config -Path "settings.json" -Key "version" -Value "2.0.0"
<what it does>
.OUTPUTS
<what it returns>
.NOTES
<limitations, deliberate trade-offs>
#>
function Update-Config {Minimal version: .SYNOPSIS and one .EXAMPLE.
Reading
Get-Help Update-Config
Get-Help Update-Config -Examples
Get-Help Update-Config -Parameter Key
Get-Help Update-Config -FullWatch out
Section names are fixed — an invented one is ignored.
The dot must start the line.
At most one blank line between #> and function. A comment or two blank lines break the association.
Load the file with a leading dot so Get-Help sees the function: . ./file.ps1
Output channels and exit codes
Write-Verbose "<progress>" # only with -Verbose
Write-Warning "<suspect>" # always
Write-Error "<error>" # always, does not stop execution
Write-Host "<text>" # not capturable, avoidNone of them reaches the function's result. The result is only what you did not assign to a variable.
Write-Verbose needs the function to be advanced — [CmdletBinding()] or at least one [Parameter()] attribute.
Write-Verbose $Variable throws on an empty variable. Use Write-Verbose "Label: $Variable".
Exit codes
exit 0 # success
exit 1 # failureLast command's code: $LASTEXITCODE
CI script
param([Parameter(Mandatory)][string]$Path)
$ErrorActionPreference = 'Stop'
try {
<work>
exit 0
}
catch {
Write-Error $_.Exception.Message
exit 1
}Out loud: the exit code is the only thing a CI pipeline actually reads.
Operations on many items
Collect results — default choice
$results = foreach ($item in $items) {
try {
<operation on $item>
[PSCustomObject]@{ Item = $item; Status = "OK"; Error = $null }
}
catch {
[PSCustomObject]@{ Item = $item; Status = "FAILED"; Error = $_.Exception.Message }
}
}
$failed = $results | Where-Object Status -eq "FAILED"
if ($failed) { $failed | Format-Table; exit 1 }Stop on first error
$ErrorActionPreference = 'Stop'
foreach ($item in $items) { <operation on $item> }Parallel (PS 7+)
$results = $items | ForEach-Object -Parallel {
<operation on $_>
} -ThrottleLimit 5Outside variables are not visible inside — use $using:name.
Result order is not preserved.
Out loud: I collect results rather than stopping, because "8 of 10 succeeded and here is which failed" is more useful than halting on the third item. The choice depends on whether the items are independent.