TB

MoppleIT Tech Blog

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

Robust Data Models with PowerShell Classes: Typed Objects, Early Validation, and Object‑First Pipelines

You get more predictable automation when you model your data with PowerShell classes instead of loose hashtables. Typed objects give you contracts, make logs clearer, help tests fail early, and keep your pipelines object-first. In this post, youll build a small but robust data model, add lightweight validation, keep display logic inside the class, and only serialize when you truly need text.

Why Classes Beat Loose Hashes in Automation

Hashtable-driven scripts often drift into stringly-typed chaos. Classes restore order by putting a contract around your data and its behavior.

  • Clear contracts: Types communicate intent. If [int]$Cores is required, that19s enforced by the runtime.
  • Early validation: Fail fast in the constructor so bad inputs don19t propagate.
  • Predictable output: Pipelines stay object-first; you control display via ToString() when you want text.
  • Discoverability: Get-Member and tab completion reveal properties and methods.
  • Easier testing: Classes fit naturally with Pester to verify behavior and edge cases.
  • Safer serialization: You choose when and how to turn objects into JSON/CSV for logs, APIs, or artifacts.

Build a Robust Model

Define a type with validation and a friendly display

Start with a class that represents server inventory. Keep it small, typed, and opinionated about what 22valid22 means. Put quick formatting in ToString() so logs and interactive output look good while pipelines remain object-first.

class ServerInfo {
  [string]$Name
  [string]$OS
  [int]$Cores
  [double]$FreeGB

  ServerInfo([string]$name, [string]$os, [int]$cores, [double]$freeGB) {
    if ([string]::IsNullOrWhiteSpace($name)) { throw "Name required." }
    if ($cores -le 0) { throw "Cores must be > 0." }
    $this.Name   = $name
    $this.OS     = $os
    $this.Cores  = $cores
    $this.FreeGB = [math]::Round($freeGB, 2)
  }

  [string] ToString() {
    return ("{0} | {1} | {2} cores | {3} GB free" -f $this.Name, $this.OS, $this.Cores, $this.FreeGB)
  }
}

That19s enough for most day-to-day automation: strict inputs, rounded storage for numbers, and a deterministic string for human-friendly logs.

Gather data and emit typed objects

Create instances from real system data. Keep computation and transformation outside the class; keep validation and display inside.

$os   = Get-CimInstance Win32_OperatingSystem
$cpu  = (Get-CimInstance Win32_Processor | Measure-Object NumberOfLogicalProcessors -Sum).Sum
$disk = [math]::Round((Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'").FreeSpace / 1GB, 2)

[ServerInfo]::new($env:COMPUTERNAME, $os.Caption, [int]$cpu, [double]$disk)

Because the object is strongly typed, downstream consumers know exactly what to expect, and your script fails early if inputs are wrong.

Fail fast with small, targeted checks

Don19t wait for a late-stage function to discover bad data. Validate close to the source. Let the constructor enforce invariants; use try/catch to guide control flow.

try {
  $info = [ServerInfo]::new($name, $os.Caption, [int]$cpu, [double]$disk)
}
catch {
  Write-Error "Invalid ServerInfo for $name: $_"
  return
}

Use Your Model in Pipelines, CI/CD, and APIs

Keep pipelines object-first and serialize at the edges

Operate on objects in the middle of the pipeline. Only convert to text (CSV/JSON) right before storage, network hops, or human display. This keeps logic robust and avoids brittle string parsing.

$servers = @("srv01", "srv02", "srv03")
$infos = foreach ($name in $servers) {
  try {
    $os   = Get-CimInstance -ComputerName $name -ClassName Win32_OperatingSystem
    $cpu  = (Get-CimInstance -ComputerName $name -ClassName Win32_Processor | Measure-Object NumberOfLogicalProcessors -Sum).Sum
    $disk = [math]::Round((Get-CimInstance -ComputerName $name -ClassName Win32_LogicalDisk -Filter "DeviceID='C:'").FreeSpace / 1GB, 2)
    [ServerInfo]::new($name, $os.Caption, [int]$cpu, [double]$disk)
  }
  catch {
    Write-Warning "Skipping $name due to error: $_"
  }
}

# Filter as objects
$lowSpace = $infos | Where-Object FreeGB -lt 10

# Log in a friendly, deterministic way without breaking the pipeline contract
$lowSpace | ForEach-Object { Write-Information $_.ToString() }

# Serialize only when you need to persist or ship the data
$infos | ConvertTo-Json -Depth 3 | Set-Content -Path "artifacts\\inventory.json"

Notice how ToString() is used for logs, but not for data processing. Your pipeline remains strongly typed until you intentionally serialize.

Round-trip JSON safely when needed

You can rehydrate plain objects back into typed instances to regain your invariants. This is handy in CI steps that restore an artifact for further analysis.

$raw = Get-Content -Path "artifacts\\inventory.json" -Raw | ConvertFrom-Json
$typed = foreach ($o in $raw) {
  try { [ServerInfo]::new($o.Name, $o.OS, [int]$o.Cores, [double]$o.FreeGB) }
  catch { Write-Error "Corrupt record for $($o.Name): $_" }
}

Integrate with CI/CD and APIs

Typed models make automation steps predictable across GitHub Actions, Azure DevOps, or Jenkins. You can post JSON to an API or publish artifacts with confidence.

# GitHub Actions job step (PowerShell)
$infos | ConvertTo-Json -Depth 3 | Set-Content -Path "$env:GITHUB_WORKSPACE\\artifacts\\inventory.json"

# Send to an internal API (object-first until the edge)
$body = $infos | ConvertTo-Json -Depth 3
Invoke-RestMethod -Uri "https://inventory.internal/api/servers" -Method Post -ContentType "application/json" -Body $body

Test behavior with Pester

Because the business rules live inside the class, tests become focused and stable. Validate constructor guards and formatting without wiring up the whole script.

Describe "ServerInfo" {
  It "throws when name is missing" {
    { [ServerInfo]::new("", "Windows 11", 4, 10.0) } | Should -Throw
  }
  It "throws when cores are non-positive" {
    { [ServerInfo]::new("srv01", "Windows 2022", 0, 10.0) } | Should -Throw
  }
  It "rounds FreeGB to 2 decimals" {
    ([ServerInfo]::new("srv01", "Windows 2022", 8, 1.239)).FreeGB | Should -Be 1.24
  }
  It "has a readable ToString" {
    ([ServerInfo]::new("srv01", "Windows 2022", 8, 12.3)).ToString() | Should -Match "srv01.*8 cores"
  }
}

Performance and security tips

  • Don19t Format-Table mid-pipeline: Formatting cmdlets output text, which breaks object-first flows. Format at the very end or inside ToString() for logs.
  • Limit JSON depth: Use -Depth only as high as necessary to avoid oversized artifacts.
  • Project intentionally: Before serialization, Select-Object only the properties you want to share to prevent accidental data leakage.
  • Avoid ambiguous nulls: Normalize unknown values (e.g., set $OS to "Unknown") before instantiation so your contract stays clear.
  • Prefer least privilege: Query CIM with accounts scoped to read-only inventory needs; avoid embedding credentials in serialized outputs.
  • Cache expensive calls: If you19re inventorying thousands of nodes, parallelize (e.g., ForEach-Object -Parallel in PowerShell 7) and avoid redundant CIM queries.

Evolve the model safely

When requirements change, add new properties with sensible defaults in your constructor. Consider adding factory methods (e.g., [ServerInfo]::FromCim($computerName)) to keep creation logic consistent across scripts, while tests cover both the factory and the constructor.

By returning typed objects, enforcing small validations, keeping display logic inside the class, and serializing only at the edges, you19ll ship automation that19s safer, easier to test, and more predictable in production. Start small with a single class and let object-first pipelines do the heavy lifting.

← All Posts Home →