Throughout this series we’ve gradually evolved from simple scripts to object-oriented automation.

So far we’ve learned:

  • Why classes can improve maintainability
  • How to build practical classes
  • When inheritance helps and when it hurts

At this point many PowerShell professionals reach an interesting stage. They no longer have a script. They have multiple classes. And that’s where a new challenge appears.

How do you organise those classes into something reusable?

Because classes alone do not magically create architecture. Without structure, you simply end up replacing a collection of functions with a collection of classes. The result can become just as difficult to maintain. In this article we’ll look at how to organise PowerShell classes into lightweight automation frameworks that remain understandable, maintainable and practical.

When Scripts Become Products

Many automation solutions begin life like a single script. Over time requirements grow and new features are added. The script expands and soon it looks something like:

Classes
Functions
Configuration
Reporting
Logging
Notification
Monitoring

At this point you’re no longer maintaining a script. You’re maintaining a product. The sooner you recognise this transition, the easier future development becomes.

The Single Responsibility Principle

Before building a framework, we need one foundational concept. A class should have one reason to change. A bad example is:

class Server {

    [void] GetHealth() {}

    [void] SaveReport() {}

    [void] SendEmail() {}

    [void] WriteLog() {}

    [void] ExportToExcel() {}
}

This class is trying to do everything. It represents a server, a reporting engine, a logging framework and an email client. This is not how we want this to be.

Breaking Responsibilities Apart

A healthier design might look like:

Server
HealthReporter
Logger
MailService
Configuration

Each class owns a single responsibility and each component becomes independently understandable.

Example:

class Server {

    [string]$Name
    [bool]$Online

}

class Logger {

    [void] WriteInfo([string]$Message) {

        Write-Host $Message
    }
}

class MailService {

    [void] SendNotification([string]$Message) {

        # email logic
    }
}

Service Classes

One useful pattern is the concept of service classes. A service class performs actions.

class ServerDiscoveryService {

    [Server[]] GetServers() {

        # discover servers

        return @()
    }
}

Or:

class HealthCheckService {

    [HealthResult] TestServer(
        [Server]$Server
    ) {

        # perform checks
    }
}

Notice something important, the Server object represents the data and the service performs the work. This separation often simplifies designs dramatically.

Modelling the Domain

Many PowerShell automation projects become easier when split into two categories:

Domain Objects (things)

Server
User
Certificate
Application
VirtualMachine

Services (actions)

MonitoringService
DiscoveryService
NotificationService
ReportingService

This distinction keeps responsibilities clear. When reviewing code, it becomes obvious where functionality belongs.

Creating a Health Monitoring Framework

Let’s build a simple example.

Imagine we want to create a monitoring solution. Our structure might look like:

Server
HealthResult
HealthCheckService
ReportService
Logger

The Server Object

class Server {

    [string]$Name

    Server([string]$Name) {

        $this.Name = $Name
    }
}

This is simple. We have no monitoring logic and no reporting logic. It’s just a representation of a server.

The Result Object

class HealthResult {

    [string]$ServerName

    [bool]$Online

    [datetime]$Timestamp
}

Again, simple. Every health check generates a result. Its purpose is to hold information, nothing more.

The Health Check Service

As we talked about, the monitoring logic lives elsewhere. In that way we now have the responsibilities properly separated.

class HealthCheckService {

    [HealthResult] TestServer(
        [Server]$Server
    ) {

        $result = [HealthResult]::new()

        $result.ServerName = $Server.Name
        $result.Online = Test-Connection -ComputerName $Server.Name -Count 1 -Quiet
        $result.Timestamp = Get-Date

        return $result
    }
}

The Reporting Service

A reporting service might look like:

class ReportService {

    [void] ExportResults(
        [HealthResult[]]$Results
    ) {

        $Results | Export-Csv -Path '.\health.csv' -NoTypeInformation
    }
}

Notice that it doesn’t know how health checks work. It only knows how to process results. This is excellent separation of concerns.

Dependency Management

As frameworks grow, classes often need to collaborate.

For example:

HealthCheckService
        │
        ├── Logger
        │
        └── Configuration

A common beginner mistake is creating objects inside other objects.

Example:

class HealthCheckService {

    [void] Run() {

        $logger = [logger]::new()

        $logger.WriteInfo("Starting")
    }
}

This creates unnecessary coupling. A better approach is supplying the dependency from outside.

Constructor Injection

Instead of:

class HealthCheckService {

    [Logger]$Logger

    HealthCheckService() {

        $this.Logger = [logger]::new()
    }
}

Prefer:

class HealthCheckService {

    [Logger]$Logger

    HealthCheckService(
        [Logger]$Logger
    ) {

        $this.Logger = $Logger
    }
}

Usage:

$logger = [logger]::new()

$service = [HealthCheckService]::new($logger)

The service no longer decides which logger it uses. That decision belongs to the caller.

Handling Configuration

Configuration is another common area where scripts become messy. Many PowerShell solutions may contain the following code in dozens of functions:

$json = Get-Content settings.json

Instead, create a configuration object.

class MonitoringConfiguration {

    [string]$ReportPath

    [string]$MailRecipient

    [int]$TimeoutSeconds
}

Load once and use everywhere. This reduces complexity and prevents configuration drift.

Organising Code into Modules

At some point the framework no longer fits comfortably into one script. Instead if we make a module of it, it will scale surprisingly well. A new team member can quickly understand where functionality belongs. Our code could be seen as:

MonitoringFramework
│
├── Classes
│   ├── Server.ps1
│   ├── HealthResult.ps1
│   └── Configuration.ps1
│
├── Services
│   ├── HealthCheckService.ps1
│   ├── ReportService.ps1
│   └── NotificationService.ps1
│
├── Public
│   └── Start-Monitoring.ps1
│
└── MonitoringFramework.psm1

The Framework Test

Here’s a simple exercise: Imagine a new engineer joins your team. Can they understand your automation solution in fifteen minutes?

If the answer is:

“Not really, there’s a lot of magic happening…”

your design may be too complicated.

A good framework is often boring. The purpose is not cleverness, but the purpose is predictability.

Summary

Reusable automation frameworks aren’t built by creating more classes. They’re built by assigning responsibilities correctly.

Good frameworks typically contain:

Domain Objects

Server
Certificate
Application
User

Services

MonitoringService
ReportingService
NotificationService

Configuration

MonitoringConfiguration

Entry Points

Start-Monitoring
Invoke-HealthCheck

Each component has a clear purpose.

Conclusion

Many PowerShell professionals never need frameworks. And that’s perfectly fine. But once your automation grows beyond a handful of scripts, structure becomes increasingly valuable. Frameworks are not about making PowerShell feel like C#. They’re about reducing chaos. The most successful automation solutions are rarely the most sophisticated. They’re the easiest to understand. When somebody opens your code six months later, clarity matters far more than cleverness and that is ultimately what good framework design delivers.

Next in This Series

Part 5: Testing Class-Based PowerShell Code with Pester

We’ll explore one of the biggest advantages of well-designed classes:

Testability.

Topics include:

  • Unit testing methods
  • Mocking external dependencies
  • Testing service classes
  • Verifying object state
  • Building reliable automation pipelines
  • Preventing regression bugs

Because automation that isn’t tested eventually becomes automation that nobody trusts.

Leave a Reply

Your email address will not be published. Required fields are marked *