Diagram illustrating the concept of Defining Boundaries, featuring a vertical red line labeled Boundary. Component A and Component B are positioned on opposite sides, with arrows indicating two-way communication across the boundary.

Komplexität zu managen ist eine eigene Disziplin, und viele Bücher wurden darüber geschrieben. Unabhängig davon, welche ihr gemeistert habt, wird die Eingrenzung von Auswirkungen stets ganz oben auf der Liste stehen. Nehmen wir an, wir haben eine sehr große Code-Basis und möchten dort einige Änderungen vornehmen. Wir haben die Änderung vorgenommen, und nun ist irgendwo anders etwas kaputt. Ohne ordentliche Eingrenzung kann jede Codeänderung jeden anderen Teil der Code-Basis beschädigen. Stellt euch den Horror vor: Wir müssten jede mögliche Kombination, in der unsere Software verwendet wird, testen, um sicherzustellen, dass nichts kaputt ist. Nicht nur das – das Vertrauen ins Refactoring geht verloren, und niemand traut sich mehr, bestehenden Code anzufassen. Genau aus diesem Grund müssen wir Code in kleinere Teile aufteilen und Grenzen zwischen ihnen setzen.

Wie machen wir das? Ursprünglich wollte ich argumentieren, dass Code kein Verhalten weitergeben sollte, sondern in Daten kommunizieren sollte – also nur mit Ein- und Ausgabedaten. Dieser Ansatz hat mir überall, wo ich ihn angewendet habe, gute Dienste geleistet und schien mir die Wahrheit zu sein. Als ich versuchte zu begründen, warum das die beste Praxis ist, brachten mich andere Programmiermuster dazu, meine Sichtweise zu hinterfragen: Was ist mit Higher-Order-Funktionen? Diese injizieren Verhalten. Ich verwende sie auch sehr häufig. Dieselbe Frage kann für reines JavaScript gestellt werden. Habe ich die Gewissheit, dass die Eingabeparameter, die ich erhalte, vom richtigen Typ sind? Nicht wirklich. Ich kenne den Typ der Daten nicht. Da war ein blinder Fleck in meinem Wissen, den ich mir nicht bewusst war.

Von vorne beginnen

Fangen wir also ganz von vorne an. Wir haben Code-Teile – einige werden aufgerufen, andere rufen andere auf. Was wir erreichen wollen, ist, dass wenn wir eine Änderung in einem vornehmen, der Wirkungsbereich so klein wie möglich ist. Das können wir erreichen, indem wir klare Grenzen zwischen dem Code etablieren. Die erste Frage, die wir stellen müssen, lautet: Was sind Grenzen? Grenzen sind Wege, Eigenschaften zu kommunizieren. Ich akzeptiere Daten mit folgenden Eigenschaften. Ich habe folgende Eigenschaften bei der Ausführung und so weiter. Es ist die Sprache der Vertragsdefinition. Die zweite Frage lautet: Welche Wege existieren, um diese Verträge zu definieren? Aus meiner Erfahrung gibt es viele davon, darunter – aber nicht darauf beschränkt – das Typsystem, gemeinsame Konventionen und Dokumentation. Wir werden sie etwas später besprechen. Die dritte Frage lautet: Welche Eigenschaften haben diese verschiedenen Lösungen? Für das, was uns interessiert, können wir sie grob mit folgenden Eigenschaften kategorisieren:

  • Wann der Vertrag in Kraft tritt
  • Wie der Vertrag in Kraft tritt
  • Der Aufwand zur Pflege des Vertrags

Je früher der Vertrag in Kraft tritt, desto besser, da eine kürzere Feedback-Schleife die Produktivität des Programmierers verbessert. Je strenger der Vertrag in Kraft tritt, desto weniger Verstöße gibt es in der Code-Basis. Den Build abzubrechen ist besser als Warnungen, die besser sind als das Ignorieren der Verstöße. Natürlich gibt es auch den menschlichen Aufwand bei der Pflege dieser Verträge. Das ist ebenfalls etwas, das von Lösung zu Lösung variiert.

Das Typsystem

Mit diesem Fundament können wir uns die Lösungen ansehen, beginnend mit dem Typsystem. Stellen wir uns folgende Funktion in TypeScript vor:

function sort<T>(data: T[], compareFn: (a: T, b: T) => number): T[];

Hier werden bestimmte Einschränkungen kommuniziert. Der erste Parameter muss ein Array eines bestimmten Typs sein. Der Rückgabetyp ist derselbe wie der Parameter data. Der zweite Parameter muss eine Funktion sein. Kann der Aufrufer einen Boolean als ersten Parameter übergeben? Kann der Aufrufer eine compareFn übergeben, die einen String statt einer Zahl zurückgibt? Nein, nicht wirklich, da TypeScript den Code andernfalls nicht transpilieren kann. All das ist möglich, weil das Typsystem Datenmodelle gut handhaben kann.

Two labeled boxes, Component A and Component B, are separated by a vertical red line labeled Boundary, clearly Defining Boundaries between the two. Arrows labeled Data flow both ways between the components across the boundary.

Typsysteme sind dennoch nicht allmächtig. Es gibt Einschränkungen, die wir mit ihnen nicht ausdrücken können – nicht einmal mit dem besten, das wir derzeit haben. Wie können wir beispielsweise sicherstellen, dass compareFn bei denselben Eingaben immer dieselbe Ausgabe liefert? Es könnte auch einen Netzwerkaufruf darin machen. Wer sagt, dass die sort-Funktion das Array tatsächlich sortiert? Vielleicht gibt sie einfach „Hello World” in der Konsole aus und gibt unabhängig von der Eingabe ein leeres Array zurück. Nichts verhindert, dass diese Dinge passieren. Datenmodellierung ist ein gut abgedecktes Thema. Die Herausforderung für bessere Typsysteme liegt in der Modellierung von Verhalten.

Konventionen

Für Anforderungen außerhalb der Reichweite des Typsystems benötigen wir etwas anderes, um diese Lücke zu füllen. Hier kommen Konventionen und Dokumentation ins Spiel. Wir erwarten, dass eine Funktion das tut, was ihr Name besagt. Wir erwarten, dass alle Parameter einer Funktion verwendet werden. Ebenso erwarten wir, dass die Dokumentation dem beschriebenen Verhalten treu ist. Das sind soziale Verträge. Verträge zwischen Menschen und nicht Code. Wir erwarten, dass der andere Programmierer an einer Konvention festhält, auf die wir uns alle hoffentlich geeinigt haben. All das deshalb, weil wir derzeit nichts Besseres haben. Da es keine strenge Durchsetzung gibt, kann es vorkommen, dass diese Verträge nicht eingehalten werden. Funktionen tun möglicherweise nicht das, was ihr Name besagt. Dokumentation kann veraltet sein und so weiter.

Two muscular arms, labeled Application developers and Library authors, clasp hands in agreement, with Conventions and Defining Boundaries written over their handshake.

Aus der Vogelperspektive betrachtet möchten wir in der Lage sein, unsere Verträge in einer (Vertrags-)Sprache mit der schnellsten Feedback-Schleife zu definieren, die sehr streng ist und den geringsten Wartungsaufwand hat. Auf andere Lösungen sollte nur zurückgegriffen werden, wenn diese Sprache für unsere Bedürfnisse nicht ausdrucksstark genug ist. Das bedeutet, wir wollen zunächst alles, was möglich ist, mit unserem Typsystem definieren und Konventionen und Dokumentation als Rückfalllösung verwenden.

Zusammenfassung

Der Kern jedes Typsystems ist die Datenmodellierung. Daher sind Datenmodelle das primäre Kommunikationsmittel zwischen Code. Funktionen empfangen Daten als Eingabe und geben Daten als Ausgabe zurück. Das bringt uns zurück zu meinem anfänglichen Gedanken zu diesem Thema, den ich am Anfang erwähnt habe. Reines JavaScript hat nicht einmal ein richtiges Typsystem. Daher vermittelt das bloße Weitergeben von Daten an sich kein Vertrauen. Was ist nun mit Higher-Order-Funktionen? Was ist, wenn wir Verhalten weitergeben wollen? Typsysteme aus Sprachen wie Haskell können einige Eigenschaften von Verhalten ausdrücken, wie z. B. ob sie Seiteneffekte haben oder welche Art von Seiteneffekt. In diesen Programmiersprachen wäre das Weitergeben von Verhalten nicht so problematisch oder könnte sogar gefördert werden. Wenn das Typsystem das nicht kann, müssen wir als Programmierer auf andere Vertragslösungen wie gemeinsame Konventionen und Dokumentation zurückgreifen. Es funktioniert nicht immer, ist aber gut genug, um unsere Arbeit zu erledigen.