Zurück zum Blog

Die Frage, die uns ein Java-Entwickler gestellt hat

Welche Entwurfsmuster verwendet ihr? Unser Frontend hat kaum Klassen. Aus der Antwort wurde eine gute Beschreibung davon, wie wir entscheiden, was wir bauen, und wie wir darüber miteinander sprechen.

engineeringhow-we-workdesign-patternsproduct

Die Frage

Ein Entwickler, der sich bei uns bewarb, kam aus der Java-Welt. Wir sprachen über Codestil, und er fragte, welche objektorientierten Entwurfsmuster wir verwenden.

Die meisten davon verwenden wir ständig, sagten wir ihm. Strategy für eine Familie austauschbarer Verhaltensweisen. Observer für eine Änderung, die alles erreichen muss, was sich dafür interessiert. Komposition, der Vererbung vorgezogen, eher als Gewohnheit denn als Regel.

Ihre anschließende Frage war prägnanter: Wie ist das möglich, wenn unser Frontend fast keine Klassen und keine Schnittstellen deklariert? Java drückt diese Muster durch einen Basistyp und die Elemente aus, die diesen implementieren. Unser Code erfüllt dieselbe Aufgabe auf andere Weise.

Das ist richtig. Eine Vue-Komponente enthält bereits alles, was ein Objekt enthält: ihre eigenen Daten, ihre eigenen Methoden und den Teil, den ein Java-Objekt normalerweise an eine andere Stelle delegiert: die Art und Weise, wie es sich präsentiert. Ein „Composable“ beschreibt, wie eine Komponente ein Verhalten von einer anderen Komponente erhält, dieselbe „Has-a“-Beziehung, die eine Klasse durch Komposition erhält, nur ohne die Basisklasse. Unterschiedliche Begrifflichkeit, dasselbe Argument.

Das geschäftliche Problem dahinter

Wir verkaufen Software, die unsere Kunden selbst konfigurieren. Ein Tiefbauamt baut seine eigenen Formulare für Serviceanfragen. Ein Versorgungsunternehmen definiert seine eigenen Anlagenkategorien. Eine Gemeinde schreibt ihre eigenen Automatisierungsregeln dafür, was passiert, wenn eine Meldung eingeht.

Das bedeutet, Anfragen kommen ständig, und sie sind klein. Können wir diesem Formular ein Unterschriftsfeld hinzufügen. Können wir Daten aus diesem anderen System holen. Können wir diesen Alarm zusätzlich zur E-Mail auch per SMS senden.

Jede einzelne davon ist leicht einmal mit Ja zu beantworten. Die interessante Frage ist, was die zehnte kostet. Wenn jeder neue Feldtyp bedeutet, dieselben drei Dateien zu bearbeiten, ist die zehnte Anfrage langsamer als die erste, und die fünfzigste wird zum Projekt. Wenn jede davon nur eine Ergänzung ist, kostet die fünfzigste ungefähr so viel wie die erste.

Davon ist in einer Demo nichts zu sehen. Dies wird bereits Jahre zuvor durch die Gestaltung des Codes entschieden. Wir legen großen Wert auf diese Gestaltung.

Der Form einen Namen geben

Die meisten Ratschläge zur Codequalität laufen auf zwei Ideen hinaus. Wiederhole dich nicht. Bevorzuge Komposition gegenüber Vererbung. Beide stimmen, und keine löst viel an einem Donnerstagnachmittag um vier, wenn jemand entscheidet, wohin ein neues Feature gehört.

Was es löst, ist ein gemeinsamer Name.

Wenn jemand von uns sagt, "mach das zu einer Registry," weiß das ganze Team, was im Review ankommt: eine Tabelle mit einer Zeile pro Typ, einer Funktion pro Zeile, und einer einzigen Zeile, die die richtige heraussucht. Niemand skizziert das an einer Tafel. Niemand streitet darüber. Das Review dauert eine Minute, weil die Form schon feststand, bevor jemand Code geschrieben hat.

Dieses Vokabular ist das, was wir aus dem Studium von Entwurfsmustern tatsächlich gewonnen haben. Es lebt zwischen den Menschen, und es zahlt sich zweifach aus. Es macht eine gute Entscheidung schnell, und es macht eine schlechte sichtbar, solange sie noch ein Satz ist, bevor sie zu drei Dateien wird.

Wir führen eine schriftliche Liste der Formen, die wir verwenden, und wo jede davon hingehört. Neue Arbeit beginnt damit, zu benennen, welche davon zutrifft. Diese eine Frage klärt den größten Teil des Entwurfs, und der Rest ist Tippen.

Was das konkret bringt

Nehmen wir Benachrichtigungen. Wenn etwas passiert, das jemand wissen muss, schicken wir es. E-Mail, SMS, eine Push-Nachricht aufs Telefon.

Ein Kunde bat uns, für einen bestimmten Alarm SMS-Benachrichtigungen hinzuzufügen. Das ist eine kleine Anfrage, und genau die Art, die wir jede Woche bekommen.

Wir führen eine einzige Tabelle mit allem, was wir versenden, mit einem Eintrag pro Benachrichtigung. Jeder Eintrag beantwortet in schlichtem Code genau eine Frage: Wer sollte davon erfahren?

'report.submitted': {
  recipients: (args) => [args.report.assignee, ...args.report.watchers],
},

Das Hinzufügen einer Benachrichtigung entspricht einem Eintrag. Die dafür vorhandenen Nachrichtenvorlagen legen die Kanäle fest, und eine SMS wird versendet, sobald jemand die Textversion der Nachricht verfasst hat. Die Engine, die den Versand übernimmt, bleibt unverändert. Sie ist etwa vierzig Zeilen lang und ist bei jeder Benachrichtigung, die wir hinzugefügt haben, bei etwa vierzig Zeilen geblieben.

So sah es nicht von Anfang an aus. Die erste Benachrichtigung, die wir je gebaut haben, war ein Toast, die kleine Meldung, die in die Ecke des Bildschirms gleitet, um zu sagen, dass das, was man gerade getan hat, funktioniert hat. Ein Toast ist so einfach, wie es nur geht, und es gab eine Version der Aufgabe, in der wir ihn als Toast geschrieben hätten und weitergezogen wären.

Wir haben stattdessen die Tabelle geschrieben, weil ein Toast bereits die beiden Fragen beantwortet, die alles andere auch beantworten wird. Etwas ist passiert. Wer muss es wissen, und was soll er sehen?

Danach nahm die Anzahl der Antworten erheblich zu. Einige Benachrichtigungen mussten eine Person erreichen, die den Tab geschlossen hatte. Diese wurden per E-Mail versendet. Einige mussten ein Team erreichen, das keinen Laptop zur Verfügung hatte. Diese wurden zu SMS. Einige mussten um zwei Uhr morgens auf einem Smartphone eintreffen. Diese wurden zu Push-Benachrichtigungen. Jede dieser Lösungen brachte einen erheblichen Arbeitsaufwand mit sich, einschließlich einer neuen Benutzeroberfläche, über die die Nutzer auswählen können, worüber sie informiert werden möchten, und keine davon änderte die Antwort auf die beiden ursprünglichen Fragen.

Die Regel, an die wir uns halten, ist, dass alles Ungewöhnliche an einer Benachrichtigung im Eintrag dieser Benachrichtigung selbst lebt. Sobald ein Sonderfall in die Engine wandert, beginnt die Engine, pro Fall eine weitere Markierung anzusammeln, und die nächste Person muss sie alle lesen, um irgendetwas hinzuzufügen.

Bei einer zweiten Variante fragt der Sendecode: „Ist dies eine E-Mail, eine SMS oder eine Push-Benachrichtigung?“, und jedes Mal wird ein neuer Zweig angelegt. Unsere Codebasis enthält beide Varianten. Wir planen Zeit ein, um die Zweigversion zu konvertieren. Eine Zeile nimmt einen Dienstag in Anspruch. Ein Zweig erfordert ein Gespräch.

Der Java-Entwickler würde die erste Version Strategy nennen. Es gibt darin nirgendwo ein Interface und keine Klasse, und sie tut genau das, was das Muster verspricht.

Das Objekt war schon da

Beginnen wir mit dem Teil der Antwort, der uns am wenigsten überrascht hat, als wir ihn direkt betrachtet haben. In Java wird ein Objekt als Klasse dargestellt: ein Bündel aus Daten und den Methoden, die auf diese Daten einwirken. Wie es auf dem Bildschirm dargestellt wird, ist ein separates Thema, das an anderer Stelle behandelt wird.

Eine Vue-Komponente bündelt dieselben drei Dinge, plus das dritte Stück, das Java üblicherweise abgibt:

<script setup lang="ts">
const props = defineProps<{ userId: string }>()
const user = ref<User | null>(null)
const isAdmin = computed(() => user.value?.role === 'admin')

async function load() { user.value = await getFetch(`/api/users/${props.userId}`) }
</script>

<template>
  <div>{{ user?.name }} <span v-if="isAdmin">Admin</span></div>
</template>

props ist ein Konstruktorargument. user und isAdmin sind Instanzvariablen. load ist eine Methode. Das Template ist der Teil, den Java üblicherweise an etwas anderes abgibt: der Teil, der entscheidet, wie sich das Objekt darstellt. Setzt man zwei davon für zwei verschiedene Nutzer auf denselben Bildschirm, hält jede ihr eigenes user, privat für sich, ohne die andere je zu sehen. Das sind zwei Objekte.

Nichts davon erforderte den Begriff „Klasse“. In den Mustern, auf die wir uns stützen, war das Objekt nie wirklich der entscheidende Aspekt. Was variiert, ist die Art und Weise, wie Objekte zueinander in Beziehung stehen: ob eines von einem anderen erbt, ob eine Gruppe von Verhaltensweisen ausgetauscht werden kann, ob eine Änderung an einem Objekt sich auf die anderen auswirken sollte. Für die Art von Anwendungen, die wir entwickeln, und auf dem Framework, auf dem wir sie erstellen, funktioniert diese Verknüpfung am besten in Form von „Composables“ und Tabellen. Unsere Klassen befinden sich an der Stelle, an der die Sprache sie vorschreibt, und darauf geht der nächste Abschnitt ein.

Wohin die Vererbung verschwunden ist

Aus Neugier haben wir jede Stelle gesucht, an der unser Code Vererbung verwendet. Es gibt eine Handvoll, und alle sind Fehlertypen, wo die Sprache es verlangt.

Was wir stattdessen verwenden, ist Komposition. In unserem Code sind das kleine Funktionen, die die angeforderten Teile zurückgeben.

const { record, canEdit, save } = useRecordEditor(recordId)

Diese Zeile erzeugt ein eigenes arbeitendes Objekt mit eigenem Zustand. Zwei gleichzeitig geöffnete Editoren halten zwei Datensätze und sehen einander nie. Ein Java-Entwickler liest das und erkennt ein Objekt mit öffentlichen Methoden, wobei der Rest privat bleibt.

Das Buch, das den meisten von uns diese Muster vermittelt hat, formuliert dies als Grundsatz: Bevorzugen Sie Komposition gegenüber Vererbung. Vererbung bindet Sie an ein übergeordnetes Element und übergibt Ihnen alles, was dieses enthält. Komposition greift hingegen genau auf die drei Dinge zurück, die Sie benötigen. Der auf Komposition basierende Stil von Vue entspricht diesem Grundsatz sehr genau, und unser Code hat sich schon früh darauf festgelegt.

Wenn eure Kunden diejenigen sind, die es erweitern

Das Muster, auf das wir uns am stärksten stützen, ist jenes, bei dem sich eine Änderung selbst ankündigt und alles Interessierte darauf reagiert.

Unsere Automatisierungsregeln funktionieren so, und das Interessante ist, wer sie schreibt. Ein Kunde öffnet die Oberfläche und baut eine Regel: Wenn für diese Art von Anlage eine Meldung eingeht, diese Mannschaft benachrichtigen und einen Arbeitsauftrag eröffnen. Diese Regel ist eine Zeile in seiner eigenen Datenbank. Wenn das Ereignis eintritt, findet die Engine jede darauf wartende Regel und führt jede aus, wobei Fehler getrennt gehalten werden, sodass eine defekte Regel die anderen weiterlaufen lässt.

Der Observer aus dem Lehrbuch lässt Programmierer die Listener registrieren. Hier tut das der Kunde, über ein Formular, an einem Dienstagnachmittag, ohne dass ein Release beteiligt wäre.

Diese Veränderung bringt Probleme mit sich, die das Lehrbuch nie lösen musste. Eine Regel kann ein Ereignis auslösen, das wiederum eine andere Regel auslöst. Jeder Durchlauf verfügt über ein Budget, das festlegt, wie weit eine Kette verlaufen darf. Jede Regel verfügt über ein eigenes Ratenlimit, und wenn ein Sensor ausgelastet ist, steht die Warteschlange allen anderen wieder zur Verfügung. Kunden entwickeln Anwendungen, die wir nicht vorhergesehen haben, und diese beiden Schutzmechanismen sorgen dafür, dass die Warteschlange auch dann weiterläuft, wenn dies geschieht.

Warum wir darüber sprechen

Die Frage, mit der wir begonnen haben, drehte sich um Entwurfsmuster, und die eigentliche Antwort dreht sich darum, wie wir arbeiten.

Wir wenden diese Konzepte ständig an, und in unserer Sprache werden sie anders geschrieben als die Schlüsselwörter. Wir verfügen über einen gemeinsamen Wortschatz dafür, und eine Designentscheidung wird in der Regel in einem kurzen Gespräch getroffen.

Der Toast ist das klarste Beispiel. Er wurde in einer Sprache und einem Framework geschrieben, die dem, was diesen Code heute ausführt, kaum noch ähneln, von Leuten, die nicht dieselben Bücher gelesen hatten. Die Form darunter hat trotzdem gehalten, und sie hält immer noch, jetzt wo dieselbe Benachrichtigung als E-Mail, als SMS oder als Push um zwei Uhr morgens ankommen kann.

Das ist der Teil, den es sich zu merken lohnt. Entwurfsmuster überdauern die Sprache, mit der man sie beschreibt. Java nennt eine Sache ein Interface mit implementierenden Klassen, wir nennen sie eine Tabelle von Funktionen, und das Argument, das beide vorbringen, ist identisch. Lerne das Argument, und die Syntax hört auf, eine Rolle zu spielen.

Wenn ihr überlegt, ob ihr mit uns zusammenarbeiten wollt, auf welcher Seite des Tisches auch immer, ist das, was wir euch gerne wissen lassen würden. Wir denken über die Form einer Sache nach, bevor wir sie bauen, wir schreiben auf, was wir lernen, und wir gehen zurück, um die Stellen zu reparieren, an denen wir es falsch gemacht haben. Was unsere Kunden sehen, ist, dass kleine Anfragen klein bleiben.

Zusammenfassung

  • Unsere Kunden konfigurieren das Produkt selbst, und kleine Anfragen kommen ständig herein. Was die fünfzigste kostet, entscheidet die Form des Codes, Jahre bevor sie gestellt wird.
  • Der bleibende Wert von Entwurfsmustern ist ein gemeinsames Vokabular. Sich auf die Form zu einigen, bevor der Code existiert, macht aus einer Entwurfsdebatte ein kurzes Review.
  • Eine Vue-Komponente bündelt bereits das, was ein Objekt bündelt: eigene Daten, eigene Methoden, und wie sie sich darstellt. Unsere Klassen stehen dort, wo die Sprache sie verlangt.
  • Wir halten Verhalten in Tabellen. Eine neue Fähigkeit ist eine Zeile, und die Engine, die die Tabelle liest, bleibt so, wie sie geschrieben wurde. Unsere Benachrichtigungs-Engine ist bei rund vierzig Zeilen geblieben, durch jede Benachrichtigung hindurch, die wir hinzugefügt haben.
  • Diese Engine begann als Toast in der Ecke eines Bildschirms. E-Mail, SMS und Push brachten jeweils echte Arbeit mit sich, einschließlich einer neuen Oberfläche, um auszuwählen, worüber man informiert werden will, und nichts davon änderte die zugrunde liegende Form.
  • Vererbung ist aus unserem Frontend praktisch verschwunden. Kleine composable Funktionen geben die Teile zurück, die ein Bildschirm braucht, jede mit eigenem Zustand.
  • Unsere Automatisierungsregeln sind die eigenen Listener der Kunden, gespeichert als Daten. Das braucht Sicherungen, die eine Lehrbuchversion nicht hat: eine Grenze dafür, wie weit eine Kette von Regeln reisen darf, und eine Ratenbegrenzung pro Regel.
  • Wir führen eine schriftliche Liste der Formen, die wir verwenden, und kehren zu den Stellen zurück, die älter sind als diese Liste.
  • Entwurfsmuster überdauern die Sprache, mit der man sie beschreibt. Die Syntax unterscheidet sich zwischen Java und JavaScript. Das Argument, das jedes Muster vorbringt, tut das nicht.