TB

MoppleIT Tech Blog

Welcome to my personal blog where I share thoughts, ideas, and experiences.

Practical PowerShell Classes for Clean, Testable Data Models

PowerShell classes let you model data with clear types, predictable behavior, and built-in validation—all without reaching for an external library. When you give your data a real home (a class) instead of loose hashtables or ad-hoc PSCustomObjects, you gain safer inputs, easier testing, and logs that actually tell you what happened. In this post, you’ll build practical class patterns you can reuse in scripts, modules, and CI pipelines: constructors that validate, static parsers that isolate input handling, and a friendly ToString() for readable logging.

Why model data with PowerShell classes?

Classes are worth the few extra lines because they give you:

  • Predictability: Properties have explicit types, so you catch bad data early.
  • Validation: Constructors enforce invariants—no more half-formed objects.
  • Encapsulation: Parsing and formatting live with the type instead of scattered across scripts.
  • Better logs: A custom ToString() prints exactly what you need.
  • Testability: Unit tests target behavior, not string parsing or fragile pipelines.

What you get: clearer models, safer inputs, easier testing, cleaner logs.

A foundational example: a Server model with validation, parsing, and logging

Start with a Server class. You’ll validate input in the constructor, keep parsing in a static method, and override ToString() for clean logs.

class Server {
  [string]$Name
  [string]$Env
  [int]$Port

  Server([string]$name, [string]$env, [int]$port) {
    if (-not $name) { throw [ArgumentException]::new('Name is required') }
    if ($env -notin @('Dev','Test','Prod')) { throw [ArgumentException]::new('Env must be Dev, Test, or Prod') }
    if ($port -lt 1 -or $port -gt 65535) { throw [ArgumentOutOfRangeException]::new('Port out of range') }
    $this.Name = $name
    $this.Env  = $env
    $this.Port = $port
  }

  static [Server] Parse([string]$s) {
    # Format: name;env;port
    $parts = $s -split ';'
    if ($parts.Count -ne 3) { throw 'Bad format. Use name;env;port' }
    return [Server]::new($parts[0], $parts[1], [int]$parts[2])
  }

  [string] ToString() {
    return ('{0}:{1} ({2})' -f $this.Name, $this.Port, $this.Env)
  }
}

# Usage
$raw = @('web01;Prod;443','api01;Test;8080')
$servers = $raw | ForEach-Object { [Server]::Parse($_) }
$servers | ForEach-Object { Write-Host $_.ToString() }

This small pattern pays off immediately:

  • Constructor validation: Reject bad data early and loudly.
  • Static parsing: Centralize string-to-object conversion (don’t leak parsing everywhere).
  • Readable logs: ToString() yields compact, meaningful output.

Make parsing safe with TryParse

When you want to collect errors without throwing (e.g., user input or batch ETL), a TryParse companion helps:

class ServerParser {
  static [bool] TryParse([string]$s, [ref]$server, [ref]$error) {
    try {
      $server.Value = [Server]::Parse($s)
      $error.Value = $null
      return $true
    } catch {
      $server.Value = $null
      $error.Value = $_.Exception.Message
      return $false
    }
  }
}

# Usage
$ok = [ServerParser]::TryParse('web02;Dev;80', [ref]$server, [ref]$err)
if ($ok) { Write-Host $server.Value } else { Write-Warning $err.Value }

You now have both strict (Parse) and tolerant (TryParse) entry points.

Stronger types with enums

Strings like Env are easy to mistype. An enum turns those into a real type the compiler can check.

enum DeploymentEnvironment { Dev; Test; Prod }

class ServerV2 {
  [string]$Name
  [DeploymentEnvironment]$Env
  [int]$Port

  ServerV2([string]$name, [DeploymentEnvironment]$env, [int]$port) {
    if (-not $name) { throw [ArgumentException]::new('Name is required') }
    if ($port -lt 1 -or $port -gt 65535) { throw [ArgumentOutOfRangeException]::new('Port out of range') }
    $this.Name = $name
    $this.Env  = $env
    $this.Port = $port
  }

  static [ServerV2] Parse([string]$s) {
    $parts = $s -split ';'
    if ($parts.Count -ne 3) { throw 'Bad format. Use name;env;port' }
    # Casting to enum throws if invalid
    $env = [DeploymentEnvironment]$parts[1]
    return [ServerV2]::new($parts[0], $env, [int]$parts[2])
  }

  [string] ToString() { '{0}:{1} ({2})' -f $this.Name, $this.Port, $this.Env }
}

With DeploymentEnvironment, PowerShell prevents invalid values at construction time and intellisense helps discover valid options.

Patterns for bigger scripts and modules

1) Keep parsing and formatting on the type

Centralize input/output on the class so every place that needs an object gets the same guarantees:

  • Parse: From delimited text, JSON, CSV, or API payloads into a validated object.
  • TryParse: Non-throwing ingestion for batch processing.
  • ToString: Concise, log-friendly output for humans.
  • ToJson/FromJson: Round-trip wire formats in one place.
class ServerSerializer {
  static [Server] FromJson([string]$json) {
    $o = $json | ConvertFrom-Json
    return [Server]::new($o.Name, $o.Env, [int]$o.Port)
  }
}

class ServerExtensions {
  static [string] ToJson([Server]$server) {
    return ($server | Select-Object Name, Env, Port | ConvertTo-Json -Compress)
  }
}

2) Prefer immutability for identity fields

For properties that define identity (like an ID or hostname), make them effectively read-only by using a hidden backing field and exposing only a getter.

class Project {
  hidden [string] $_id
  [string] Id { get { return $this._id } }
  [string]$Name

  Project([string]$id, [string]$name) {
    if ($id -notmatch '^[A-Z]{3}-\d{4}$') { throw [ArgumentException]::new('Id must look like ABC-1234') }
    if (-not $name) { throw [ArgumentException]::new('Name is required') }
    $this._id = $id
    $this.Name = $name
  }
}

Now you can’t accidentally reassign Id after construction.

3) Model value objects for tricky primitives

Wrap “dangerous” primitives like ports, emails, or resource names as small types that validate once and stay valid.

class PortNumber {
  [int]$Value
  PortNumber([int]$value) {
    if ($value -lt 1 -or $value -gt 65535) { throw [ArgumentOutOfRangeException]::new('Port out of range') }
    $this.Value = $value
  }
  [string] ToString() { return [string]$this.Value }
}

class ServerV3 {
  [string]$Name
  [DeploymentEnvironment]$Env
  [PortNumber]$Port

  ServerV3([string]$name, [DeploymentEnvironment]$env, [PortNumber]$port) {
    if (-not $name) { throw [ArgumentException]::new('Name is required') }
    $this.Name = $name
    $this.Env  = $env
    $this.Port = $port
  }

  static [ServerV3] Parse([string]$s) {
    $parts = $s -split ';'
    if ($parts.Count -ne 3) { throw 'Bad format. Use name;env;port' }
    return [ServerV3]::new($parts[0], [DeploymentEnvironment]$parts[1], [PortNumber]::new([int]$parts[2]))
  }
}

This enforces correct ports everywhere the object travels.

Testing your models with Pester and using them in CI

Because classes concentrate behavior, tests become short and focused.

Pester unit tests

# Tests/Server.Tests.ps1
Import-Module Pester -MinimumVersion 5.0

Describe 'Server' {
  It 'constructs with valid input' {
    $s = [Server]::new('web01','Prod',443)
    $s.Name | Should -Be 'web01'
    $s.Env  | Should -Be 'Prod'
    $s.Port | Should -Be 443
  }

  It 'rejects invalid env' {
    { [Server]::new('web01','Stage',443) } | Should -Throw
  }

  It 'parses delimited input' {
    $s = [Server]::Parse('api01;Test;8080')
    $s.ToString() | Should -Be 'api01:8080 (Test)'
  }
}

GitHub Actions: run tests on every push

name: pwsh-ci
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install Pester
        run: pwsh -Command 'Install-Module Pester -Scope CurrentUser -Force -SkipPublisherCheck'
      - name: Run tests
        run: pwsh -Command 'Invoke-Pester -CI'

Now your data models are enforced automatically in CI, not just on your machine.

Practical tips and performance/security notes

  • Throw early: Constructors should enforce invariants; avoid silently fixing bad input.
  • Separate I/O from models: Keep HTTP, file reads, and prompts out of classes; inject already-parsed data.
  • Be explicit with types: Prefer enums and value objects over raw strings/ints for meaningful constraints.
  • Use ToString() thoughtfully: Include identifiers and key fields; skip secrets.
  • Serialize carefully: Implement ToJson/FromJson where needed, omitting tokens or credentials.
  • Measure parsing hot paths: If parsing millions of lines, avoid regex backtracking and pre-split once.
  • Version your models: When formats change, add ParseV2 or a migration method to keep old jobs working.

Where to use these patterns

  • DevOps workflows: Model servers, environments, deployments, and change requests as classes to keep pipelines predictable.
  • Configuration management: Parse CSV/JSON/INI inputs into typed objects before applying changes.
  • Automation scripts: Keep business rules in constructors and methods; let the script just orchestrate.
  • Integration boundaries: Normalize third-party APIs into your own models for consistent downstream logic.

PowerShell classes help you build stronger abstractions with minimal ceremony. Add validation in constructors, keep parsing in static methods that return clearly-typed objects, and override ToString() so your logs tell the truth at a glance. The result is code that’s easier to read, safer to run, and simpler to test.

← All Posts Home →