Der erste Go-Service neben einem PHP-Monolithen ist schnell geschrieben. Das Problem kommt zwei Wochen später, und es sieht so aus: Der neue Service hat eine Struktur mit einem Feld kd_stat, weil die alte Tabelle diese Spalte hat, und ein Feld flag2, weil niemand mehr weiß, was es bedeutet, aber der Bericht es braucht.
Damit ist der neue Service kein neuer Service mehr. Er ist ein zweites Stück Altsystem, in einer anderen Sprache, und das Modell, dem man entkommen wollte, hat den Sprung mitgemacht.
Die Schicht, die das verhindert, heißt Anti-Corruption Layer. Dieser Artikel beschreibt, wo sie liegt, was sie übersetzt, was sie nebenbei erledigt und wann sie wieder verschwinden darf.
Das alte Modell wandert mit, wenn man es lässt
Der Mechanismus ist leise und sehr zuverlässig. Der neue Service braucht Kundendaten, also holt er sie aus der bestehenden Tabelle. Am einfachsten ist es, die Spalten zu übernehmen, weil dann kein Umrechnen nötig ist. Zwei Wochen später hängen fünf Stellen im neuen Code an diesen Feldnamen, und das alte Modell ist zementiert.
Das Ergebnis ist schlechter als der Ausgangszustand. Vorher gab es ein altes Modell an einer Stelle. Jetzt gibt es dasselbe alte Modell an zwei Stellen, in zwei Sprachen, und jede Änderung daran braucht beide.
Die Schicht dagegen ist keine neue Erfindung, sondern eine Entscheidung darüber, wo übersetzt wird. Sie kostet beim ersten Mal einen halben Tag, und sie ist der Unterschied zwischen einem Dienst, der später eigenständig wird, und einem, der es nie wird.
Was die Schicht ist, und was sie nicht ist
Eine Anti-Corruption-Schicht ist eine schmale Menge Code auf der neuen Seite, die alles übersetzt, was aus der alten Welt hereinkommt, und alles, was hinausgeht. Hinter ihr kennt niemand die alten Begriffe.
Drei Missverständnisse lohnen es, ausgeräumt zu werden.
Sie ist kein eigener Dienst. Ein zusätzlicher Prozess in der Mitte verdoppelt die Ausfallstellen und die Betriebsarbeit. Die Schicht ist ein Paket im neuen Dienst, keine eigene Anwendung.
Sie ist kein Adapter um jeden Aufruf. Wer jede Funktion der alten Welt spiegelt, hat die alte Schnittstelle nachgebaut und nichts gewonnen. Die Schicht bildet das ab, was der neue Dienst braucht, nicht das, was die alte Welt anbietet.
Sie ist nicht dauerhaft gedacht. Sie ist eine Brücke für die Zeit, in der beide Modelle existieren. Ohne Enddatum wird sie selbst zum Bestandteil, den niemand mehr anfasst.
Welcher Teil überhaupt zuerst herausgelöst wird, entscheidet sich vorher: Welchen Service Sie zuerst aus dem Monolithen lösen.
Wo die Schicht liegt
Die Frage entscheidet über den Nutzen, und sie wird oft falsch beantwortet: Die Schicht gehört auf die neue Seite, nicht in die Mitte und schon gar nicht in den Monolithen.
Im Monolithen wäre sie eine weitere Stelle im Altsystem, die jemand pflegen muss, und sie würde beim Umbau des Monolithen mitleiden. In der Mitte wäre sie ein Prozess mehr. Auf der neuen Seite ist sie Teil des Dienstes, wird mit ihm ausgerollt, mit ihm getestet und mit ihm entfernt.
Praktisch sieht das so aus: Der neue Dienst hat ein Paket, das die einzige Stelle ist, die alte Begriffe kennt. Alles andere im Dienst arbeitet mit den eigenen Begriffen.
interner/
├── abrechnung/ Fachlogik. Kennt nur eigene Begriffe.
│ └── kunde.go type Kunde struct { ID, Tarif, AktivSeit ... }
└── altsystem/ Die Schicht. Einzige Stelle mit alten Begriffen.
├── leser.go Liest aus der alten Datenbank
└── uebersetzung.go Macht aus kd_stat einen TarifWer aus PHP kommt, stolpert dabei über andere Dinge als erwartet: Go lernen als PHP-Entwickler.
Drei Sorten Unterschied, die übersetzt werden
Was die Schicht tut, zerfällt in drei Kategorien mit sehr unterschiedlichem Aufwand. Die dritte ist die, die Projekte kostet.
Benennung. kd_stat wird zu Tarif, anlage_dat zu AngelegtAm. Das ist mechanisch, langweilig und die halbe Miete: Allein dadurch liest sich der neue Code wie neuer Code.
Struktur. Die alte Welt hat eine breite Tabelle mit vierzig Spalten, von denen zwölf nur für einen Sonderfall gefüllt sind. Die neue Seite hat ein Objekt mit einem klaren Zuschnitt. Die Schicht wirft weg, was der Dienst nicht braucht, und das ist ihre wichtigste Eigenschaft: Sie reicht nicht alles durch.
Bedeutung. Hier wird es teuer. status = 7 heißt „gekündigt, aber noch im laufenden Monat aktiv", und das weiß nur eine Person. Solche Regeln stehen nirgends, und sie sind der eigentliche Wert der Schicht: Sie werden dort einmal aufgeschrieben, mit einem Kommentar, statt an fünf Stellen neu erraten zu werden.
// Tarifzustand aus der alten Spalte kd_stat.
//
// Die Zahlen stammen aus dem Abrechnungssystem von 2011. 7 und 8 meinen
// beide "gekuendigt": 7 ist die Kuendigung zum Monatsende (Vertrag laeuft
// also noch), 8 die sofortige. Wer das zusammenfasst, schaltet Kunden zu
// frueh ab. Geklaert mit der Fachabteilung am 2026-10-08.
func tarifZustand(kdStat int) (abrechnung.Zustand, error) {
switch kdStat {
case 1, 2:
return abrechnung.Aktiv, nil
case 7:
return abrechnung.GekuendigtZumMonatsende, nil
case 8:
return abrechnung.SofortGekuendigt, nil
case 9:
return abrechnung.Gesperrt, nil
default:
// Bewusst ein Fehler statt eines Standardwerts: ein unbekannter
// Zustand ist ein Datenfund, kein Normalfall.
return 0, fmt.Errorf("unbekannter kd_stat %d", kdStat)
}
}Der default-Zweig ist die wichtigste Zeile des Beispiels. Ein unbekannter Wert wird zum Fehler und nicht stillschweigend zu Aktiv. In jedem Altsystem gibt es Werte, mit denen niemand rechnet, und die Schicht ist die Stelle, an der sie auffallen.
Der Lesepfad, und warum er eine eigene Struktur bekommt
Die Versuchung ist groß, die Datenbankzeile direkt in das Fachobjekt zu lesen. Damit steht die alte Struktur wieder im neuen Code, nur unsichtbar.
Zwei Strukturen sind hier kein Mehraufwand, sondern die ganze Idee:
// Innerhalb der Schicht: bildet die alte Tabelle ab, Namen wie dort.
type kundeZeile struct {
KdNr int
KdStat int
AnlageDat sql.NullTime
Flag2 sql.NullString // Bedeutung ungeklaert, siehe unten
}
// Was der Dienst bekommt. Kennt kd_stat nicht.
func (l *Leser) Kunde(ctx context.Context, id int) (abrechnung.Kunde, error) {
var z kundeZeile
err := l.db.QueryRowContext(ctx,
`SELECT kd_nr, kd_stat, anlage_dat, flag2 FROM kunde WHERE kd_nr = ?`, id).
Scan(&z.KdNr, &z.KdStat, &z.AnlageDat, &z.Flag2)
if err != nil {
return abrechnung.Kunde{}, fmt.Errorf("kunde %d lesen: %w", id, err)
}
zustand, err := tarifZustand(z.KdStat)
if err != nil {
return abrechnung.Kunde{}, fmt.Errorf("kunde %d: %w", id, err)
}
return abrechnung.Kunde{
ID: z.KdNr,
Zustand: zustand,
AngelegtAm: z.AnlageDat.Time,
}, nil
}Zu Flag2: Ein Feld, dessen Bedeutung niemand kennt, wird in der Schicht gelesen und dort stehen gelassen, mit einem Kommentar. Es wandert nicht nach innen. Wenn sich später herausstellt, wofür es steht, ändert sich genau eine Datei.
Der Schreibpfad und die Fehler
In Richtung Altsystem übersetzt die Schicht zurück, und sie erledigt dabei zwei Dinge, die sonst über den ganzen Dienst verstreut wären.
Zeitüberschreitungen. Der Aufruf ins Altsystem bekommt eine Frist. Ohne die erbt der neue Dienst die Antwortzeiten des alten, und ein langsamer Monolith macht den schnellen Dienst langsam. Die Frist gehört in die Schicht, weil sie eine Eigenschaft der Verbindung ist und nicht der Fachlogik.
Die Übersetzung der Fehler. Ein Zeitfehler der Datenbank ist für den Dienst kein Datenbankfehler, sondern ein „Altsystem nicht erreichbar". Innen wird darauf reagiert, ohne dass die Fachlogik weiß, was eine Datenbank ist.
var ErrAltsystemNichtErreichbar = errors.New("altsystem nicht erreichbar")
func (l *Leser) mitFrist(ctx context.Context) (context.Context, context.CancelFunc) {
// 800 ms: gemessen liegt das 99. Perzentil des Monolithen bei 610 ms.
// Kein runder Wert aus dem Bauch, sondern die Messung plus Luft.
return context.WithTimeout(ctx, 800*time.Millisecond)
}Auf der PHP-Seite bleibt der Aufruf bewusst dünn. Der Monolith kennt den neuen Dienst über genau eine Klasse, und die liefert im Fehlerfall das alte Verhalten:
public function tarifZustand(int $kundeId): string
{
try {
return $this->abrechnungsdienst->zustand($kundeId);
} catch (DienstNichtErreichbar) {
// Rueckfall auf den alten Weg, solange es ihn noch gibt.
// Verschwindet mit dem alten Code, nicht vorher.
return $this->alterZustand($kundeId);
}
}Wann die Schicht wieder verschwindet
Eine Anti-Corruption-Schicht ist eine Brücke. Brücken werden abgebaut, wenn beide Ufer zusammengewachsen sind, und das ist hier der Fall, wenn die alten Daten nicht mehr die Quelle sind.
Der Weg dorthin ist der übliche: Der Dienst bekommt eigene Daten, eine Weile werden beide geschrieben, dann wird der Lesepfad umgeschaltet, dann fällt der alte weg. Erst in diesem letzten Schritt verschwindet die Schicht, und zwar vollständig, inklusive der Übersetzungsfunktionen.
Was nicht verschwindet, sind die Kommentare. Die Regel, dass 7 und 8 zwei verschiedene Kündigungen sind, ist Fachwissen und gehört dann in die Fachlogik, nicht in den Papierkorb. Das ist der eigentliche Ertrag der ganzen Übung: Wissen, das in einer Spalte ohne Dokumentation steckte, steht danach als Code mit einer Begründung da.
Solange die Schicht existiert, gehört an sie ein Datum und ein Name. Ohne beides ist sie in zwei Jahren der Teil des Systems, den niemand anfasst, weil niemand mehr weiß, welche Übersetzungen noch gebraucht werden.
Wie Go-Services neben einem PHP-System überhaupt eingebunden werden, steht in einem eigenen Artikel; wie ich so ein Vorhaben aufsetze, auf einer eigenen Seite.
Dieser Artikel gehört zu einer Reihe über Systeme, die es schon gibt. Der Rückblick ordnet alle Artikel der Reihe nach Anlass.

