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