Wann ein Skript aufhört, ein Skript zu sein
Ein Skript beginnt oft harmlos.
Es soll eine klar umrissene Aufgabe erledigen. Eine API aufrufen. Dateien verschieben. Daten bereinigen. Benutzer anlegen. E-Mails verarbeiten. Einen wiederkehrenden manuellen Ablauf automatisieren.
Die erste Version ist entsprechend einfach:
verbinden
daten laden
daten verändern
fertig
Das ist völlig legitim. Für kleine, lineare Aufgaben ist ein einzelnes Skript oft die beste Lösung. Wenig Struktur, wenig Overhead, kurze Entwicklungszeit.
Problematisch wird es nicht dadurch, dass das Skript länger wird. Problematisch wird es dann, wenn es beginnt, unterschiedliche Verantwortlichkeiten gleichzeitig zu tragen.
Dann ist es funktional noch ein Skript, strukturell aber bereits eine Anwendung.
Der gefährliche Übergang
Dieser Übergang passiert selten bewusst.
Kaum jemand entscheidet:
Ab heute ist dieses Skript eine Anwendung.
Stattdessen kommen Anforderungen einzeln dazu.
Zuerst soll das Skript nur eine Aktion ausführen.
Dann soll es Unterordner berücksichtigen.
Dann Paging.
Dann Retry.
Dann Fortschritt anzeigen.
Dann einen Dry Run unterstützen.
Dann Fehler nach einem Neustart fortsetzen können.
Dann sollen mehrere Konten unterstützt werden.
Dann braucht man Logs.
Dann JSON-Ausgabe für Automatisierung.
Dann Tests.
Dann eine schönere Benutzeroberfläche.
Jede einzelne Anforderung wirkt klein genug, um sie einfach noch in die bestehende Datei einzubauen.
Genau hier entsteht das Problem.
Die Architektur wächst nicht mit den Anforderungen mit. Stattdessen wächst nur die Datei.
Ein typischer Verlauf
Eine erste Version sieht vielleicht so aus:
Connect-Service
$items = Get-Items
foreach ($item in $items) {
Update-Item $item
}
Das ist überschaubar.
Dann kommt Pagination:
while ($nextLink) {
...
}
Dann Retry:
try {
...
}
catch {
Start-Sleep
...
}
Dann Fortschritt:
Write-Progress
Dann Zustände:
$processed++
$failed++
$currentFolder = ...
Dann Wiederaufnahme:
$checkpoint = Import-Clixml ...
Dann Benutzeroberfläche:
Write-Host ...
Clear-Host
Dann verschiedene Betriebsmodi:
param(
[switch]$Audit,
[switch]$Force,
[switch]$Quiet,
[switch]$Resume
)
Irgendwann enthält dieselbe Datei:
- Authentifizierung
- API-Zugriffe
- Paging
- Retry
- Batch-Verarbeitung
- Zustandsverwaltung
- UI
- Logging
- Fehlerbehandlung
- Persistenz
- Konfiguration
- Argumentverarbeitung
- Testbarkeit
- Ausgabeformatierung
Dann ist die relevante Frage nicht mehr:
Wie füge ich noch eine Funktion hinzu?
Sondern:
Warum ist das alles überhaupt noch in derselben Datei?
Länge ist nicht das eigentliche Problem
Eine Datei mit 1’000 Zeilen ist nicht automatisch schlecht.
Eine Datei mit 150 Zeilen kann bereits strukturell problematisch sein.
Entscheidend ist nicht die Anzahl der Zeilen, sondern die Anzahl der Gründe, warum sich der Code ändern muss.
Wenn eine Datei geändert werden muss, weil:
- die API geändert wurde,
- das UI anders aussehen soll,
- ein neuer Betriebsmodus dazukommt,
- Checkpoints anders gespeichert werden,
- Retry-Verhalten angepasst wird,
- eine neue Ausgabeform hinzukommt,
dann trägt sie zu viele Verantwortlichkeiten.
Das ist genau die Art von Kopplung, die spätere Änderungen teuer macht.
Das klassische Symptom: Lokale Fixes erzeugen neue Fehler
Ein wichtiger Warnhinweis ist, wenn neue Fehler zunehmend durch frühere Lösungen entstehen.
Beispiel:
Ein Skript flackert beim Rendern.
Also wird Clear-Host entfernt.
Danach bleiben alte Bildschirmreste stehen.
Also wird ein In-Place-Renderer gebaut.
Danach stimmen Zeilenlängen nicht mehr.
Also wird Padding ergänzt.
Danach funktionieren Unicode-Zeichen nicht zuverlässig.
Also wird Encoding angepasst.
Danach entstehen Probleme bei bestimmten PowerShell-Typen.
Jeder einzelne Fix kann sinnvoll sein.
Trotzdem verschlechtert sich das Gesamtsystem.
Das Problem liegt dann nicht in einem bestimmten Bug.
Das Problem liegt in der Struktur.
Die entscheidende Erkenntnis
Ein Skript hört nicht dann auf, ein Skript zu sein, wenn es „groß“ wird.
Es hört dann auf, ein Skript zu sein, wenn es einen eigenen Lebenszyklus entwickelt.
Dazu gehören typischerweise:
- Initialisierung
- Konfiguration
- Zustände
- Fehlerfälle
- Wiederaufnahme
- Benutzerinteraktion
- verschiedene Betriebsmodi
- externe Schnittstellen
- Tests
- Persistenz
- Versionierung
Ein lineares Skript hat einen Ablauf.
Eine Anwendung hat Zustände.
Das ist ein fundamentaler Unterschied.
Linearer Ablauf versus Zustandsmaschine
Ein einfaches Skript denkt so:
start
↓
aktion 1
↓
aktion 2
↓
aktion 3
↓
ende
Eine Anwendung denkt eher so:
INITIALIZING
↓
CONNECTING
↓
READY
↓
PROCESSING
↓
VERIFYING
↓
COMPLETED
Zusätzlich:
ERROR
CANCELLED
RETRYING
RESUMING
Sobald solche Zustände relevant werden, sollte der Code sie explizit modellieren.
Andernfalls entstehen sie trotzdem.
Dann sind sie nur versteckt in:
$connected = $true
$processing = $false
$failed = 3
$currentFolder = ...
$retryCount = ...
Das ist implizite Zustandslogik.
Und implizite Zustandslogik ist schwer zu testen und schwer zu verstehen.
Der Moment für das Refactoring
Der richtige Zeitpunkt für ein Refactoring liegt deutlich früher, als viele Entwickler denken.
Nicht erst dann, wenn die Datei unwartbar ist.
Sondern wenn erkennbar wird, dass zusätzliche Anforderungen neue Arten von Verantwortung einführen.
Ein pragmatisches Signal ist:
Wenn eine neue Funktion eine neue dieser Kategorien einführt, sollte die Struktur überprüft werden:
UI
Persistenz
Netzwerk
Retry
State
Parallelität
Resume
Konfiguration
Tests
Machine Output
Wenn bereits mehrere davon existieren, ist die Schwelle meist überschritten.
Aus einem Skript wird ein kleines System
Der natürliche nächste Schritt ist nicht automatisch ein großes Framework.
Oft genügt eine kleine, klare Modulstruktur.
Zum Beispiel:
File-For-Bankruptcy.ps1
FileForBankruptcy.psm1
Public/
Invoke-FileForBankruptcy.ps1
Private/
Graph.ps1
State.ps1
Checkpoint.ps1
UI.Terminal.ps1
UI.Screens.ps1
Tests/
Graph.Tests.ps1
Checkpoint.Tests.ps1
Module.Tests.ps1
Das sieht zunächst nach mehr Dateien aus.
Aber mehr Dateien bedeuten nicht automatisch mehr Komplexität.
Im Gegenteil.
Die Komplexität existiert bereits.
Die Dateien machen sie nur sichtbar.
Der Launcher
Der Einstiegspunkt sollte möglichst langweilig sein.
Zum Beispiel:
Import-Module ./FileForBankruptcy.psd1
Invoke-FileForBankruptcy @args
Der Launcher ist nicht der Ort für Geschäftslogik.
Er startet nur die Anwendung.
Das klingt banal, hat aber einen großen Vorteil: Der eigentliche Code kann als Modul geladen, getestet und wiederverwendet werden.
Public und Private
Eine sinnvolle Trennung ist:
Public/
Private/
Unter Public liegen Funktionen, die Teil der offiziellen Oberfläche sind.
Zum Beispiel:
Invoke-FileForBankruptcy
Unter Private liegt die Implementierung.
Zum Beispiel:
Get-FBMailFolders
Invoke-FBGraphBatch
Save-FBCheckpoint
Show-FBProcessingScreen
Das verbessert nicht nur die Übersicht.
Es zwingt dazu, über die öffentliche API des eigenen Tools nachzudenken.
API-Zugriffe gehören in eine eigene Schicht
Netzwerkcode sollte möglichst nicht direkt mit UI-Code vermischt werden.
Schlecht:
Write-Host "Processing folder..."
$response = Invoke-MgGraphRequest ...
Write-Host "Done"
Besser:
$result = Invoke-FBFolderProcessing
Show-FBFolderResult $result
Die API-Funktion weiß dann nichts über Farben, Spinner oder Terminalbreite.
Sie liefert Daten.
Zum Beispiel:
[pscustomobject]@{
Folder = $folderName
Success = 120
Failed = 2
Retried = 4
}
Die UI entscheidet, wie diese Daten dargestellt werden.
Das ist mehr als „Clean Code“
Diese Trennung hat einen sehr praktischen Nutzen.
Wenn dieselbe Engine sowohl eine TUI als auch JSON-Ausgabe erzeugen kann, ist die Architektur wahrscheinlich gesund.
Zum Beispiel:
.\tool.ps1
liefert eine interaktive Oberfläche.
Und:
.\tool.ps1 -Quiet
liefert:
{
"processed": 120,
"failed": 0,
"remaining": 0
}
Die Geschäftslogik ist dieselbe.
Nur der Renderer ist ein anderer.
Das ist ein gutes Architektur-Signal.
Quiet Mode als Architekturtest
Ein -Quiet-Modus wirkt zunächst wie ein kleines Feature.
Tatsächlich ist er ein hervorragender Test.
Wenn -Quiet schwer einzubauen ist, weil überall im Code direkt Write-Host verwendet wird, ist die UI zu stark mit der Logik gekoppelt.
Wenn die Kernlogik stattdessen nur Objekte zurückgibt, ist es einfach:
$result = Invoke-Processing
if ($Quiet) {
$result | ConvertTo-Json
}
else {
Show-Result $result
}
Die Ausgabeform wird damit austauschbar.
-WhatIf ist mehr als Komfort
Ein weiteres Reifezeichen ist echtes PowerShell-Verhalten.
Mit:
[CmdletBinding(SupportsShouldProcess = $true)]
kann das Tool native Mechanismen unterstützen:
.\tool.ps1 -WhatIf
Das ist deutlich besser als ein selbst erfundener -DryRun, wenn das Tool ohnehin in PowerShell lebt.
Der Vorteil ist nicht nur Konvention.
ShouldProcess zwingt dazu, klar zu definieren:
Was genau ist die mutierende Operation?
Das ist eine Architekturfrage.
Audit und Mutation sollten getrennt sein
Ein gutes Tool trennt:
Was würde passieren?
von:
Führe es aus.
Das ergibt zwei klar unterschiedliche Phasen:
AUDIT
→ Zustand lesen
→ Umfang bestimmen
→ Risiken anzeigen
EXECUTION
→ Änderungen durchführen
Das verbessert Sicherheit und Verständlichkeit.
Nebenbei kann der Audit oft mit geringeren Berechtigungen laufen.
Beispiel:
Audit:
Mail.Read
Mutation:
Mail.ReadWrite
Das ist Least Privilege praktisch umgesetzt.
Retry ist keine Fehlerbehandlung
Ein häufiger Fehler ist:
catch {
Start-Sleep 2
retry
}
Das wirkt robust, ist aber oft nur verschobenes Scheitern.
Ausgereifte Retry-Logik unterscheidet mindestens:
429
5xx
4xx
Timeout
Partial Batch Failure
Ein 429 Too Many Requests ist etwas anderes als ein 403 Forbidden.
Ein 500 ist etwas anderes als ein ungültiger Request.
Die Reaktion muss entsprechend unterschiedlich sein.
Partial Failure verändert die Architektur
Bei Batch-Operationen gibt es nicht nur:
erfolgreich
fehlgeschlagen
Ein Batch kann so aussehen:
20 Requests
18 erfolgreich
1 throttled
1 dauerhaft fehlgeschlagen
Dann muss der Zustand pro Element modelliert werden.
Zum Beispiel:
SucceededIds
RetryIds
FailedIds
Ein simples:
Success = 18
Failed = 2
reicht spätestens dann nicht mehr, wenn Resume oder Checkpoints unterstützt werden.
Resume macht aus einem Skript endgültig eine Anwendung
Wiederaufnahme ist eine klare Grenze.
Sobald ein Tool nach einem Abbruch später weiterarbeiten können soll, braucht es persistenten Zustand.
Zum Beispiel:
{
"processedMessageIds": [
"abc",
"def"
],
"startedAt": "2026-09-05T14:00:00Z"
}
Ab diesem Punkt existiert Zustand außerhalb des Prozesses.
Das bedeutet:
- Versionierung
- Konsistenz
- Fehlerfälle beim Laden
- veraltete Checkpoints
- teilweise abgeschlossene Arbeit
Das ist Anwendungslogik.
Kein einfacher linearer Skriptablauf mehr.
Checkpoints müssen semantisch korrekt sein
Ein besonders gefährlicher Fehler ist:
versucht
mit:
erfolgreich
zu verwechseln.
Ein Checkpoint darf nicht einfach alle verarbeiteten IDs speichern.
Er muss wissen, welche Operation tatsächlich erfolgreich war.
Sonst entsteht dieses Problem:
Message A
PATCH erfolgreich
→ gespeichert
Message B
PATCH fehlgeschlagen
→ ebenfalls gespeichert
Beim Resume wird Message B dann übersprungen, obwohl sie nie erfolgreich verändert wurde.
Das ist ein gutes Beispiel dafür, warum Datenmodelle wichtiger werden, sobald Resume existiert.
Verifikation gehört zum Erfolg
Ein Tool sollte nicht nur sagen:
56 requests completed
und daraus ableiten:
SUCCESS
Die relevante Frage ist:
Ist der gewünschte Zustand tatsächlich erreicht?
Das sind zwei unterschiedliche Dinge.
Operation Success
und:
State Success
Beispiel:
56 PATCH Requests erfolgreich
ist eine Aussage über Operationen.
0 unread messages remaining
ist eine Aussage über den Zustand.
Die zweite ist oft wichtiger.
Deshalb ist ein Post-Run-Audit sinnvoll:
Mutation
↓
Verification
↓
Final Result
Tests werden plötzlich sinnvoller
Bei einem kleinen Wegwerfskript ist ein Testframework oft unnötig.
Aber sobald Funktionen wie diese existieren:
Paging
Retry
Resume
Batching
Checkpoint
Verification
werden Tests extrem wertvoll.
Nicht nur Happy-Path-Tests.
Gerade Fehlerfälle sollten modelliert werden.
Zum Beispiel:
Paging ohne nextLink
Paging mit mehreren Seiten
429 → Retry
500 → Retry
403 → kein Retry
Batch mit 20 Erfolgen
Batch mit 19 Erfolgen und 1 Fehler
Batch mit fehlender Response
Checkpoint speichert nur erfolgreiche IDs
Resume verarbeitet fehlgeschlagene IDs erneut
Das sind die Stellen, an denen reale Produktionsfehler entstehen.
Der wichtigste Architekturtest
Ein sehr einfacher Test lautet:
Kann ich die Kernlogik ohne Terminal starten?
Wenn nein, ist die TUI wahrscheinlich zu stark gekoppelt.
Ein zweiter:
Kann ich die API-Schicht testen, ohne echte API-Calls auszuführen?
Wenn nein, sind Abhängigkeiten wahrscheinlich zu hart verdrahtet.
Ein dritter:
Kann ich die Verarbeitung mit einem gespeicherten Zustand wieder aufnehmen?
Wenn nein, ist State wahrscheinlich nicht explizit genug modelliert.
Nicht über-engineeren
Die Gegenreaktion wäre falsch:
Jedes Skript sofort in 30 Dateien zerlegen.
Auch das erzeugt Komplexität.
Ein einfaches Skript sollte einfach bleiben.
Zum Beispiel:
Get-ChildItem *.log |
Where-Object LastWriteTime -lt (Get-Date).AddDays(-30) |
Remove-Item
Das braucht keine Module, Domain Objects und Repository Interfaces.
Die Struktur sollte mit der tatsächlichen Problemkomplexität wachsen.
Nicht mit theoretischen Möglichkeiten.
Eine praktische Heuristik
Ein einzelnes Skript ist meist noch passend, wenn:
- der Ablauf linear ist
- kein persistenter Zustand existiert
- Fehler einfach behandelt werden können
- nur ein Betriebsmodus existiert
- keine aufwendige UI notwendig ist
- wenig externe Integration existiert
Ein Umbau wird interessant, wenn mehrere dieser Punkte auftauchen:
- mehrere Betriebsmodi
- API-Paging
- Retry
- Batch-Verarbeitung
- komplexe Fehlerbehandlung
- UI oder TUI
- Resume
- Checkpoints
- Machine-readable Output
- Tests
- mehrere Konten oder Targets
- unterschiedliche Berechtigungen
- Post-Verification
Nicht einer dieser Punkte allein macht eine Anwendung.
Die Kombination tut es.
Die eigentliche Lektion
Viele Entwickler betrachten Refactoring als Arbeit, die nach der eigentlichen Funktionalität kommt.
Das ist zu kurz gedacht.
Refactoring wird notwendig, wenn die bestehende Struktur beginnt, neue Anforderungen falsch abzubilden.
Der entscheidende Zeitpunkt ist also nicht:
Der Code sieht unsauber aus.
Sondern:
Neue Features erzeugen überproportional viele Wechselwirkungen mit bestehenden Features.
Dann ist die aktuelle Abstraktion erschöpft.
Der zwölfte Patch ist ein Signal
Wenn ein Tool bereits diese Geschichte hat:
Bug
→ Patch
→ neuer Bug
→ Patch
→ Seiteneffekt
→ Patch
→ neuer Sonderfall
→ Patch
dann ist der nächste Fehler oft nicht mehr das eigentliche Problem.
Das System versucht mitzuteilen:
Die Struktur passt nicht mehr zur Komplexität.
Dann ist ein weiterer Patch möglicherweise technisch korrekt und trotzdem die falsche Entscheidung.
Die bessere Frage lautet:
Welche Verantwortung fehlt als eigene Abstraktion?
Vielleicht:
Graph Client
Checkpoint Store
State Model
Renderer
Command Handler
Das ist der Punkt, an dem Refactoring nicht kosmetisch ist.
Es wird funktional.
Fazit
Ein Skript hört nicht auf, ein Skript zu sein, weil es viele Zeilen hat.
Es hört auf, ein Skript zu sein, wenn es beginnt, Eigenschaften einer Anwendung zu entwickeln:
State
Lifecycle
Recovery
Multiple Interfaces
Persistence
Verification
Tests
Dann sollte die Architektur das auch widerspiegeln.
Nicht mit möglichst vielen Patterns.
Nicht mit unnötigen Frameworks.
Sondern mit klaren Verantwortlichkeiten.
Die wichtigste Frage bei jedem weiteren Feature lautet deshalb nicht:
Wo füge ich diesen Code ein?
Sondern:
Gehört diese neue Verantwortung überhaupt noch in die bestehende Struktur?
Wenn diese Frage zu spät gestellt wird, entsteht ein großes Skript.
Wenn sie rechtzeitig gestellt wird, entsteht ein kleines, gut strukturiertes System.