01

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 0

param() 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.

02

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 = $Value

New or existing — default choice:

$config | Add-Member -NotePropertyName $Key -NotePropertyValue $Value -Force

Check 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 $Path

Without -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.

03

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.

04

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.

05

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.

06

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

FormEffect
throwre-throws the original error with its type
throw "text"new error, cause lost
no throw in catchswallowed, 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.

07

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[]]$Servers

ValidateSet 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.

08

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 -Full

Watch 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

09

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, avoid

None 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      # failure

Last 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.

10

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 5

Outside 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.