"React.js - Eine JavaScript-Bibliothek zum Erstellen von Benutzeroberflächen." Aus dem Titel auf der React-Homepage können wir klar erkennen: React hat keine eingebaute Architektur, da es kein Framework ist, sondern nur eine schlanke Bibliothek.
Bevor ich die Probleme der fehlenden Architektur in React diskutiere, möchte ich auf das Konzept des "Domain-Driven Design" verweisen.
"Domain-Driven Design ist ein Ansatz zur Softwareentwicklung, der die Entwicklung auf die Programmierung eines Domänenmodells ausrichtet, das ein tiefes Verständnis der Prozesse und Regeln einer Domäne hat. Der Name stammt aus einem Buch von Eric Evans aus dem Jahr 2003, das den Ansatz durch einen Katalog von Mustern beschreibt."
— Martin Fowler
In meiner Praxis beziehe ich mich auf eine Domain, wenn ich über den Zuständigkeitsbereich eines Codestücks spreche. Es kann die Geschäftsdomäne sein, wie die Bankendomäne in einer Fintech-Anwendung. Es kann auch die technische Domäne sein, wie HTTP-Logik-Handling, Fehlerbehandlung und Konfiguration für REST-Kommunikation.
Diese Abstraktion ermöglicht es, alle in DDD bekannten Architekturmuster zu vermeiden und einfach die Idee der Code-Verantwortung für einen bestimmten Logiktyp (die Domain) zu nutzen. Mit diesem Ansatz können wir eine Diskussion über React.js-Architektur beginnen.
Anzeichen einer fehlenden React-Architektur
- ✗ Keine Trennung zwischen reiner UI und domänenspezifischer UI-Logik — Sie können Ihre reinen Layout-Komponenten nicht für die Wiederverwendung in anderen Projekten Ihres Unternehmens extrahieren
- ✗ Nach der Smart- und Dumb-Components-Philosophie sind die reinen UI-Komponenten so dumm, dass sie schwer zu verwenden sind — zahllose Funktionen werden ohne Standardwerte in die Komponente übergeben, sodass man alles über sie wissen muss, um sie zu nutzen
- ✗ Neue Entwickler haben Schwierigkeiten, Logik wiederzuverwenden, da die gesamte Codebasis aus einzeln exportierten Methoden mit abstrakten Namen besteht — Auto-Vervollständigung ist ohne Kenntnis der spezifischen Methodennamen unmöglich
- ✗ Architekturschichten sind vermischt — die höchsten View-Komponenten behandeln HTTP-Response-Codes direkt, ohne zentrale Stelle für einheitliche Fehlerbehandlung
- ✗ Das Konzept domänenspezifischer Logik existiert nicht — jedes Feature baut seine Berechnungen zufällig direkt in der "Smart Component" auf, vermischt mit der eigenen UI-Logik
Ein Blick zurück auf architektonische Freiheit
Dies ist das Ergebnis architektonischer Freiheit. Wir hatten ähnliche Situationen, bevor meinungsstarke Frameworks aufkamen. Ich erinnere mich gut, als Ruby on Rails um 2006 entstand. Als Mainstream-Framework hatte es eine eingebaute Architektur. Alle großen Startup-Produkte wurden fast ein Jahrzehnt lang mit großem Erfolg in Rails geschrieben. RoR erreichte enormen Erfolg, weil es Richtung gab, eine komplette Out-of-the-Box-Architektur und einen vollständig konfigurierten technischen Stack.
Warum also geben wir diese gelernten Lektionen auf?
Lösungen sind unterwegs, obwohl es länger dauert als es sollte. Angular zum Beispiel hat dieses Problem NICHT — es hat eine eingebaute Architektur. Frameworks wie Next.js haben dieses Problem ebenfalls erkannt und bauen mehr Meinungen in ihre Architektur ein. Allerdings fehlt Next.js noch ein domänenorientierter Ansatz — konkret fehlt eine Trennung in Domänen- und technische Dienste als Teil des Frameworks.
Die vereinfachte Domain-Architektur
Da keine große Domain-Orientierung existiert, sind Sie besser beraten, dieses Thema anzugehen und innerhalb Ihres Teams eine Architektur zu definieren, die für alle funktioniert. Ein Architekturstil, der in vielen meiner Projekte gut funktioniert hat, ist das, was ich die "Vereinfachte Domain-Architektur" nenne.
UI Plain Domain
Diese Schicht enthält nur UI-Komponenten, die nicht an das Geschäft gebunden sind. Stellen Sie sich vor, Sie schreiben eine andere Anwendung und können einfach Ihre UI-Komponenten wiederverwenden: Textfelder, Buttons, Formulare und Panels. Plain-UI-Komponenten sollten als teilbare Bibliothek für Ihr gesamtes Unternehmen gedacht sein.
Ein sehr eleganter Ansatz wird vom nx.dev Monorepo-Framework bereitgestellt, wo Sie leicht teilbare Bibliotheken definieren können. Die Plain-UI-Domain sollte niemals Context-API oder Domain-Services referenzieren, um vollständig teilbar zu bleiben. Abhängigkeiten fließen nur in diese Schicht hinein, niemals heraus.
UI Business Domain
Diese Schicht enthält Ihre geschäftsorientierten Features. Die UI-Business-Domain referenziert hauptsächlich die Plain-UI und die Business-Service-Domain zur Implementierung von Features. Sie kann auch einige Tech-Services referenzieren — zum Beispiel ist ein Routing-Service UI-bezogen, kann aber als Tech-Service betrachtet werden, da er nicht domänenspezifisch ist.
Service Business Domain
Dies sind Ihre geschäftsspezifischen Services. Für eine Bankanwendung würden Sie hier Geschäftsabläufe und Geschäftslogik koordinieren. Die Business-Domain nutzt Tech-Services zur Ausführung ihrer Aufgaben und schafft eine Abstraktion über reine Technik. Ein Business-Service kann über die richtige Fehlerbehandlung entscheiden, bevor er Informationen an die UI weitergibt. Die UI hat es leichter, und Sie wiederholen sich nie, da Sie die Logik für einen Geschäftsfall in Ihrem Business-Service konsolidieren.
Beispiel: PhoneNumberService
import parsePhoneNumber from "libphonenumber-js/max";
const DEFAULT_COUNTRY = "DE";
const INVALID_NUMBER_MSG = "Your phone number is not valid.";
const readPhoneNumber = (value: string) => {
return parsePhoneNumber(value, {
defaultCountry: DEFAULT_COUNTRY,
extract: false,
});
};
const isValidNumber = (value: string, allowEmpty = false): boolean => {
if (allowEmpty && !value) {
return true;
}
const phoneNumber = readPhoneNumber(value);
return phoneNumber ? phoneNumber.isValid() : false;
};
const formatNumberInternationally = (value: string): string => {
const phoneNumber = readPhoneNumber(value);
return phoneNumber ? phoneNumber.formatInternational() : value;
};
const PhoneNumberService = {
INVALID_NUMBER_MSG,
isValidNumber,
formatNumberInternationally,
};
export default PhoneNumberService;
Wichtige Vorteile dieses Musters:
- ✓ Klare Kapselung privater Methoden
- ✓ Eine klare API nach außen
- ✓ Kapselung externer Bibliotheken
- ✓ Wiederverwendung interner privater Methoden
- ✓ Domänenspezifische Validierungsmeldungen
- ✓ Jede UI-Komponente, die es nutzt, erhält Logik von einer einzigen Stelle
Service Tech Domain
Alles, was nicht geschäftlich ist, kann als Ihr Tech-Service betrachtet werden — es ist auch Ihre App-Infrastruktur. Typische Beispiele sind:
- • HTTP: Kapseln Sie hier Ihre HTTP-Client-Bibliothek
- • Logging: Einrichtung von Log-Levels
- • Environment: Kapseln Sie Ihre ENVs, berechnen Sie URLs für das Backend
- • I18N: Übersetzungslogik
- • DateTime: Kapseln Sie externe Bibliotheken wie moment.js
- • Routing: Kapseln Sie Standard-Routing in Methoden
Globales State-Management
Wenn Sie eine mittelgroße React-App ohne Besonderheiten haben, sollten Sie mit der Context-API auskommen, die mit React mitgeliefert wird. Wichtige Überlegungen:
- • Sie können Context von überall nutzen, solange Sie Ihre Service-Methoden als Hooks strukturieren
- • Wenn eine Service-Methode kein Hook sein muss, machen Sie sie zu einer einfachen Funktion — das macht die Nutzung flexibler und einfacher
- • Idealerweise halten Sie Ihren Context nur in der Business-UI-Domain und Business-Domain, obwohl das nicht immer möglich ist
- • Ich habe komplette Projekte mit dieser Architektur abgeschlossen und dabei globalen State vollständig vermieden — versuchen Sie, die Einführung von globalem State so lange wie möglich hinauszuzögern
Gerichteter Abhängigkeitsfluss
Abhängigkeiten fließen hauptsächlich unidirektional, von oben nach unten, ähnlich wie bei Backend-Architekturen. Tech-Services wissen nichts über die UI- und Business-Domain. Die Business-Domain holt sich, was sie aus dem System braucht, und referenziert Tech-Services von allen Ebenen, immer in eine Richtung.
Tech-Services können andere Tech-Services referenzieren (zum Beispiel kann der HTTP-Service den EnvService referenzieren, um die benötigte URL herauszufinden). Tech-Services dürfen jedoch niemals höhere Schichten wie Business oder UI referenzieren. Insbesondere ist es verboten, Objekte aus verschiedenen Schichten in Methodensignaturen zu vermischen, wie etwa HTTP-domänenspezifische Objekttypen an andere Domänen zu übergeben.
Das Service-Objekt-Muster
Eine Sache, die mich in JavaScript-Projekten überrascht hat, ist der nachlässige Umgang mit Methoden-Sichtbarkeit — alles wird exportiert, jede Variable und jede Methode. Zusätzlich werden diese Exporte meist in einzelnen Dateien platziert! Sie verschmutzen nicht nur den Code-Namespace, sondern haben auch Tausende einzelner Dateien!
Ich verstehe die ursprüngliche Motivation: Tree-Shaking. Das ist in Ordnung, wenn Sie lodash importieren und nur eine einzelne Methode davon nutzen wollen. Aber warum würden Sie Ihre eigenen Methoden tree-shaken wollen, wenn Ihr Service nur 5 davon hat? Wird ein bisschen Tree-Shaking Ihrer eigenen Methoden Ihre Bundle-Größe so stark reduzieren, dass es rechtfertigt, Ihr gesamtes Repo so zu bauen? Ich denke wirklich nicht — das ist nicht, wo Sie Ihre Bundles optimieren sollten.
Beispiel: Service-Objekt-Muster
export interface MyDomainType {
someAttrib: string;
}
const myPrivateMethod = () => {
console.log('Do something...')
}
const myMethod = (param?: MyDomainType): MyDomainType => {
myPrivateMethod()
console.log('called with', param);
return { ...param };
};
export const MyDomainService = {
myMethod,
};
export default MyDomainService;
Vorteile des Service-Objekt-Musters:
- ✓ Klare private und öffentliche Sichtbarkeit mit Kapselung
- ✓ Namespace und Lesbarkeit beim Aufrufen Ihrer Methoden
- ✓ Einfache Auto-Vervollständigung durch Eingabe des Service-Namens
- ✓ Leichtere Logik-Wiederverwendung und Erkennung von Wiederholungen
- ✓ Klarer Ort für andere Teammitglieder, um geschriebene Logik zu finden
Vorteile dieser Architektur
Pragmatisch
Aus meinen 20 Jahren Erfahrung ist dies ein perfektes Gleichgewicht zwischen einer puristischen Architektur und keiner Architektur. Sie können die sauberste Architektur haben, aber werden Sie sie in einem realistischen Projekt beibehalten können? Werden Ihre Junior-Teammitglieder sie klar verstehen? Wir brauchen ein Gleichgewicht zwischen Theorie und praktischer Anwendung.
Einfach einzuführen
Zeigen Sie dem Team die Theorie und wenden Sie sie an, wenn alle zustimmen. Keine Notwendigkeit, das ganze Projekt umzustrukturieren — es kann inkrementell und nur für neue Features geschehen, weil es keine Menge Konventionen oder ausgefallene Ordnerstrukturen hat.
Backend-Best-Practices
Es ist einer Spring-Backend-Architektur sehr ähnlich. Viele Backend-Entwickler werden sich mit diesem Ansatz wohl fühlen, da dies das ist, was sie meistens tun.
Logik an einem Ort
Hatten Sie jemals das Gefühl, dass kein zentraler Ort für einen bestimmten Logiktyp existiert? Der Domain-Ansatz zwingt Entwickler, Logiktypen an den richtigen Ort zu schieben.
Domain-Namespaces
Das JS-Ökosystem ist bekannt dafür, viele einzelne Funktionen in den globalen Namespace zu exportieren. Mit dieser Architektur können Sie einfach einen Service-Namen wie "UserService." eingeben und die Auto-Vervollständigung Ihrer IDE nutzen. Die Regel lautet: "Nicht lesen, nur Auto-Vervollständigung nutzen."
Keine anderen Muster
Vergessen Sie jede Diskussion über Helpers, Utils oder APIs. Es ist einfach "YourSomethingService." Es gibt Ihrer Logik ihren Aufgabenbereich und kann leicht von anderen gefunden werden. Das Team einigt sich auf ein Hauptmuster, das für die meisten Situationen gut funktioniert.
React-freundlich
Mit Hooks bis hinunter zu den Tech-Services können Sie immer die globale Context-API nutzen, um State zu lesen oder zu schreiben, wenn dies in Ihrer Architektur benötigt wird.
Kapselung externer Bibliotheken
Jede externe Bibliothek wird in einem entsprechenden Service platziert. Auch globale Browser-Objekte wie localStorage, window und Events werden in einem Service platziert und abstrahiert.
Test-freundlich
Durch die Trennung Ihrer App in Schichten und Services wird sie sehr test-freundlich. In meinen Tests rufe ich dieselben Infrastruktur-Services wie EnvService oder InitializationService auf, die auch in der App verwendet werden.
Fazit
Diese Architektur ist nicht puristisch — es gibt puristischere Ansätze. Aber jede Architektur bringt ihre Kosten mit sich. Mit dieser Architektur habe ich versucht, ein perfektes Gleichgewicht zwischen Einfachheit, Domain-Driven-Design-Ideen und einem puristischeren Ansatz wie der CLEAN-Architektur zu schaffen.
Ich werde eine puristischere Architektur (CLEAN) in meinem nächsten Blogbeitrag diskutieren...