Das Interface passt. Das Verhalten nicht.

Das Interface passt. Das Verhalten nicht.

Table of Contents

Das Interface passt. Das Verhalten nicht.

Warum austauschbare Komponenten mehr brauchen als dieselben Methoden.

In einem fiktiven Entwicklungsteam soll der Zahlungsanbieter gewechselt werden. Der neue Adapter ist fertig, die Methodennamen stimmen, die Tests sind grün. Im Architekturdiagramm wird ein Kästchen ausgetauscht. Endlich Lego für Erwachsene.

Dann fragt die Produktverantwortliche: „Was passiert, wenn der Kunde bezahlt und wir keine Antwort bekommen?“

„Dann versuchen wir es noch mal.“

„Und wenn wir inzwischen auf den neuen Anbieter umgestellt haben?“

Das Diagramm schweigt. Diagramme sind in solchen Momenten erstaunlich geschickt.

Austauschbarkeit zeigt sich daran, welche Zusagen nach dem Wechsel noch gelten. Ob eine Methode weiterhin charge() heißt, ist dabei die leichteste Prüfung. Spannend wird es, wenn etwas schiefgeht.

Der Typcheck bezahlt keine Rückerstattung

Die ursprüngliche Schnittstelle ist angenehm klein:

from typing import Protocol


class PaymentGateway(Protocol):
    def charge(self, amount_minor: int, currency: str) -> bool: ...

amount_minor ist der Betrag in der kleinsten Währungseinheit, bei Franken also Rappen. True bedeutet Erfolg. Aber was bedeutet False? Fachlich abgelehnt? Noch in Bearbeitung? Verbindung unterbrochen? Ein Adapter kann all das auf denselben Wert abbilden und den Aufrufer damit vor völlig unterschiedliche Entscheidungen stellen.

Ein Python-Protocol hilft einem Typchecker, die Struktur zu prüfen. Es beweist kein Geschäftsverhalten. Typannotationen werden von der Python-Laufzeit außerdem nicht automatisch erzwungen. [1] Von einem passenden Methodennamen auf gleiche Zusagen zu schließen, wäre ungefähr so, als nähme das Maßband auch den Brandschutz ab.

Der Unterschied wird beim Timeout teuer: Wir wissen zunächst nur, dass innerhalb der Wartefrist keine verwertbare Antwort eingetroffen ist. Der Anbieter könnte die Zahlung trotzdem verarbeitet haben. Eine zweite Zahlung ist deshalb keine harmlose Wiederholung. [3]

Zahlungszustand und Wissensstand auseinanderhalten

„Ungeklärt“ ist kein zusätzlicher Zahlungszustand beim Anbieter. Es beschreibt unseren Wissensstand. Dort könnte die Zahlung bereits erfolgreich sein, während wir noch auf einen Nachweis warten.

Das Team hält deshalb fest, welche Aussagen es zuverlässig unterscheiden kann:

Unsere BeobachtungBedeutungNächste Handlung
Erfolg bestätigtDer Einzug wurde bestätigt.Bestellung fortsetzen.
Ablehnung bestätigtEine fachliche Ablehnung liegt vor.Kunden eine passende Alternative anbieten.
Bearbeitung bestätigtDie Operation wurde angenommen; das Ergebnis steht aus.Ursprünglichen Vorgang nachverfolgen.
Kein belastbarer NachweisDer tatsächliche Zustand ist unbekannt.Ursprünglichen Vorgang klären.

Auch „Erfolg“ braucht eine genaue Definition. Für dieses Beispiel vereinbart das Team den bestätigten Einzug; eine bloße Autorisierung reicht dafür nicht. Der Adapter darf diese Schritte nicht stillschweigend gleichsetzen. Nach einer vereinbarten Frist gehen offene Vorgänge mit Anbieterkennung an eine zuständige Rolle; der Kunde erhält eine dazu passende Meldung. Sonst wird aus vorsichtiger Fehlerbehandlung eine endlose Warteschleife mit Bestellnummer.

Ein Schlüssel verbindet keine zwei Anbieter

Ein Idempotenzschlüssel hilft dem Anbieter, mehrere Anfragen derselben Operation zuzuordnen. Unser System muss diese Kennung vor dem ersten Versuch speichern und bei zulässigen Wiederholungen mit unveränderten Parametern wiederverwenden.

Die Zusage hat Grenzen. Stripe dokumentiert für API v1 beispielsweise eine begrenzte Aufbewahrung und die Wiederholung gespeicherter Antworten, einschließlich bestimmter Fehlerantworten. API v2 hat andere Regeln. Entscheidend ist die dokumentierte Zusage des verwendeten Endpunkts. [2]

Vor allem kennt Anbieter B die Operation bei Anbieter A nicht automatisch. Derselbe Schlüssel ist kein gemeinsames Gedächtnis. Deshalb legt unser Team fest: Neue Vorgänge dürfen über B laufen; ein ungeklärter Versuch bei A bleibt zunächst A zugeordnet. Vor einem neuen Zahlungsversuch wird sein Ausgang über den vorgesehenen Klärungsweg abgeglichen. [3]

Das ist eine bewusste Entscheidung für dieses Beispiel. Ein transparenter automatischer Anbieterwechsel wird damit gerade nicht versprochen.

Vom Architekturwort zur überprüfbaren Zusage

Für den Wechsel reicht dem Team zunächst diese kleine Vorlage. Sie benennt, was der Aufrufer braucht und woran die Umsetzung gemessen wird:

VertragsfrageVereinbarung im BeispielNachweis
Was heißt Erfolg?Der Einzug wurde bestätigt; eine Autorisierung allein reicht nicht.Anbieterantwort wird korrekt übersetzt.
Was heißt Fehler?Fachliche Ablehnung und fehlender Nachweis bleiben unterscheidbar.Antwortverlust nach Verarbeitung simulieren.
Was darf eine Wiederholung bewirken?Kein blinder zweiter Zahlungsversuch; Kennung, Parameter und Anbieter bleiben gebunden.Dieselbe Operation erneut übergeben und einen Anbieterwechsel versuchen.
Wer klärt offene Fälle?Zuständige Rolle und Frist sind vereinbart.Übergabe und Kundenmeldung prüfen.
Wo endet die Zusage?Nur die vereinbarten Fähigkeiten und dokumentierten Fristen werden unterstützt.Nicht unterstützte Fälle und Fristablauf prüfen.

Das ist der eigentliche Vertrag. Sein Python-Interface bildet einen Teil davon ab. Die verbleibenden Zusagen brauchen Tests und einen nachvollziehbaren Betriebsablauf.

Im ausführbaren Beispiel im Anhang speichert ein Journal die Anbieterzuordnung vor dem Aufruf. Der Test simuliert eine verlorene Antwort und prüft, dass weder eine erneute Übergabe noch ein Wechsel auf B eine zweite Zahlung auslöst. Er prüft unsere lokale Ablaufentscheidung; für den realen Anbieter brauchen wir zusätzlich Adapter- und Integrationsprüfungen.

Ein Mock, der immer True zurückgibt, hat dagegen vor allem eines bewiesen: Wir haben ihn erfolgreich auf True eingestellt.

Austauschbar für welche Aufgabe?

Der Vertrag muss keine Super-API werden, die jede Spezialfunktion versteckt. Unterstützt B den benötigten Zahlungsschritt nicht, ist B für diesen Ablauf kein gleichwertiger Ersatz. Ein Adapter kann Unterschiede übersetzen, aber keine fehlende Fähigkeit herbeideklarieren.

Dasselbe gilt für andere Komponenten: Bei einem Parser interessiert uns, ob fehlender Text als leeres Ergebnis oder als Fehler erscheint. Bei einem Speicheradapter, wann andere Leser einen Schreibvorgang sehen. Bei einem Sprachmodell, welches Antwortformat tatsächlich zugesagt wird. Welche Unterschiede zählen, folgt jeweils aus der Aufgabe des Aufrufers.

Meine Empfehlung fürs nächste Review: Nehmt einen geplanten Komponententausch. Füllt die fünf Vertragsfragen oben aus und testet zuerst den Fehlerfall, bei dem ein falscher zweiter Versuch besonders teuer wäre. Das verlangt weder einen neuen Service noch ein neues Framework. Es verlangt eine gemeinsame Antwort auf die Frage, was nach dem Wechsel weiterhin gelten muss.

Das Team kann seinen Zahlungsanbieter jetzt immer noch wechseln. Der Kunde sollte für unseren Wunsch nach sauberer Architektur schließlich nicht zweimal bezahlen.

Bei welchem eurer letzten Komponententausche passte das Interface – und welche Verhaltensannahme ist trotzdem gebrochen?

Anhang: Python-Beispiel und Test

Nach dem Timeout stehen bleiben

Im folgenden Lernbeispiel gibt es eine feste Operation und eine Beobachtung dazu. None bedeutet: kein belastbarer Nachweis. Das Journal merkt sich die Operation vor dem externen Aufruf. Ein erneuter lokaler Aufruf liefert den gespeicherten Stand; eine nachträglich geänderte Anbieterzuordnung wird abgewiesen.

from dataclasses import dataclass
from enum import Enum
from typing import Protocol


class Status(Enum):
    CONFIRMED = "confirmed"  # Einzug bestätigt
    DECLINED = "declined"
    PENDING = "pending"


@dataclass(frozen=True)
class Operation:
    key: str
    provider: str
    amount_minor: int
    currency: str


@dataclass(frozen=True)
class Observation:
    status: Status | None


class Gateway(Protocol):
    def charge(self, operation: Operation) -> Observation: ...


Journal = dict[str, tuple[Operation, Observation]]


def submit_once(
    operation: Operation,
    gateways: dict[str, Gateway],
    journal: Journal,
) -> Observation:
    if operation.key in journal:
        previous, observation = journal[operation.key]
        if previous != operation:
            raise ValueError("Operation darf nicht umgebogen werden")
        return observation

    gateway = gateways[operation.provider]
    journal[operation.key] = (operation, Observation(None))
    try:
        observation = gateway.charge(operation)
    except TimeoutError:
        return journal[operation.key][1]

    journal[operation.key] = (operation, observation)
    return observation

Diese Funktion führt bewusst keinen externen Retry aus. Ein eigener Klärungsablauf muss offene Beobachtungen später durch bestätigte Ergebnisse ersetzen. Der jeweilige Adapter übergibt die Operationskennung als Idempotenzschlüssel gemäß der Anbieter-API; das erledigt ein Protocol nicht für ihn.

Grenze des Beispiels: Das Dictionary ist nur ein flüchtiges Journal für eine einzelne, sequenzielle Ausführung. Nach einem Prozessneustart ist es verloren; parallele Zugriffe sind nicht abgesichert. Für einen echten Zahlungspfad braucht es dauerhafte Speicherung und eine atomare Reservierung der Operationskennung vor dem externen Aufruf. Dieser Code ist keine fertige Zahlungsintegration und kein allgemeiner Exactly-once-Nachweis.

Der Test beginnt dort, wo die Antwort verloren geht

Ein Fake simuliert die unangenehme Reihenfolge: Die Zahlung wirkt bereits, dann geht die Antwort verloren. Wir übergeben dieselbe Operation erneut und versuchen anschließend, sie auf Anbieter B umzubiegen. Das Journal bleibt dabei dasselbe.

Dieser Test steht direkt unter dem vorherigen Code und lässt sich mit python -m unittest beispiel.py -v ausführen:

import unittest
from dataclasses import replace


class LostReplyGateway:
    def __init__(self):
        self.effects = 0

    def charge(self, operation: Operation) -> Observation:
        self.effects += 1
        raise TimeoutError("Zahlung verarbeitet, Antwort verloren")


class PaymentContractTest(unittest.TestCase):
    def test_lost_reply_does_not_start_another_payment(self):
        first, second = LostReplyGateway(), LostReplyGateway()
        gateways: dict[str, Gateway] = {"A": first, "B": second}
        operation = Operation("op-42", "A", 2500, "CHF")
        journal: Journal = {}

        result = submit_once(operation, gateways, journal)
        again = submit_once(operation, gateways, journal)
        with self.assertRaises(ValueError):
            submit_once(replace(operation, provider="B"), gateways, journal)

        self.assertIsNone(result.status)
        self.assertEqual(again, result)
        self.assertEqual(first.effects, 1)
        self.assertEqual(second.effects, 0)

Unser Checkout schützt damit dieselbe Operation vor einem zweiten lokalen Zahlungsversuch. Ob Anbieter B die übrigen Vertragsfragen genauso erfüllt wie A, müssen dessen Adaptertests zeigen. Der Test beweist noch nicht, dass ein realer Anbieter eine Anfrage richtig verarbeitet oder dessen Adapter die richtigen Statuswerte liefert. Dafür braucht es zusätzliche Adapter- und Integrationsprüfungen mit realistischen Antworten. Ein Testdouble allein kann vom tatsächlichen Dienst abweichen. [4]

Öffentliche Fachquellen

Zuletzt geprüft am 22.10.2026:

  1. Python: typing und Protocol – strukturelle Typkompatibilität und Grenzen von Typannotationen.
  2. Stripe: Idempotent requests – gespeicherte Ergebnisse, Grenzen und unterschiedliche Regeln der API-Namespaces.
  3. Stripe: Advanced error handling – unbekannter Ausgang, Wiederholungen und Abgleich der ursprünglichen Operation.
  4. Martin Fowler: Contract Test – Testdoubles mit dem tatsächlichen Verhalten externer Dienste abgleichen. Das Prinzip ist etabliert; dieser Beitrag zeigt seine Anwendung auf einen Anbieterwechsel.
Share :
comments powered by Disqus

Related Posts

Eine Datenbank ist keine API... oder doch?

Eine Datenbank ist keine API... oder doch?

Zusammenfassung für Entscheidungsträger (TL;DR) Die Frage: Kann eine Datenbank als API fungieren? Technisch gesehen, ja. Strategisch gesehen ist es ein Fehler.

Mehr erfahren
Die 1.500-Zeilen-Klasse: Eine Tragödie in drei Akten (und ohne Happy End)

Die 1.500-Zeilen-Klasse: Eine Tragödie in drei Akten (und ohne Happy End)

Vorab: Die 60.000-Zeilen-Falle (oder: Warum Exponentialwachstum bei Code schmerzt) Die Mathematik der Verzweiflung:

Mehr erfahren