lars
webmobiledatenbankendevopsarchitektur
hello (at) larskoelpin.de

Utils sind Extensions

December 20, 2022
typescript

Weiß der Entwickler nicht, wo Code einzuordnen ist, landet es bei den “Utils”. Utils verkommen schnell zum Müllberg der Codebase. Doch was sind eigentlich Utils, und welches Problem versuchen wir damit wirklich zu lösen?

Entwickeln wir Web-Applikationen, stoßen wir früher oder später auf das Problem, dass wir nicht wissen wohin bestimmter generischer Code liegen soll. Dieser Code landet dann bei den Utils. Utils haben die schöne Eigenschaft, dass sie dessen Definition sehr unspezifisch sind — Code der dort liegt ist halt irgendwie nützlich. Keiner im Team weiß deshalb so wirklich was Utils sind, bzw was dessen Kriterien sind.

Für die Einen sind Utils simple, generische Funktionen. Andere wussten nicht, wohin mit ihrem Code in der aktuellen Projektstruktur. Das konkrete Problem, was dahinter steht, wird selten hinterfragt — oder es findet sich schlicht nicht in der Projektstruktur wieder.

Ich bin der Meinung Utils lösen ein konkretes Problem: Sie wollen Funktionalität an ein externes Paket hinzufügen, auf das wir keinen Einfluss haben.

Doch wie sieht das in der Praxis aus?

Beispiel

Angenommen wir befinden uns in einem React-Projekt. Die Struktur könnte beispielsweise so aussehen:

package.json
   - dayjs
   - zod
   - react
utils/

Wir haben also ein Projekt, das in irgendeiner Form dayjs, zod und react als UI-Ebene nutzt.

Erweitern vorhandener Pakete um Funktionen

Aus irgendeinem Grund brauchen wir jetzt eine Funktion mit der Signatur

type getDateBetween = (start: dayjs.Dayjs, end: dayjs.Dayjs) => Date[]
                                    ^---------------^------------DayJS Modell

Wir legen also eine neue Datei in unsere Utils, die die entsprechende Implementierung bereitstellt.

...
utils/
  + getDatesBetween.ts

Doch, alleine wenn man sich die Signatur anschaut merkt man eines: Das gesamte Konstrukt baut auf dem Modell von dayjs. Anders gedacht: Wenn im dayjs Objekt eine getDatesBetween-Funktion existieren würde, würde die gesamte Funktion gar nicht existieren. Wir erweitern also gerade das Modell von dayjs. Diese Erweiterung kann verschiedene Ausprägungen haben:

  • Erweitern von Paketen um eigene Funktionalitäten (eigenes Modell)
  • Erweitern von Pakete um Integrationen (z.B. Integration vom Framework)
  • Erweitern der Runtime (erweitern von “Array”, etc)
  • Erweitern um Abstraktionen (Framework-Framework-Integration)

Erweitern von Paketen um Integrationen

Bestehende Pakete müssen häufig in das Framework wie React integriert werden. Durch diese Integrationen können Daten die im Framework vorgehalten werden ausgelesen (Inbound), oder ein Neurendern angestoßen werden (Outbound). Das passiert bei einer React-Integration z.B. durch Hooks oder bei Angular durch Module und Injectables.

Die Bilbiothek zod zum Beispiel, stellt eine einfache Möglichkeit validierungsreglen im Code abzubilden bereit. Damit sich diese mit React integrieren lässt. Speziell für zod existiert für Formulate eine bereits fertige Integration. Wenn diese nicht existieren würde, läge es an uns, eine Integration zwischen zod und react herzustellen. Das könnte in der Praxis beispielsweise so aussehen.

extensions/
   zod/
      react/
         useForm.ts

Diese Struktur lässt sich ganz einfach lesen: Ein Datenmodell von Zod integriert sich in das React-Modell mittels einer useForm Hook.

Erweitern der Runtime

Eigentlich geht das Erweitern der Runtime Hand-In-Hand mit dem Erweitern von Paketen. Nicht selten installieren wir Pakete wie ramda oder lodash, um die teilweise dürftige API der Laufzeitumgebung zu erweitern. Würde es diese Pakete nicht geben, müsste eine Implementierung der Funktionalität trotzdem irgendwo leben — meistens bei den Utils.

In der Realität erweitern diese Funktionalitäten aber die Runtime und dessen Datenstrukturen / APIs. ramda bietet beispielsweise diverse Higher Order Functions an, um angenehmer mit Arrays und Objekten arbeiten zu können — eine Aufgabe, die eigentlich von einer Standardbibliothek übernommen wird.

const union = (a: any[], b: any[]): boolean
                   ^---------^--------^------ Runtime-Modell (Array, Boolean)

Erweitern um Abstraktionen

In einem Projekt habe ich den Lifecycle von react-query in den ionic-Lifecycle integrieren müssen. Das hieß in der Praxis, dass ionic in der Praxis keine Komponenten unmountet. Ein Erneuern der Daten musste deshalb selbst, onEnter, getätigt werden. Es war für mich also nötig, jede Nutzung von useQuery durch eine Abstraktion auszutauschen, die zusätzlich eine Integration in den Ionic Lifecycle vollzogen hat. Genau diese Abstraktion findet in useIonicQuery.ts statt.

extensions/
  react-query/
     ionic/
        useIonicQuery.ts

Angewandt auf verschiedene Beispiele, könnte sich eine extensions-Struktur wie folgt ergeben.

package.json
   - dayjs
   - zod
   - react
components/
   MyApp.tsx
extensions/
   dayjs/
      core/
        getDatesBetween.ts
      react/
         useDayJs
   react-query
      ionic/
         useIonicQuery.ts
   zod/
      core/
         createZodObject.ts
      react/
         useForm.ts
  typescript/
      core/
         anyMatch.ts

Wo ist der Mehrwert?

Es ist berechtigt zu fragen, was eine Betrachtung von utils als extensions für einen Mehrwert hat. Professionelle Software hat immer einen architektonischen Aspekt inne. Architektur bedeutet in diesem Kontext, dass Abhängigkeiten zwischen Modulen kontrolliert werden. Beispielsweise kann eine Regel sein, dass DayJS nur in components, nicht aber in services genutzt werden — eine Betrachtung die durch ein Utils-Layer völlig außen vor bleibt — denn: Was unterscheidet in dieser Betrachtung ein zod-Util von einem dayjs-util?

Durch eine extension-Sicht, werden Regeln geerbt: Alles was in extensions/dayjs liegt unterliegt den selben import-Regeln wie das Package dayjs. Das bedeutet in der Praxis, wenn ein import aus Dayjs für ein bestimmtes Modul verboten ist, dann ist auch der import aus dessen extension package verboten. Eine explizite Sicht der Architektur ist damit viel klarer.

Fazit

Werden die Signaturen der Util-Functions betrachtet lassen sich häufig erkenne, wozu diese eigentlich gehören, oder auch: Welches Modell sie Sprechen. Die meisten Utils lassen sich als Funktionserweiterung, Integrationserweiterung oder Abstraktionserweiterung betrachten. Auch die Runtime ist in einer Form ein externes Package z.B. wenn eine grundlegende Datenstruktur wie ein Array um Funktionalität erweitert werden soll: typescript. Die meisten Pakete, wie ramda oder lodash lassen sich 1 zu 1 in diese extension einordnen.

Extensions haben den Vorteil, dass diese 1 zu 1 die Architektur Regeln ihres Kern-Packages beerben können und somit architekturell gesondert betrachtet werden können.