Adapter-Port-Architektur in zwei echten Codebases
Dieser Beitrag basiert auf dem Artikel „Adapter Port Architecture In Two Real Codebases” von Saad Hasan. Der Originalartikel zeigt das Pattern anhand von zwei realen Projekten mit interaktiven Demos. Hier ist die Kernaussage zusammengefasst und auf die wesentlichen Konzepte reduziert.
Das Problem: Kopplung an externe Abhängigkeiten
Stell dir folgendes Szenario vor. Im Standup sagt die Finanzabteilung, dass die E-Mail-Kosten zu hoch sind. Der Plan: bis Monatsende auf einen günstigeren Anbieter wechseln. Klingt einfach. Dann suchst du im Code nach dem alten E-Mail-SDK und findest 28 Dateien, die es direkt verwenden. Bestellbestätigungen im Order-Service. Passwort-Resets in der Auth-Logik. Erstattungsbenachrichtigungen im Admin-Panel. Wöchentliche Digests in einem Cron-Job, den seit einem Jahr niemand mehr angeschaut hat. Jede Datei ruft das SDK auf ihre eigene Art auf, mit eigener Retry-Logik und eigenen Template-Eigenheiten.
28 Dateien lesen, 28 ändern und 28 Gelegenheiten, einen E-Mail-Flow stillschweigend zu brechen, weil der neue Anbieter Fehler in einer leicht anderen Form meldet. Das ist die reale Konsequenz von Kopplung.
Das Problem ist nicht der Anbieter. Es ist die Tatsache, dass die Geschäftslogik die exakte API-Oberfläche eines externen Dienstes kennt. Jede Änderung an diesem Dienst sickert in die Geschäftslogik zurück. Eine saubere Integration in einer Datei ist kein Problem. Dieselbe Integration verstreut über 30 Dateien ist eines.
Wie das Pattern funktioniert
Die Lösung besteht aus einem einzigen Prinzip: ein Interface zwischen Geschäftslogik und Außenwelt setzen. Die Geschäftslogik hängt nur vom Interface ab, nie direkt vom Anbieter.
Dieses Prinzip hat drei konkrete Bestandteile.
Port. Ein Interface, das beschreibt, was unsere Geschäftslogik tun will. In unserer eigenen Sprache. „Sende eine E-Mail.” „Belaste eine Karte.” Kein Stripe, kein SendGrid, kein HTTP. Nur ein Vertrag.
Adapter. Eine konkrete Implementierung, die genau einen Anbieter kapselt. Der Stripe-Adapter kennt Stripe. Der bKash-Adapter kennt bKash. Die Geschäftslogik kennt keines von beiden.
Registry. Die einzige Datei, die weiß, welcher Adapter zur Laufzeit aktiv ist. Geschäftslogik importiert nie direkt aus dem Adapters-Ordner.
Die Ordnerstruktur
Drei Ordner reichen aus:
app/
checkout.ts ← Geschäftslogik, importiert nur Ports
webhooks.ts ← Geschäftslogik
refunds.ts ← Geschäftslogik
lib/
ports/
payments.ts ← PaymentGateway Interface
mailer.ts ← Mailer Interface
adapters/
bkash.ts
stripe.ts
sslcommerz.ts
resend.ts
sendgrid.ts
registry.ts ← Runtime-Verdrahtung
Die Regel, die alles zusammenhält: Dateien in app/ importieren aus lib/ports. Sie importieren nie aus lib/adapters. Wenn diese eine Regel eingehalten wird, funktioniert der Rest.
Pfeile zeigen immer nach innen, zum Port. Geschäftslogik greift nicht nach außen zu Adaptern. Adapter passen sich einer Form an, die die Geschäftslogik bereits definiert hat. Das ist Dependency Inversion.
Praxisbeispiel 1: Stripe zu einem bKash-Backend hinzufügen
Das erste Projekt war ein Node.js-Backend für einen Marktplatz in Bangladesch. bKash war der primäre Payment-Provider. Nach sechs Monaten wollte das Produktteam Stripe für internationale Karten, mit SSLCommerz als Fallback. Das Problem: jede Checkout-Route, jeder Webhook-Handler und jedes Refund-Skript importierte das bKash-SDK direkt.
Der erste Schritt war der Port. Kein Code, nur das Interface. Was will unsere Geschäftslogik mit einem Payment-Provider tun?
// lib/ports/payments.ts
export interface ChargeRequest {
amount: number;
currency: string;
reference: string;
customerEmail: string;
}
export interface ChargeResult {
id: string;
status: "success" | "failed" | "pending";
}
export interface PaymentGateway {
createCharge(req: ChargeRequest): Promise<ChargeResult>;
refund(chargeId: string, amount: number): Promise<void>;
verifyWebhook(
payload: string,
signature: string
): boolean;
}
Kein bKash, kein Stripe, kein paymentID, kein statusCode. Nur unsere Begriffe. Die Geschäftslogik sieht so aus:
// app/checkout.ts
import { getPaymentGateway } from "@/lib/registry";
import { markOrderFailed } from "@/lib/orders";
import type { Order } from "@/lib/types";
export async function checkout(order: Order) {
const gateway = getPaymentGateway(order.region);
const charge = await gateway.createCharge({
amount: order.total,
currency: order.currency,
reference: order.id,
customerEmail: order.email,
});
if (charge.status === "failed") {
await markOrderFailed(order.id);
return null;
}
return charge.id;
}
Jeder Adapter kapselt ein SDK hinter demselben Interface. Der bKash-Adapter übersetzt bKash-Begriffe in unsere Port-Begriffe. Der Stripe-Adapter tut dasselbe für Stripe. Die Registry entscheidet, welcher Adapter für welche Region aktiv ist.
// lib/registry.ts
import { BkashAdapter } from "./adapters/bkash";
import { StripeAdapter } from "./adapters/stripe";
import { SslcommerzAdapter } from "./adapters/sslcommerz";
import type { PaymentGateway } from "./ports/payments";
const gateways: Record<string, PaymentGateway> = {
BD: new BkashAdapter(),
INTL: new StripeAdapter(),
BD_FALLBACK: new SslcommerzAdapter(),
};
export function getPaymentGateway(
region: string
): PaymentGateway {
return gateways[region] ?? gateways["INTL"];
}
Das Ergebnis: Stripe hinzufügen kostete etwa einen Tag. Eine neue Datei in adapters/, eine Zeile in der Registry. Checkout, Webhooks und Refunds haben sich nicht bewegt. Die ursprüngliche bKash-Integration hatte ungefähr eine Woche gedauert. Der Unterschied liegt in allem, was nicht angefasst werden musste.
Ein paar Monate später änderte bKash ein Feld in der Response bei einem kleinen SDK-Update. Der Fix war vier Zeilen, alle in adapters/bkash.ts. Der Rest der Codebase hat davon nichts mitbekommen.
Praxisbeispiel 2: 40 E-Mail-Stellen migrieren
Dieselbe Codebase, anderes Problem. Etwa 40 Stellen sendeten E-Mails. Bestellbestätigungen, Passwort-Resets, Erstattungen, 2FA-Codes, wöchentliche Digests, Admin-Alerts. Jede rief das SDK auf ihre eigene Art auf. Manche übergaben rohes HTML. Manche nutzten Template-IDs. Der Signup-Flow hatte eine Retry-Schleife. Der Refund-Flow hatte gar kein Retry. Das Audit-Log war bestenfalls inkonsistent.
Als der Provider-Wechsel anstand, vermied das Team das Thema drei Wochen lang. Der Fix war dieselbe Struktur.
// lib/ports/mailer.ts
export interface Mailer {
send(
to: string,
subject: string,
body: string
): Promise<void>;
sendTemplate(
to: string,
templateId: string,
vars: Record<string, unknown>
): Promise<void>;
}
Die Migration lief inkrementell über vier Wochen. In der ersten Woche wurden Port und Adapter für den bestehenden Provider geschrieben. In Woche zwei und drei ersetzte jeweils ein PR pro Datei den direkten SDK-Aufruf durch mailer.send(...). In Woche vier kam der zweite Adapter dazu, wurde im Staging getestet und die Registry umgestellt. Eine Zeile. Der Produktions-Traffic lief auf dem neuen Provider.
Der größere Gewinn war unerwartet: Retry und Audit-Log wurden hinter dem Port implementiert. Einmal. Vorher hatten nur manche E-Mails ein Retry. Danach alle. Kein Aufrufender schrieb Retry-Code. Das passierte im Adapter.
Praxisbeispiel 3: Frontend ohne Backend
Anderes Projekt, anderes Problem. Ein Sports-Tracking-Dashboard. Standings, Spieler-Rankings, Live-Matches, kommende Fixtures. Designs waren freigegeben. Das Frontend-Team hatte drei Wochen bis zum Launch. Das Backend-Team brauchte sechs Wochen.
Der Reflex wäre Mocking gewesen. Fetch stubben, statische Daten reingeben, später die echten APIs einbauen. Das funktioniert für ein oder zwei Endpoints. Hier waren es über zwanzig, und sie würden zu verschiedenen Zeitpunkten über sechs Wochen hinweg fertig.
Die Lösung: Adapter-Port im Frontend. Komponenten riefen Funktionen wie getStandingsRepo().listGroups() auf. Ob die Daten aus einem In-Memory-Array oder einem echten Fetch kamen, war Sache der Registry.
app/lib/
ports/
standings.ts ← StandingsRepo Interface
player-ranks.ts ← PlayerRanksRepo Interface
matches.ts ← MatchesRepo Interface
fixtures.ts ← FixturesRepo Interface
adapters/
static/ ← In-Memory-Adapter
standings.ts
player-ranks.ts
matches.ts
fixtures.ts
api/ ← Echte HTTP-Aufrufe
standings.ts
player-ranks.ts
matches.ts
fixtures.ts
registry.ts ← Toggle pro Ressource
Die statischen Adapter lieferten exakt die Form, die die echten APIs später liefern würden. Das Frontend-Team baute gegen diese Formen mit statischen Daten. Das Backend-Team baute die Endpoints passend dazu.
Als das Backend den Standings-Endpoint zuerst lieferte, wurde ein Toggle in der Registry umgestellt. Die Standings-Seite lief auf echten Daten. Kein Komponenten-File wurde geändert. Player Ranks kamen zwei Wochen später. Derselbe Flip. Bis zum Launch lief jede Seite auf echten Daten und keine einzige Komponente wurde während der Migration angefasst.
Ein ungeplanter Nebeneffekt: die statischen Adapter blieben im Repo. Sie dienen bis heute als deterministische Testdaten ohne laufende API.
Was das Pattern konkret bringt
Die offensichtliche Zahl: 2 Dateien geändert beim Hinzufügen des zweiten Payment-Providers. 1 Datei geändert beim E-Mail-Provider-Wechsel. 0 Komponenten angefasst bei der Frontend-API-Migration. 4 Zeilen geändert bei einer bKash-Response-Regression.
Die weniger offensichtlichen Vorteile sind größer. Tests werden einfacher, weil der Test einfach einen Fake-Adapter erhält, der den Port implementiert. Kein jest.mock(), kein Module-Patching. Paralleles Arbeiten wird möglich, weil Frontend und Backend unabhängig voneinander bauen. Der aufrufende Code wird sauberer, auch wenn nie ein Wechsel stattfindet, weil Provider-spezifische Eigenheiten nicht mehr in die Geschäftslogik sickern.
Was es kostet
Es sind mehr Dateien. Ein direkter SDK-Aufruf lebt in einer Datei. Dasselbe über einen Port lebt in dreien. Bei kleinen Projekten ist dieser Overhead real und nicht lohnenswert.
Der Port muss gut entworfen sein. Ein schlechter Port spiegelt den Provider ins Interface und bringt keinen Vorteil. Wenn der Port eine Methode createBkashPayment hat, ist das kein Port. Das ist ein Wrapper. Ein echter Port spricht in der Sprache unserer Domäne. Das ist der Teil, den Anfänger am häufigsten falsch machen.
Beim Debugging gibt es einen kleinen Overhead. Wenn mailer.send(...) im Stack-Trace steht, muss man in die Registry springen, um zu sehen, welcher Adapter aktiv ist. IDE-Navigation löst das, aber es ist ein zusätzlicher Schritt.
Der erste Port wird vermutlich falsch sein. Die richtige Form zeigt sich erst beim zweiten Adapter, wenn man spürt, wo die Annahmen des ersten brechen. Ein Refactoring des Ports nach dem zweiten Adapter sollte eingeplant werden.
Wann das Pattern sinnvoll ist
Wenn die Abhängigkeit voraussichtlich ausgetauscht oder erweitert wird und der aufrufende Code über viele Dateien verteilt ist. Payment, E-Mail, SMS, Suche, Dateispeicher, Queues, Drittanbieter-APIs. Alles, was pro Aufruf abgerechnet wird, reguliert ist oder bei dem ein Anbieterwechsel innerhalb von sechs Monaten realistisch ist.
Ebenso sinnvoll, wenn die echte Implementierung noch nicht existiert, aber der Code dagegen gebaut werden muss. Frontend ohne Backend. Ein neues Feature, das von einem Service abhängt, den es noch nicht gibt. Eine Migration, bei der altes und neues System parallel laufen müssen.
Wann man es weglassen sollte
In den meisten Fällen. Der Großteil des Codes hat dieses Problem nicht. Wenn eine Bibliothek stabil ist, kein Austausch geplant ist und sie in ein oder zwei Dateien verwendet wird, sollte man die Bibliothek einfach direkt aufrufen.
Eine Faustregel, die sich bewährt hat: die Regel der Zwei. Eine Implementierung und kein realer Plan für eine zweite, kein Port. Zwei Implementierungen oder ein konkretes Gespräch über eine zweite, Port bauen. Drei, er sollte schon existieren.
Die Kosten eines Ports fallen am ersten Tag und bei jedem Lesen danach an. Der Nutzen zahlt sich an dem Tag aus, an dem gewechselt oder erweitert wird. Kein Wechsel, keine Erweiterung, kein Nutzen. Die Rechnung ist hart für spekulative Abstraktionen.
Was man beim nächsten Mal anders machen sollte
Zwei Dinge fallen über alle drei Projekte auf.
Erstens: den Port vor dem ersten Adapter entwerfen. In beiden Backend-Fällen wurde zuerst der bKash-Code geschrieben und der Port danach. Der Port kam dann geformt wie bKash heraus. Das ist die falsche Reihenfolge. Der Port sollte die Form haben, die die Geschäftslogik braucht, nicht die Form, die der erste Provider zufällig exponiert.
Zweitens: den Fake-Adapter nach dem Go-Live des echten Adapters behalten. Im Frontend-Projekt wären die statischen Adapter fast gelöscht worden, als die echten APIs standen. Das wäre ein Fehler gewesen. Deterministische Testdaten ohne laufende API sind zu nützlich, um sie wegzuwerfen.
Wie handhabst du den Austausch externer Abhängigkeiten in deinen Projekten?
Häufig gestellte Fragen (FAQ)
Was ist Hexagonal Architecture bzw. Ports and Adapters? ↓
Hexagonal Architecture (auch Ports and Adapters oder Onion Architecture) trennt Geschäftslogik von externen Abhängigkeiten. Ein Port ist ein Interface, das beschreibt, was die Geschäftslogik tun will, ohne einen konkreten Anbieter zu nennen. Ein Adapter ist eine konkrete Implementierung, die einen Anbieter hinter dem Port-Interface kapselt. Eine Registry entscheidet zur Laufzeit, welcher Adapter aktiv ist.
Wann lohnt sich das Adapter-Port-Pattern? ↓
Wenn eine externe Abhängigkeit voraussichtlich ausgetauscht oder erweitert wird und der aufrufende Code über viele Dateien verteilt ist. Typische Anwendungsfälle sind Payment-Provider, E-Mail-Dienste, SMS, Suche, Dateispeicher und Queues. Auch wenn das Frontend vor dem Backend fertig sein muss, ist das Pattern sinnvoll.
Wann sollte man auf das Adapter-Port-Pattern verzichten? ↓
Wenn die Bibliothek stabil ist, kein Austausch geplant ist und der Aufruf in ein oder zwei Dateien stattfindet. Eine Faustregel: Erst bei zwei Implementierungen oder einem konkreten Plan für eine zweite lohnt sich der Aufwand.
Senior Full-Stack Developer mit Fokus auf stabiler Software-Architektur, pragmatischem Engineering und der Realität von KI im Entwickler-Alltag. Seine Wurzeln liegen im praktischen Lösen komplexer Probleme unter realen Bedingungen.
github.com/hyretic-dev