OOD-Prinzipien in Angular

Keine Kommentare

Angular ist seit einigen Jahren eines der beliebtesten JavaScript-Frameworks zur Erstellung von Single Page Applications. Während viele JavaScript-Frameworks auf funktionaler Programmierung beruhen, orientiert sich Angular hauptsächlich an der objektorientierten Welt und findet so bei vielen Entwicklern Anklang, die Erfahrung mit objektorientierten Programmiersprachen haben. Das Inversion of Control-Prinzip ist eines der fundamentalen Prinzipien des objektorientierten Desings (OOD) und wird durch das integrierte Dependency-Injection-Framework zur Basis jeder Angular-Anwendung. Durch die starke Anlehnung an die Objektorientierung stellt sich die Frage, wie gut sich andere OOD-Ansätze mit Angular vereinen lassen. Werfen wir also einmal einen Blick auf die SOLID-Prinzipien im Angular-Kontext:

Single Responsibility

Das Single Responsibility-Prinzip wird intuitiv von vielen Entwicklern beim Schreiben von Angular-Services angewendet. Die Strukturierung von Modulen und Komponenten wird dagegen üblicherweise initial mit dem Routing aufgesetzt und ist dadurch häufig auch nur durch das Routing statt durch Zuständigkeiten geprägt. Wenn diese Struktur nicht gepflegt wird, während eine Anwendung wächst, führt dies im Extremfall dazu, dass es eine Komponente pro Route gibt, die nur mithilfe einer Komponentenbibliothek für Eingabefelder und Strukturelemente (wie zum Beispiel Angular Material) aufgebaut wird. Während das die Entwicklung in der Anfangsphase eines Projekts beschleunigen mag, führt es schnell zu stark gekoppelten Komponenten.

Ein guter Ansatz, um Angular-Komponenten zu entkoppeln, ist die Trennung von Layout und Geschäftslogik. Dadurch wird in vielen Anwendungen die Größe von Komponenten bereits deutlich reduziert. Trotzdem ist es oft leider nicht trivial, auf der UI die verschiedenen Bereiche der Nutzer-Interaktionen zu identifizieren und diese voneinander zu trennen – insbesondere, wenn man versucht eine stark gekoppelte Codebasis nachträglich zu überarbeiten. Die ersten Refactoring-Maßnahmen zum Entkoppeln von Komponenten sind oft die Reduktion von Code-Duplikation (insbesondere auch von HTML-Markup) und die Trennung verschiedener Aktionen auf denselben Daten (z. B. die Trennung von Eingabe und Anzeige) durch das Ergänzen neuer Komponenten. Es ist ein gutes Zeichen, wenn dabei ein Großteil des Datenflusses innerhalb von Komponenten durch Komponenten-Interaktion ersetzt wird.

Anwenden des Single Responsibility-Prinzips auf Komponenten führt so zu kleineren Komponenten mit übersichtlichen Interfaces. Diese helfen insbesondere bei der Strukturierung und Wiederverwendung von Code für data-binding und von HTML Markup, vereinfachen aber auch das Schreiben von Komponententests. 

Open/closed

Angular-Module (oder -Bibliotheken) werden üblicherweise als npm-Modul paketiert, verbreitet und versioniert, wodurch sie verschlossen für Modifikationen sind. Gerade für Bibliotheken ist es aber wesentlich, dass auch der “Open”-Teil des “Open-closed”-Prinzips eingehalten wird, der besagt, dass Module offen für Erweiterungen sein sollten. In objektorientierten Programmiersprachen wird dies über Vererbung ermöglicht, aber auch in TypeScript besteht prinzipiell die Möglichkeit, Services und Komponenten via Vererbung bestehender Angular-Objekte abzuleiten. Die Flexibilität, die dieser Ansatz auf den ersten Blick verspricht, ist aber stark eingeschränkt, da Vererbungen auf Klassen-Ebene und somit außerhalb des Angular-Kontexts stattfinden. Es werden also nicht alle Eigenschaften von Angular-Objekten vererbt und erstellte Objekte müssen erneut in Angular registriert werden. Auch Konstrukte wie abstrakte Services oder abstrakte Komponenten, die es erlauben würden, konkrete Implementierungen zu ersetzen, existieren in Angular bisher nicht. 

Die üblichen Entwurfsmuster in Angular sehen daher meistens Komposition statt Vererbung vor. Durch die enge Bindung von Komponenten zu ihrem HTML-Template bietet Angular außerdem die Möglichkeit, diese mittels Direktiven zu erweitern. Auch wenn diese Mechanismen mächtige Werkzeuge sind, ist es die Aufgabe der Entwickler, die Erweiterbarkeit eigener Komponenten sicherzustellen. So wird die Erweiterbarkeit zum Beispiel durch unnötig tiefe Verschachtelungen von (Wrapper-)Komponenten eingeschränkt, da die Konfigurierbarkeit tief liegender Komponenten rein von der Implementierung der Wrapper abhängig ist. Oft ist es möglich, Flexibilität zu erhalten, indem Direktiven statt Wrapper-Komponenten genutzt werden, oder explizit der Zugriff auf bestimmte Bereiche via <ng-content> ermöglicht wird. 

Solche Konstrukte erlauben es insbesondere auch, Komponenten zu erweitern, ohne die Schnittstellen zu verändern. Ein sehr schönes Beispiel ist das Custom-Input von Angular Material, das die Funktionalität von <input> lediglich über die Direktive matInput modifiziert. Die Steuerungslogik des Formulars wird in der übergeordneten Komponente <mat-form-field> implementiert, das dynamische Inhalte via <ng-content> entgegennimmt. So haben Entwickler, die das Input nutzen, weiterhin Zugriff auf das native <input>-Element und können dieses nach Belieben erweitern. Ein weiterer Vorteil ist auch, dass die API des <input>-Elements beibehalten wird und weiterhin nutzbar ist, womit wir schon bei der Idee hinter dem Liskov Substitution-Prinzip angekommen sind.

Liskov Substitution

Wie zuvor schon erwähnt, wird in Angular üblicherweise Komposition gegenüber Vererbung bevorzugt. Daher gibt es wenige Fälle, in denen wir Subtypen von Objekten betrachten und das Liskov Substitution-Prinzip Anwendung findet. Die Erweiterung von Komponenten ist einer der wenigen Fälle, in denen sich die Idee hinter dem Prinzip konkret anwenden lässt. Im oben genannten Beispiel zum Input von Angular Material können beispielsweise weiterhin alle Attribute von HTMLInputElement genutzt werden, sodass die Verwendung des Custom-Inputs keine funktionale Einschränkung mit sich bringt.

Verwandt mit dem Liskov Substitution-Prinzip ist ansonsten noch der Fall, dass eine Anwendungen mehrere ähnliche Services oder Komponenten enthält. Zum Beispiel ist es nicht unüblich, dass alle Komponenten (bzw. Tabs) einheitliche Informationen an eine Steuerungskomponente liefern sollen. Es erscheint auf den ersten Blick recht intuitiv, die Tab-Komponenten hierfür von einer Basisklasse erben zu lassen. Das bringt allerdings Einschränkungen im Hinblick auf dependency injection und data-binding mit sich, da die Basisklasse nicht im Angular-Kontext registriert ist.

Stattdessen reicht es meistens aus ein Interface mit den benötigten Funktions-Signaturen zu erstellen:

export interface Tab {
  getErrors(): ValidationError[];
  isValid(): boolean;
}

Die einzelnen Komponenten können dieses Interface implementieren, wobei Code-Duplikationen durch die Einführung eines Services vermieden werden.

@Component({
  selector: 'app-details',
  templateUrl: './details.component.html',
  styleUrls: ['./details.component.scss']
})
export class DetailsComponent implements Tab {
  detailsForm: FormControl;

  constructor(private formHelper: FormHelperService) {
    ...
  }

  getErrors(): ValidationError[] {
    return this.formHelper.getErrors(this.detailsForm);
  }

  isValid(): boolean {
    return this.formHelper.hasErrors(this.detailsForm);
  }
}

Auf diese Weise lässt sich Steuerungslogik für Klassen von Komponenten abstrahieren. Gleichzeitig bewegt man sich vollständig im Angular-Kontext und das Liskov Substitution-Prinzip findet automatisch Anwendung.

@Component({
  selector: 'app-tabs',
  templateUrl: './tabs.component.html',
  styleUrls: ['./tabs.component.scss']
})
export class TabsComponent {
  @ViewChild(InputComponent, {static: false}) inputTab: Tab;
  @ViewChild(DetailsComponent, {static: false}) detailsTab: Tab;
  @ViewChild(OverviewComponent, {static: false}) overviewTab: Tab;

  constructor() {
    ...
  }

  getTabs(): Tab[] {
    return [this.inputTab, this.detailsTab, this.overviewTab];
  }

  getInvalidTabs() {
    return this.getTabs().filter(tab => !tab.isValid());
  }
}

Interface Segregation

Da TypeScript zu ECMAScript kompiliert wird, haben Interfaces in Angular nicht den gleichen Stellenwert wie Interfaces in Java oder C#. Die häufigste Verwendung von Interfaces in Angular ist die Struktur-Deklaration von Objekten, die zur Kommunikation zwischen Funktionen verwendet werden. Wie oben beschrieben, können Interfaces auch genutzt werden, um ein bestimmtes Verhalten von Komponenten oder Services sicherzustellen. Die Verwendung von Interfaces ist optional, aber sie ermöglicht dem Compiler, eine Vielzahl von Fehlern bereits zur Compile-Zeit zu erkennen. Die durchgängige Verwendung von Interfaces zahlt sich dadurch fast immer aus!

Mit diesem Grundsatz findet die Idee hinter dem Interface Segregation-Prinzip auch in Angular wie gewohnt Anwendung. Bei einer großflächigen Verwendung von Interfaces sammeln sich auf Dauer aber natürlich einige – möglicherweise auch sehr ähnliche – Interfaces an, die gepflegt werden müssen. Es mag daher verlockend sein, an den komplexesten Stellen der Anwendung auf Interfaces zu verzichten, wobei das aber natürlich auch die Stellen sind, an denen gut gepflegte Interfaces den größten Mehrwert bieten. Zum Beispiel werden gerne komplexe Objekte, die vom Backend geliefert werden, auch für die Kommunikation innerhalb des Frontends genutzt.

export interface BackendPersonDTO {
  id: number;
  firstname: string;
  middlename: string;
  lastname: string;
  birthdate: Date;
  address: Address;
  email: string;
  phonenumber: string;
  purchaseHistory: PurchaseInfo[];
  ...
}

Das Interface Segregation Prinzip besagt letztendlich, dass Interfaces für jeden Use Case und nicht pro Objekt-Definition angelegt werden sollten:

export interface DisplayName {
  firstname: string;
  middlename: string;
  lastname: string;
}

export interface ContactInfo {
  personId: number;
  firstname: string;
  middlename: string;
  lastname: string;
  address: Address;
  email: string;
  phonenumber: string;
}

export interface PurchaseHistory {
  personId: number;
  purchaseHistory: PurchaseInfo[];
}

Zum einen wird durch korrekte Verwendung von Interface Segregation die Code-Stabilität erhöht, da Module auf Abhängigkeiten zu unbenötigten Daten verzichten können. Zum anderen reduziert sich auch die Größe von Mocks und Unittests deutlich, da die Logik auf „kleineren“ Objekten operiert.

Dependency Inversion

Wie bereits in der Einleitung beschrieben, bringt Angular ein integriertes Dependency-Injection-Framework mit sich, das sicherstellt, dass Angular-Anwendungen nach dem Dependency Inversion-Prinzip aufgebaut sind. Indem Klassen und Konstanten nach Möglichkeit als Angular-Objekte registriert werden, anstatt sie via TypeScript-Imports außerhalb des Angular-Kontexts zu verwenden, wird der größtmögliche Nutzen aus dem DI-Framework gezogen.

Beispielsweise lassen sich Konstanten mithilfe des InjectionToken für die Dependency Injection registrieren, sodass sie in Unittests leicht gemockt werden können.

import {InjectionToken} from '@angular/core';

export let INJECTABLE_CONSTANTS = new InjectionToken('my.constants');

export interface IInjectableConstants {
  default_color: string;
}

export const InjectableConstants: IInjectableConstants = {
  default_color: 'blue'
};

Dafür wird ein provider in dem entsprechenden Modul registriert:

providers: [
    { provide: INJECTABLE_CONSTANTS, useValue: InjectableConstants }
  ],

Anschießend lassen sich die Konstanten innerhalb des Moduls über den Konstruktor in Services, Komponenten und Direktiven injecten.

constructor(@Inject(INJECTABLE_CONSTANTS) private config: IInjectableConstants) {
  }

Fazit: SOLID-Prinzipien in Angular

Die SOLID-Prinzipien lassen sich gut auf Angular-Anwendungen übertragen und werden teilweise schon implizit durch die Verwendung des Frameworks erfüllt. Generell lassen sich die Prinzipien ohne weiteres auf Angular-Services anwenden, da diese stark an herkömmliche Klassen angelehnt sind. Wir können die Codequalität in Angular-Anwendungen aber weiter verbessern, indem wir die Ideen hinter diesen Prinzipien auch auf andere Angular-Objekte und insbesondere auf Komponenten übertragen. Die Anwendungsmöglichkeiten der SOLID-Prinzipien sind bei Komponenten durch die starke Bindung an das HTML-Template etwas eingeschränkt. Gerade durch die Verwendung von Single Responsibility und Interface Segregation lässt sich der TypeScript-Code aber stark optimieren und teilweise hilft dies sogar auch dabei, Struktur in die HTML-Templates zu bringen.

 

Avatar

Harald Werner ist seit 2020 als Senior Software Engineer für die codecentric tätig. In seinen letzten Projekten war er Full-Stack Entwickler für verschiedene Web-Technologien, wobei er unabhängig von dem verwendeten Toolstack ein Verfechter von Clean Code und guter Software Architektur ist.

Über 1.000 Abonnenten sind up to date!

Die neuesten Tipps, Tricks, Tools und Technologien. Jede Woche direkt in deine Inbox.

Kostenfrei anmelden und immer auf dem neuesten Stand bleiben!
(Keine Sorge, du kannst dich jederzeit abmelden.)

* Hiermit willige ich in die Erhebung und Verarbeitung der vorstehenden Daten für das Empfangen des monatlichen Newsletters der codecentric AG per E-Mail ein. Ihre Einwilligung können Sie per E-Mail an datenschutz@codecentric.de, in der Informations-E-Mail selbst per Link oder an die im Impressum genannten Kontaktdaten jederzeit widerrufen. Von der Datenschutzerklärung der codecentric AG habe ich Kenntnis genommen und bestätige dies mit Absendung des Formulars.

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.