Production Script Architecture, Versioning, Configuration, and Deployment
Design a maintainable PowerShell automation project that separates pure logic, adapters, configuration, secrets, tests, documentation, packaging, semantic versions, and reproducible deployment contracts.
Learning objectives
- Design a project layout that separates source, tests, configuration, documentation, scripts, and artifacts.
- Separate pure logic from external-effect adapters and orchestration.
- Apply semantic versioning, changelogs, compatibility contracts, and deprecation deliberately.
- Choose between module packaging and standalone-script distribution.
- Pin dependencies and document a platform support matrix for reproducible environments.
- Recognize architecture anti-patterns before they become operational dependencies.
1. Project structure should make boundaries visible
A maintainable repository lets a new engineer locate pure logic, external integrations, tests, configuration examples, runbooks, and packaging metadata without reading one 3,000-line script. Folder names are not architecture by themselves, but they can make architecture enforceable.
AutomationToolkit/
├── src/AutomationToolkit/
│ ├── AutomationToolkit.psd1
│ ├── AutomationToolkit.psm1
│ ├── Public/
│ └── Private/
├── tests/
│ ├── unit/
│ └── integration/
├── config/
│ ├── defaults.psd1
│ └── environments/
├── scripts/
│ └── Invoke-Automation.ps1
├── docs/
│ ├── README.md
│ └── RUNBOOK.md
├── build/
└── PSScriptAnalyzerSettings.psd12. Separate pure logic, effects, and orchestration
Pure logic transforms input into output without changing external state. Adapters talk to files, processes, APIs, remoting, cloud modules, or native tools. Orchestration coordinates those pieces and decides sequencing, retries, approvals, and exit status.
This separation makes tests fast because pure decisions can be tested without fake networks, while integration tests focus on adapter contracts.
function ConvertTo-EnvironmentPlan { param([object]$Config) ... } # pure
function Get-RepositoryMetadata { param([string]$Path) ... } # adapter
function Invoke-HealthProbe { param([uri]$Uri) ... } # adapter
function Invoke-ReleasePreparation { param(...) ... } # orchestration3. Configuration is data; secrets are injected separately
Store non-secret defaults as data files that can be code reviewed. Override them explicitly by environment. Inject secrets at runtime from a secret store, managed identity, CI secret, or credential provider. Avoid hidden profile variables or implicit working-directory assumptions.
$defaults = Import-PowerShellDataFile ./config/defaults.psd1
$environment = Import-PowerShellDataFile ./config/environments/staging.psd1
$config = $defaults.Clone()
foreach ($key in $environment.Keys) { $config[$key] = $environment[$key] }
# Secret values are resolved separately at the adapter boundary.4. Semantic versioning is a compatibility promise
Semantic Versioning uses MAJOR.MINOR.PATCH. Increase PATCH for backward-compatible fixes, MINOR for backward-compatible features, and MAJOR for incompatible public-contract changes. This rule is useful only when the project clearly defines its public contract: exported functions, parameters, output fields, config schema, artifact format, and supported platforms.
Do not hide breaking output-shape changes inside a “patch” because the code still imports successfully. Machine consumers depend on contracts.
5. A changelog explains operational impact
A useful changelog distinguishes added, changed, deprecated, removed, fixed, and security-relevant behavior. Link entries to migration guidance when inputs or outputs change. “Refactored code” is less useful to operators than “renamed report field Host to Target; old field remains through 2.x.”
6. Deprecation is a migration period, not a warning forever
When a contract must change, announce the replacement, emit a bounded warning, keep the old path for a documented window, test both paths, then remove the old behavior in the promised major release. Deprecations with no removal plan become permanent complexity.
7. Choose module packaging when the reusable surface matters
Use a module when multiple scripts or teams need reusable functions, version metadata, command discovery, help, testing, and package distribution. A standalone script can be appropriate for a narrow single-entry workflow, but it should still have explicit parameters, no profile dependency, documented requirements, structured output, and a versioned release artifact.
A common production design is both: reusable logic in a module plus a thin scripts/Invoke-*.ps1 entry point.
8. Reproducibility requires explicit dependency versions
Record minimum/validated PowerShell versions, required module versions, native-tool versions where contracts matter, and configuration schema versions. Pin exact versions for CI/build environments when deterministic behavior matters; maintain an upgrade process so pins do not become permanent abandonment.
Dependency restoration belongs before execution. Do not silently install arbitrary latest versions inside a production script.
$requirements = [ordered]@{
PowerShell = '7.6.x'
Pester = '6.0.0'
PSScriptAnalyzer = '1.24.0'
Dsc = '3.2.x (optional adapter)'
}
$requirements | ConvertTo-Json9. Publish a platform support matrix
Cross-platform PowerShell does not make every adapter cross-platform. Document which operating systems and editions support each feature. For example, modern DSC is cross-platform, but specific resources may be Windows-only; remoting transport choices differ; Authenticode cmdlets and Windows service tooling have platform boundaries.
Treat “not tested” differently from “unsupported.” A support matrix is a quality contract, not marketing.
10. Build artifacts should be generated, not edited by hand
Use a build step to validate manifests, run tests/analyzer checks, assemble the module, create checksums, and write release metadata. Keep generated artifacts out of the source-of-truth folders unless the repository intentionally versions them.
$dist = Join-Path $PWD 'build/dist'
Remove-Item -LiteralPath $dist -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $dist -Force | Out-Null
Copy-Item ./src/AutomationToolkit -Destination $dist -Recurse
Get-ChildItem $dist -File -Recurse |
Get-FileHash -Algorithm SHA256 |
Select-Object Path, Hash |
ConvertTo-Json | Set-Content (Join-Path $dist 'checksums.json')11. Architecture anti-patterns are hidden dependencies
Watch for monolithic scripts, global mutable state, functions that format instead of returning objects, swallowed exceptions, secrets in config files, implicit profile imports, current-directory assumptions, “latest” dependency installs during execution, unbounded concurrency, and adapters that both decide policy and perform effects.
The cure is not more classes. It is smaller contracts with explicit inputs, outputs, side effects, and ownership.
12. Lab — scaffold a production project and validate its contract files
Create a disposable project tree and machine-readable requirements file. This lab changes only the local workspace.
$root = Join-Path $PWD 'chapter20-architecture-lab'
@('src/AutomationToolkit/Public','src/AutomationToolkit/Private','tests/unit','tests/integration','config/environments','scripts','docs','build') |
ForEach-Object { New-Item -ItemType Directory -Path (Join-Path $root $_) -Force | Out-Null }
[ordered]@{
project='AutomationToolkit'
version='1.0.0'
powershell='7.6.x'
supportedPlatforms=@('Windows','Linux','macOS')
generatedAtUtc=[datetime]::UtcNow
} | ConvertTo-Json -Depth 4 |
Set-Content (Join-Path $root 'requirements.json') -Encoding utf8
Get-ChildItem $root -Recurse | Select-Object FullName13. Maintainability review checklist
Review the project as though another team must own it next month.
- Public commands have help and stable contracts.
- Pure logic is separated from external effects.
- Configuration contains no secrets.
- Dependency versions and support matrix are explicit.
- Tests and analyzer configuration live with the source.
- Build artifacts are reproducible.
- Changelog and deprecation policy describe compatibility.
14. Knowledge check
Question 1. Why separate pure logic from adapters?
Question 2. What is a public compatibility contract besides exported function names?
Question 3. When should a module be preferred over one large script?
Question 4. Why not install the latest dependency version during every production run?
Question 5. What is the purpose of a support matrix?
15. Summary and capstone bridge
You now have the production scaffolding required for the course capstone. Lesson 5 combines these boundaries into a cross-platform toolkit with read-only inventory and validation by default, optional state-changing adapters behind ShouldProcess, structured reports, tests, analyzer configuration, help, and runbook guidance.
16. Authoritative references
Microsoft Learn — script modules
Microsoft Learn — module manifests
Semantic Versioning specification
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this address.