05.09.2026 · Fabricio Ruch

Wann ein Skript aufhört, ein Skript zu sein

AutomationMethodsProgramming PrinciplesSoftware ArchitectureSoftware EngineeringSystem EngineeringWorkflow Systems

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.