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]$Coresis required, that 19s enforced by the runtime. - Early validation: Fail fast in the constructor so bad inputs don 19t propagate.
- Predictable output: Pipelines stay object-first; you control display via
ToString()when you want text. - Discoverability:
Get-Memberand 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 22valid 22 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)
}
}
That 19s 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
Don 19t 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
- Don 19t
Format-Tablemid-pipeline: Formatting cmdlets output text, which breaks object-first flows. Format at the very end or insideToString()for logs. - Limit JSON depth: Use
-Depthonly as high as necessary to avoid oversized artifacts. - Project intentionally: Before serialization,
Select-Objectonly the properties you want to share to prevent accidental data leakage. - Avoid ambiguous nulls: Normalize unknown values (e.g., set
$OSto"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 you 19re inventorying thousands of nodes, parallelize (e.g.,
ForEach-Object -Parallelin 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, you 19ll ship automation that 19s safer, easier to test, and more predictable in production. Start small with a single class and let object-first pipelines do the heavy lifting.