# Serverless-Workflows mit AWS Step Functions

URL: https://www.mikebild.dev/de/blog/step-functions-serverless-workflows/

Die erste Funktion validiert und ruft die zweite, die berechnet und ruft die dritte, die benachrichtigt. Auf dem Whiteboard ergibt das eine saubere Kette. Zur Laufzeit bleibt davon nur eine Folge von Aufrufen übrig, die niemand von außen anhalten, prüfen oder wieder aufnehmen kann. Wer die Lambda-Funktionen in den Handlern aneinanderhängt, hat den Ablauf dem Betrieb entzogen: Bleibt eine Bestellung hängen, beginnt die Suche in den Logs.

AWS Step Functions dreht das Verhältnis um. Statt den Ablauf in verschachtelten Handlern zu verstecken, beschreibt man ihn deklarativ als State Machine – und der Zustand wird sichtbar, statt in Aufruf-Ketten zu verschwinden.

Eine State Machine macht den Ablauf als Reihe benannter Zustände und ausdrücklich behandelter Ergebnisse sichtbar.

:::process{title="Ein expliziter Workflow" role="primary" preferredPattern="connected-process"}
1. Eingabe · fachlichen Auftrag und benötigten Kontext übernehmen
2. Task · genau eine begrenzte Fähigkeit ausführen
3. Choice · anhand eines Ergebnisses den nächsten zulässigen Pfad wählen
4. Parallel · unabhängige Zweige nur bei echter Nebenläufigkeit starten
5. Catch · erwartbare Fehler in einen benannten Behandlungspfad führen
6. Abschluss · Erfolg oder Scheitern als eindeutigen Endzustand festhalten
:::

Die Darstellung behauptet keinen linearen Happy Path. Verzweigung und Fehlerbehandlung bleiben Teil des Modells, statt in verstreuten Funktionsaufrufen zu verschwinden.

## Das Problem: Orchestrierung im Code

Schauen wir uns zuerst an, wie die Reflex-Lösung typischerweise aussieht. Eine Lambda-Funktion, die eine Bestellung verarbeitet, könnte intern so orchestrieren:

```js
exports.handler = async (event) => {
  const order = await validateOrder(event);
  if (!order.valid) {
    await notifyFailure(order);
    return { status: "rejected" };
  }

  if (order.amount > 1000) {
    await requestManualReview(order);
  } else {
    await autoApprove(order);
  }

  const [inventory, payment] = await Promise.all([
    reserveInventory(order),
    chargePayment(order),
  ]);

  return finalize(order, inventory, payment);
};
```

Auf den ersten Blick ist das lesbar. Aber jeder dieser Aufrufe kann fehlschlagen, und sobald man Robustheit will, wächst der Code. Was passiert, wenn `chargePayment` einen transienten Fehler wirft? Dann braucht man einen Retry mit Backoff. Was, wenn `reserveInventory` klappt, aber `chargePayment` endgültig scheitert? Dann muss die Reservierung wieder zurückgerollt werden. Nach und nach füllt sich der Handler mit try/catch-Blöcken, Zählern und Timeout-Logik. Der eigentliche fachliche Ablauf – validieren, entscheiden, parallel ausführen, abschließen – verschwindet unter der Fehlerbehandlung.

Und der unangenehmste Teil kommt im Betrieb. Wenn eine Bestellung stecken bleibt, gibt es keinen Ort, an dem man den Zustand ablesen kann. Man hat CloudWatch-Logs und muss rekonstruieren, wie weit der Prozess gekommen ist. Bei einer einzigen Funktion geht das noch. Bei fünf Funktionen, die sich gegenseitig aufrufen, wird es zur Detektivarbeit. Die Koordination mehrerer Funktionen gehört zu den Grenzen, an die man mit reinem Code schnell stößt.

## Die Idee: der Ablauf als State Machine

Step Functions verlagert die Orchestrierung aus dem Code heraus in eine deklarative Beschreibung. Diese Beschreibung ist in der **Amazon States Language** (ASL) formuliert, einem JSON-Format. Man definiert Zustände, deren Übergänge und die Fehlerpfade – die einzelnen Lambda-Funktionen bleiben klein und machen jeweils genau eine Sache.

Der entscheidende Gewinn: Der Ablauf steht nicht mehr in einem Handler, sondern liegt als eigenständiges Artefakt vor, das die Konsole grafisch darstellt. Man sieht bei jeder Ausführung, in welchem Zustand sie sich befindet, welcher Übergang genommen wurde und wo etwas fehlgeschlagen ist.

Eine ASL-Definition hat auf oberster Ebene wenige Pflichtfelder. `StartAt` benennt den Einstiegszustand, `States` enthält die Zustände als Objekt. Jeder Zustand hat einen `Type` und verweist mit `Next` auf den Folgezustand oder markiert sich mit `"End": true` als terminal.

Bevor wir in eine konkrete Definition einsteigen, hier die acht Zustandstypen, die 2021 zur Verfügung stehen – das ist der komplette Baukasten:

- `Task` führt Arbeit aus, typischerweise eine Lambda-Funktion.
- `Choice` verzweigt anhand von Bedingungen, das if/else der State Machine.
- `Parallel` führt mehrere Zweige gleichzeitig aus und sammelt deren Ergebnisse.
- `Map` wendet einen Ablauf auf jedes Element einer Liste an, 2021 nur als Inline-Variante.
- `Pass` reicht Eingabe durch oder injiziert feste Daten, nützlich zum Testen.
- `Wait` pausiert für eine Dauer oder bis zu einem Zeitpunkt.
- `Succeed` beendet die Ausführung erfolgreich.
- `Fail` beendet die Ausführung mit einem Fehler.

Mit diesen acht Bausteinen lassen sich erstaunlich viele Prozesse abbilden, ohne eine Zeile Orchestrierungscode zu schreiben.

## Ein Task mit Retry und Catch

Fangen wir mit dem Kern an: einem Task-Zustand, der eine Lambda-Funktion aufruft und Fehler deklarativ behandelt. Das ist der Teil, der im Handler-Ansatz am meisten Code kostet – und in ASL zu ein paar Feldern schrumpft.

```json
{
  "Comment": "Order processing workflow",
  "StartAt": "ValidateOrder",
  "States": {
    "ValidateOrder": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:eu-central-1:123456789012:function:ValidateOrder",
      "Retry": [
        {
          "ErrorEquals": ["Lambda.ServiceException", "Lambda.SdkClientException"],
          "IntervalSeconds": 2,
          "MaxAttempts": 3,
          "BackoffRate": 2.0
        }
      ],
      "Catch": [
        {
          "ErrorEquals": ["States.ALL"],
          "Next": "NotifyFailure"
        }
      ],
      "Next": "CheckAmount"
    },
    "NotifyFailure": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:eu-central-1:123456789012:function:NotifyFailure",
      "End": true
    }
  }
}
```

Hier steckt viel drin, deshalb der Reihe nach. Der `Retry`-Block ist ein Array von Retrier-Objekten. `ErrorEquals` benennt, auf welche Fehler dieser Retrier reagiert – hier zwei transiente Lambda-Service-Fehler, die man nicht dem fachlichen Code anlasten sollte. `IntervalSeconds` legt die erste Wartezeit fest, `BackoffRate` multipliziert sie bei jedem weiteren Versuch (Default 2.0), `MaxAttempts` deckelt die Wiederholungen (Default 3). Ohne explizite Angabe würde AWS diese Defaults verwenden.

Der `Catch`-Block greift, wenn die Retries erschöpft sind oder ein Fehler auftritt, den kein Retrier abdeckt. Jeder Catcher braucht `ErrorEquals` und ein `Next`, das den Übergang beschreibt. `States.ALL` ist der Auffang-Fehler, der alles matcht – und genau deshalb gibt es zwei Regeln, die man kennen muss: `States.ALL` muss allein in seinem `ErrorEquals` stehen und als letzter Eintrag kommen. Steht danach noch ein weiterer Retrier oder Catcher, ist die Definition ungültig.

Ein wichtiges Detail bei der Reihenfolge: Erst werden die passenden Retrier abgearbeitet, und nur wenn keiner mehr greift, wird der Catcher-Übergang genommen. Retry vor Catch, immer in dieser Ordnung. Das ist genau das Verhalten, das man im Code mühsam von Hand nachbauen würde – hier ist es Konvention der Sprache.

Ein Punkt, der regelmäßig für Diskussion sorgt: Nicht jeder `States.`-Fehler wird von allem gefangen. `States.TaskFailed` etwa matcht so ziemlich alles außer `States.Timeout` – wer also nur auf `States.TaskFailed` retryt und eine Zeitüberschreitung erwartet, wird überrascht. Und `States.DataLimitExceeded`, der bei Überschreitung des Payload-Limits (2021 sind das 256 KB zwischen den Zuständen) auftritt, wird von `States.ALL` nicht gefangen. Solche Fehler muss man bewusst vermeiden, nicht abfangen.

## Verzweigen mit Choice

Nach der Validierung soll der Prozess entscheiden. Kleine Bestellungen werden automatisch freigegeben, große gehen in die manuelle Prüfung. Im Handler war das ein `if`, in ASL ist es ein Choice-Zustand.

```json
{
  "CheckAmount": {
    "Type": "Choice",
    "Choices": [
      {
        "Variable": "$.amount",
        "NumericGreaterThan": 1000,
        "Next": "ManualReview"
      }
    ],
    "Default": "FanOut"
  },
  "ManualReview": {
    "Type": "Task",
    "Resource": "arn:aws:lambda:eu-central-1:123456789012:function:RequestManualReview",
    "Next": "FanOut"
  }
}
```

Der Choice-Zustand enthält ein `Choices`-Array. Jede Regel besteht aus einer `Variable` (ein Pfad in die aktuelle Eingabe, hier `$.amount`), einem Vergleichsoperator wie `NumericGreaterThan`, `StringEquals` oder `BooleanEquals`, und einem `Next`. Man kann Bedingungen auch mit `And`, `Or` und `Not` verschachteln. Trifft keine Regel zu, greift `Default`.

Genau dieses `Default` ist ein häufiger Stolperstein. Choice hat kein `End` und keinen impliziten Ausgang. Ohne `Default` und ohne passenden Treffer läuft die Ausführung in einen `States.NoChoiceMatched`-Fehler. Ich empfehle deshalb, `Default` immer zu setzen, selbst wenn man theoretisch alle Fälle abgedeckt glaubt.

## Parallel: mehrere Zweige gleichzeitig

Für Reservierung und Zahlung, die unabhängig voneinander laufen, gibt es den Parallel-Zustand. Er führt mehrere `Branches` gleichzeitig aus – jeder Branch ist selbst eine kleine State Machine mit eigenem `StartAt` und `States`.

```json
{
  "FanOut": {
    "Type": "Parallel",
    "Branches": [
      {
        "StartAt": "ReserveInventory",
        "States": {
          "ReserveInventory": {
            "Type": "Task",
            "Resource": "arn:aws:lambda:eu-central-1:123456789012:function:ReserveInventory",
            "End": true
          }
        }
      },
      {
        "StartAt": "ChargePayment",
        "States": {
          "ChargePayment": {
            "Type": "Task",
            "Resource": "arn:aws:lambda:eu-central-1:123456789012:function:ChargePayment",
            "End": true
          }
        }
      }
    ],
    "Next": "Finalize"
  },
  "Finalize": {
    "Type": "Task",
    "Resource": "arn:aws:lambda:eu-central-1:123456789012:function:Finalize",
    "End": true
  }
}
```

Der Output eines Parallel-Zustands ist ein geordnetes Array – ein Eintrag pro Branch, in der Reihenfolge der Definition. `Finalize` bekommt also beide Ergebnisse gebündelt übergeben. Auch Parallel unterstützt `Retry` und `Catch` auf Zustandsebene, ebenso der Map-Zustand, wenn man eine Liste iterativ abarbeitet.

## Wie Daten zwischen den Zuständen fließen

Sobald man das erste Mal ernsthaft mit ASL arbeitet, stolpert man über eine Frage, die im Handler-Code nie auftaucht: Was genau bekommt der nächste Zustand als Eingabe? In einer JavaScript-Funktion reicht man Werte einfach als Argumente weiter. In einer State Machine ist die Weitergabe geregelt, und die Regeln muss man kennen, sonst gehen Daten unbemerkt verloren.

Jeder Zustand bekommt ein JSON-Objekt als Eingabe und gibt ein JSON-Objekt als Ausgabe. Vier Felder steuern diesen Fluss. `InputPath` filtert, welcher Ausschnitt der Eingabe überhaupt beim Zustand ankommt. `Parameters` baut daraus das Objekt, das die Lambda-Funktion tatsächlich erhält. `ResultPath` bestimmt, an welcher Stelle das Ergebnis in die Eingabe eingefügt wird. `OutputPath` filtert zuletzt, was an den Folgezustand weitergereicht wird.

Der häufigste Stolperstein steckt in `ResultPath`. Ohne Angabe steht der Default auf `$`, und das heißt: Das Ergebnis des Tasks ersetzt die komplette Eingabe. Wer nach `ValidateOrder` im Choice-Zustand noch auf `$.amount` zugreifen will, muss das Ergebnis mit `"ResultPath": "$.validation"` unter einen eigenen Schlüssel legen, sonst ist die ursprüngliche Bestellung weg. Genau dieser Default ist der Grund, warum ein Choice-Zustand danach ins Leere greift: Der Schlüssel, den die Bedingung erwartet, existiert nach dem Task gar nicht mehr. Solche Fehler wirken wie eine falsche Bedingung, sind in Wahrheit aber ein Datenfluss-Problem.

## Der Ablauf als Bild

Das eigentliche Argument für Step Functions ist die Sichtbarkeit. Was ich oben in JSON beschrieben habe, stellt die Konsole als Graph dar. Und genau diese Struktur lässt sich als Diagramm nachzeichnen:

```mermaid
flowchart TD
    Start([Start]) --> Validate[ValidateOrder<br/>Task]
    Validate --> Check{CheckAmount<br/>Choice}
    Check -->|amount > 1000| Review[ManualReview<br/>Task]
    Check -->|else| FanOut[FanOut<br/>Parallel]
    Review --> FanOut
    FanOut --> Reserve[ReserveInventory]
    FanOut --> Charge[ChargePayment]
    Reserve --> Finalize[Finalize<br/>Task]
    Charge --> Finalize
    Finalize --> Done([Succeed])
    Validate -. "Catch States.ALL" .-> Notify[NotifyFailure<br/>Task]
```

Die gestrichelte Kante ist der Fehlerpfad, den der Catcher beschreibt. Bei einer laufenden Ausführung färbt die Konsole die durchlaufenen Zustände ein, sodass man auf einen Blick sieht, wo der Prozess steht oder wo er hängengeblieben ist. Diesen Blick habe ich im Handler-Ansatz nie – und das ist der Unterschied zwischen „wir müssen die Logs durchsuchen“ und „schau einfach in die Ausführung“.

## Standard oder Express

Bei der Erstellung einer State Machine muss man sich für einen Typ entscheiden, und diese Entscheidung ist danach fix. Wer sich vertut, baut eine neue State Machine. Deshalb lohnt es sich, die beiden Typen früh zu verstehen.

**Standard** ist der Typ für lang laufende, auditierbare Prozesse. Eine Ausführung darf bis zu ein Jahr dauern, die Ausführung ist exactly-once, und die vollständige Historie steht bis zu 90 Tage über die API bereit. Abgerechnet wird pro Zustandsübergang. Wichtig für die Kostenrechnung: Jeder Retry-Versuch ist ein eigener Übergang und zählt mit. Das visuelle Debugging in der Konsole gibt es nur bei Standard.

**Express** ist für hochfrequente, kurze Abläufe gedacht. Eine Ausführung darf höchstens fünf Minuten laufen. Asynchron ausgeführt ist die Garantie at-least-once, synchron über `StartSyncExecution` (verfügbar seit November 2020) at-most-once. Abgerechnet wird nach Anzahl der Ausführungen sowie Dauer und Speicher. Eine Ausführungshistorie in der Konsole gibt es nicht – man ist auf CloudWatch Logs angewiesen, und dafür muss Logging aktiv sein.

Die Trennlinie verläuft entlang der Fachlichkeit: Bestellprozesse, Freigabe-Workflows, alles mit menschlicher Beteiligung oder Prüfpflicht gehört zu Standard. Event-Verarbeitung mit hohem Durchsatz, kurze Transformationen, IoT-Ingestion – das ist Express-Territorium. Der idempotente Charakter kurzer Express-Abläufe passt zur at-least-once-Semantik, während man bei einem Bezahlvorgang die exactly-once-Garantie von Standard haben will.

## Wann Step Functions – und wann nicht

Ich rate nicht dazu, jede Aneinanderreihung von zwei Funktionen zur State Machine zu machen. Wenn eine Funktion eine andere aufruft und das war es, ist ein direkter Aufruf oder ein Event völlig in Ordnung. Der Aufwand von Step Functions zahlt sich ab dem Punkt aus, an dem es echte Ablauflogik gibt: Verzweigungen, Parallelität, Retries mit unterschiedlichen Strategien, Kompensation bei Teilfehlern, oder wenn man den Zustand für den Betrieb sichtbar braucht.

Ein zweites Kriterium ist die Fehlerbehandlung. Sobald man merkt, dass der Orchestrierungs-Handler mehr try/catch als Fachlogik enthält, ist das ein starkes Signal. Diese Behandlung deklarativ auszulagern macht den Code der einzelnen Funktionen wieder klein und lesbar – jede Funktion konzentriert sich auf ihre eine Aufgabe, die Koordination liegt in der State Machine.

Und Step Functions ersetzt nicht die anderen Serverless-Bausteine, sondern koordiniert sie. Die Lambda-Funktionen bleiben, die Datenbank bleibt, die Authentifizierung – etwa mit [Cognito User Pools](https://www.mikebild.dev/de/blog/cognito-userpool-auth-serverless/) – bleibt an ihrem Platz. Step Functions ist die Schicht darüber, die den Ablauf beschreibt.

## Fazit

Der Weg von verschachtelten Handlern zu einer State Machine ist vor allem ein Wechsel der Perspektive. Statt den Ablauf im Code zu verstecken, macht man ihn zum Artefakt: `StartAt`, ein paar Zustände, `Next`-Übergänge, und die Fehlerpfade als `Retry` und `Catch` direkt am Zustand. Die einzelnen Funktionen werden dadurch kleiner, weil sie die Koordination nicht mehr tragen müssen, und der Betrieb wird ruhiger, weil der Zustand jeder Ausführung sichtbar ist.

Die acht Zustandstypen sind schnell gelernt, und mit Task, Choice und Parallel deckt man bereits die meisten realen Abläufe ab. Mit der Reihenfolge von Retry und Catch im Hinterkopf, einem gesetzten `Default` bei Choice und einer bewussten Wahl zwischen Standard und Express ist das Fundament beisammen. Überzeugt hat in den Projekten, an denen ich beteiligt war, ohnehin erst die eingefärbte Ausführung in der Konsole. Ab da liegt der Ablauf nicht mehr in Logs vergraben, sondern ist ein Bild, auf das alle schauen können.

## Weiterführende Quellen

- Foliensatz zu diesem Thema: https://supabase.mikebild.dev/storage/v1/object/public/slides/introduction-aws-serverless.html
- Repository zur Serverless-Schulung mit den ASL-Beispielen dieses Artikels
- [AWS Step Functions – Zustände (States)](https://docs.aws.amazon.com/step-functions/latest/dg/concepts-states.html)
- [Amazon States Language](https://docs.aws.amazon.com/step-functions/latest/dg/concepts-amazon-states-language.html)
- [Standard vs. Express Workflows](https://docs.aws.amazon.com/step-functions/latest/dg/concepts-standard-vs-express.html)
- [Fehlerbehandlung: Retry und Catch](https://docs.aws.amazon.com/step-functions/latest/dg/concepts-error-handling.html)
- [Task-Zustand](https://docs.aws.amazon.com/step-functions/latest/dg/state-task.html)
- [Choice-Zustand](https://docs.aws.amazon.com/step-functions/latest/dg/state-choice.html)
- [Tutorial: State Machine mit Lambda](https://docs.aws.amazon.com/step-functions/latest/dg/tutorial-creating-lambda-state-machine.html)
