Startseite / Artikel / Das Verständnis der SOLID-Prinzipien durch praktische Codebeispiele

Das Verständnis der SOLID-Prinzipien durch praktische Codebeispiele

Dieser Leitfaden erläutert alle fünf SOLID-Prinzipien anhand konkreter Codebeispiele und zeigt, wie sie in echten Projekten sowie React-Anwendungen angewandt werden.

1903 Wörter

Beim Erstellen von Software ist es nur die halbe Miete, wenn der Code richtig läuft.

Sobald eine Anwendung an Größe gewinnt, wird ihre Codebasis oft schwieriger zu lesen, zu ändern und weiterhin einwandfrei zu halten. Eine kleine Anpassung in einem Teil des Systems kann unbemerkt etwas Unverwandtes beschädigen.

Genau dieses Problem sollen die SOLID-Prinzipien lösen.

SOLID ist eine Gruppe von fünf Entwurfsprinzipien aus der objektorientierten Programmierung, die dazu beitragen, Code zu schaffen, der:

  • einfacher zu warten ist
  • einfacher zu testen ist
  • einfacher erweiterbar ist
  • looser gekoppelt ist
  • für ein Team leichter verständlich ist

Die Abkürzung setzt sich wie folgt zusammen:

S – Prinzip der einzigen Verantwortung

O – Prinzip der Offenheit/Schließbarkeit

L – Liskovsches Substitutionsprinzip

I – Prinzip der Interface-Segregation

D – Prinzip der Abhängigkeitsumkehr

Lassen Sie uns jedes Prinzip anhand einfacher Beispiele durchgehen.

1. S – Prinzip der einzigen Verantwortung

„Eine Klasse sollte nur einen Grund zum Ändern haben.“

Einfach ausgedrückt sollte jede Klasse oder jedes Modul um eine einzige Aufgabe herum konstruiert werden.

Betrachten wir eine User-Klasse, die für Folgendes verantwortlich ist:

  • Das Speichern von Benutzerdaten
  • Die Kommunikation mit der Datenbank
  • Das Versenden von E-Mails
  • Die Erstellung von Berichten

Das ist viel zu viel für eine einzige Klasse.

class User {
  createUser() {
    // create user
  }
saveToDatabase() {
    // save user
  }
  sendEmail() {
    // send email
  }
  generateReport() {
    // generate report
  }
}

Falls sich die Logik zum Versenden von E-Mails ändern muss, müssen Sie die User-Klasse bearbeiten.

Falls sich die Handhabung der Datenbank ändert, müssen Sie wieder in derselben Klasse arbeiten.

Ein saubererer Ansatz teilt diese Aufgaben auf.

class User {
  createUser() {
    // create user
  }
}
class UserRepository {
  saveToDatabase() {
    // database logic
  }
}
class EmailService {
  sendEmail() {
    // email logic
  }
}
class ReportService {
  generateReport() {
    // report logic
  }
}

Durch diese Trennung hat jede Klasse nun genau eine Aufgabe.

Warum ist das nützlich?

Sobald sich eine Anforderung ändert, wissen Sie sofort, welchen Codeteil Sie bearbeiten müssen.

Eine Aufgabe pro Klasse bedeutet einen Grund dafür, dass diese Klasse jemals geändert werden muss.

2. O – Offen/Schließungsprinzip

„Softwareentitäten sollten für Erweiterungen offen, aber für Änderungen geschlossen sein.“

Die Formulierung klingt abstrakt, doch das dahinterstehende Konzept nicht.

Ziel ist es, neue Funktionalitäten ohne wiederholtes Umschreiben bereits funktionierenden Codes einzuführen.

Nehmen wir ein Beispiel aus der Zahlungsverarbeitung:

function processPayment(type, amount) {
  if (type === "card") {
    // card payment
  } else if (type === "upi") {
    // UPI payment
  } else if (type === "paypal") {
    // PayPal payment
  }
}

Nehmen wir nun an, Sie müssen folgende Optionen unterstützen:

  • Stripe
  • Razorpay
  • Apple Pay
  • Google Pay

Jede neue Option macht diese Funktion größer und unübersichtlicher.

Eine bessere Strategie besteht darin, jeder Zahlungsmethode eine eigene Klasse zuzuweisen.

class CardPayment {
  pay(amount) {
    console.log(`Card payment: ${amount}`);
  }
}
class UpiPayment {
  pay(amount) {
    console.log(`UPI payment: ${amount}`);
  }
}
class PaypalPayment {
  pay(amount) {
    console.log(`PayPal payment: ${amount}`);
  }
}

Mit dieser Struktur muss man bei der Hinzufügung einer weiteren Zahlungsoption nicht die bereits geschriebenen Klassen ändern.

class StripePayment {
  pay(amount) {
    console.log(`Stripe payment: ${amount}`);
  }
}

Die ursprünglichen Implementierungen bleiben genau so, wie sie waren.

Die Idee

Erweitern Sie das System durch das Hinzufügen neuer Code, anstatt den bereits stabilen Code immer wieder zu bearbeiten.

3. L – Liskovsches Substitutionsprinzip

„Untertypen sollten durch ihre Basistypen ersetzt werden können.“

Im Kern besagt dieses Prinzip:

Falls B ein Untertyp von A ist, sollte es möglich sein, B anstelle von A überall einzusetzen, und die Anwendung sollte weiterhin korrekt funktionieren.

Eine klassische Veranschaulichung sind Vögel.

Nehmen wir an, Sie definieren:

class Bird {
  fly() {
    console.log("Flying");
  }
}

Dann erweitern Sie es:

class Sparrow extends Bird {
  fly() {
    console.log("Sparrow is flying");
  }
}

Bis jetzt ist alles in Ordnung.

Aber was passiert mit einem Pinguin?

class Penguin extends Bird {
  fly() {
    throw new Error("Penguins cannot fly");
  }
}

Das zeigt einen Fehler im Design auf.

Falls andere Teile des Codes davon ausgehen, dass jedes Bird-Objekt fliegen kann, führt die Verwendung einer Penguin-Instanz dazu, dass diese Erwartung nicht erfüllt wird.

Ein sinnvolleres Design trennt das Flugverhalten in eine eigene Komponente aus.

class Bird {
  eat() {
    console.log("Eating");
  }
}
class FlyingBird extends Bird {
  fly() {
    console.log("Flying");
  }
}
class Sparrow extends FlyingBird {}
class Penguin extends Bird {}

Auf diese Weise müssen Pinguine kein Verhalten unterstützen, das nicht auf sie zutrifft.

Die Lektion

Vermeiden Sie die Erstellung von Vererbungshierarchien, die logisch nicht funktionieren.

Eine Unterklasse muss überall dort richtig funktionieren, wo von der Überklasse erwartet wird, dass sie funktioniert.

4. I – Prinzip der Interface-Segregation

„Kunden sollten nicht gezwungen werden, auf Methoden angewiesen zu sein, die sie nicht verwenden.“

Stellen Sie sich eine solche Schnittstelle vor:

print()
scan()
fax()
copy()

Stellen Sie sich nun einen einfachen Drucker vor, der nur drucken kann.

Warum sollte dieser Drucker auch noch scan(), fax() und copy() implementieren müssen?

Dafür gibt es keinen guten Grund.

Der bessere Ansatz ist es, die Schnittstelle danach zu gliedern, was jede Funktion tatsächlich leistet.

Zum Beispiel:

class Printer {
  print() {
    console.log("Printing...");
  }
}
class Scanner {
  scan() {
    console.log("Scanning...");
  }
}
class FaxMachine {
  fax() {
    console.log("Faxing...");
  }
}

Ein einfacher Drucker muss nur das Verhalten implementieren, das er tatsächlich unterstützt.

In modernem JavaScript

JavaScript verfügt nicht über formelle Schnittstellen wie Java oder C#, aber die zugrundeliegende Idee bleibt dieselbe.

Diese kann man durch Folgendes in die Praxis umsetzen:

  • Kleine Module
  • Kleine APIs
  • Komposition
  • Selbstständige Dienste
  • Fokussierte React-Komponenten

Anstatt alles in einen einzigen riesigen Dienst zu packen:

userService.getUser();
userService.createUser();
userService.deleteUser();
userService.sendEmail();
userService.generateReport();

Trennen Sie die Verantwortlichkeiten voneinander:

userService.getUser();
userService.createUser();
emailService.sendEmail();
reportService.generateReport();

Die Lektion

Zwingen Sie niemals ein Komponente, eine Klasse oder ein Modul dazu, auf Funktionen angewiesen zu sein, die es nicht benötigt.

5. D – Prinzip der Abhängigkeitsumkehr

„Hochlevel-Module sollten nicht direkt von Lowlevel-Modulen abhängen. Beide sollten von Abstraktionen abhängen.“

Dieses Prinzip dient dazu, eine enge Kopplung zu verringern.

Nehmen Sie dieses Beispiel:

class MongoDB {
  save(data) {
    console.log("Saving to MongoDB");
  }
}
class UserService {
  constructor() {
    this.database = new MongoDB();
  }
  saveUser(user) {
    this.database.save(user);
  }
}

Das Problem hier ist, dass UserService direkt mit MongoDB verbunden ist.

Ein späterer Wechsel auf PostgreSQL würde bedeuten, wieder zurückzugehen und UserService selbst zu ändern.

Ein besseres Vorgehen besteht darin, stattdessen die Abhängigkeit einzuspeisen.

class UserService {
  constructor(database) {
    this.database = database;
  }
saveUser(user) {
    this.database.save(user);
  }
}

Nun können verschiedene Datenbankimplementierungen frei übergeben werden.

const mongoDB = new MongoDB();
const userService = new UserService(mongoDB);

Und später ist ein Austausch davon trivial:

const postgresDB = new PostgreSQL();
const userService = new UserService(postgresDB);

UserService muss niemals wissen, welche Datenbank hinter ihm liegt.

Warum ist das nützlich?

Dadurch erhält man Code, der:

  • einfacher zu testen ist
  • einfacher ausgetauscht werden kann
  • weniger stark verknüpft ist
  • im Laufe der Zeit leichter zu warten ist

SOLID in einem echten Projekt

Das Befolgen von SOLID bedeutet nicht, für jede einzelne Sache eine eigene Klasse zu erstellen.

Dieser Unterschied ist sehr wichtig.

SOLID geht darum, gute Entwurfsentscheidungen zu treffen, nicht darum, Abstraktionen aus reiner Gewohnheit anzuhäufen.

In einer React-Anwendung treten diese Prinzipien beispielsweise natürlich zutage, wenn man folgendes trennt:

Components
    ↓
Hooks
    ↓
Services
    ↓
API Layer
    ↓
Database

Die Aufgabe eines Components besteht hauptsächlich darin, die Benutzeroberfläche zu verwalten.

Eine benutzerdefinierte Hook kann wiederverwendbare Zustandslogik beinhalten.

Eine API-Service kann sich um die HTTP-Kommunikation kümmern.

Die Backend-Systeme handeln die Geschäftslogik ab.

Die Datenbankschicht kümmert sich um die Persistenz.

Durch diese Aufteilung bleibt die Anwendung im Laufe ihres Wachstums handhabbar.

SOLID und React

Auch wenn SOLID aus dem objektorientierten Design hervorgegangen ist, lassen sich mehrere seiner Konzepte gut auf die Arbeit mit React übertragen.

Eine einzige Verantwortung

Anstatt ein riesiges Komponenten zu erstellen:

Dashboard.jsx

das versucht, alles zu erledigen, sollte es in folgende Teile aufgeteilt werden:

Dashboard
UserProfile
Statistics
RecentOrders
Notifications

Jeder Teil hat dann eine viel klarere Aufgabe.

Offen/Geschlossen

Entwerfen Sie wiederverwendbare Komponenten, die durch Props neue Funktionalitäten erhalten, anstatt deren Interne jedes Mal umgeschrieben werden zu müssen, wenn eine neue Variante benötigt wird.

<Button variant="primary">
  Save
</Button>
<Button variant="danger">
  Delete
</Button>

Abhängigkeitsumkehr

Anstatt eine Komponente direkt an eine bestimmte Methode zum Abrufen von Daten zu binden, sollte die API-Logik in einem Service oder Hook untergebracht werden.

const users = await userService.getUsers();

Der Komponenten selbst muss nicht wissen, wie diese Anfrage im Hintergrund abgewickelt wird.

Warum SOLID wichtig ist

Der tatsächliche Nutzen von SOLID hat wenig mit der Eleganz des Codes zu tun.

Es geht darum, zukünftige Änderungen weniger schmerzhaft zu gestalten.

Bilden Sie sich ein Projekt vor, das folgendes umfasst:

10 Entwickler → 100 Funktionen → Tausende von Dateien → ständige Änderungen

Ohne sorgfältiges Design kann eine kleine Anforderung eine Kaskade unabhängiger Fehler auslösen.

Durch klare Trennung und lockeren Zusammenhang bleiben Änderungen hingegen weitaus vorhersehbarer.

SOLID kann Ihnen helfen:

  • Kodenduplizierung zu verringern
  • Enge Verbindungen abzubauen
  • Die Testbarkeit zu verbessern
  • Funktionen leichter erweiterbar zu machen
  • Das Debuggen zu vereinfachen
  • Die Zusammenarbeit im Team zu verbessern
  • Größere Anwendungen wartbar zu halten

SOLID bedeutet nicht Überengineering

Dies könnte die wichtigste Erkenntnis sein.

wenden Sie SOLID nicht als starre Checkliste an.

Nehmen Sie eine einfache Funktion wie folgt:

function add(a, b) {
  return a + b;
}

Sie benötigt weder fünf Klassen, drei Schnittstellen noch einen Dependency-Injection-Container.

Es ging niemals darum, den Code komplizierter zu machen.

Ziel ist es, wirklich komplexen Code übersichtlicher zu gestalten.

wenden Sie SOLID erst an, wenn die Komplexität des Systems tatsächlich eine zusätzliche Struktur rechtfertigt.

Kurzfassung

S – Einzelne Verantwortung: Eine Klasse oder ein Modul sollte eine Hauptverantwortung haben.

O – Offen/Geschlossen: Erweitern Sie das Verhalten, anstatt den bereits funktionierenden Code immer wieder zu ändern.

L – Liskov-Substitutionssatz: Unterklassen sollten überall dort korrekt funktionieren, wo eine Oberklasse erwartet wird.

I – Trennung von Schnittstellen: Lassen Sie Clients nicht von Funktionen abhängig werden, die sie nicht benötigen.

D – Abhängigkeitsumkehr: Verlassen Sie sich auf Abstraktionen statt auf konkret implementierte Lösungen.

Fazit

SOLID war nie dazu gedacht, fünf Definitionen zu sein, die man für ein Vorstellungsgespräch auswendig lernen muss.

Es handelt sich um eine Denkweise für die Softwareentwicklung.

Beim Schreiben von Code hilft es, innezuhalten und sich folgende Fragen zu stellen:

Versucht dieses Modul, gleichzeitig zu viele Dinge zu erledigen?

Zwingt das Hinzufügen einer neuen Funktion dazu, den vorhandenen Code neu zu schreiben?

Werden hier unnötige Abhängigkeiten eingeführt?

Kann dieser Code ohne Schwierigkeiten getestet werden?

Wird etwas gezwungen, ein Verhalten zu unterstützen, das es eigentlich nicht benötigt?

Die Auseinandersetzung mit diesen Fragen ist in der Regel wichtiger als das Auswendiglernen dessen, was jede SOLID-Buchstabe bedeutet.

Gute Software ist nicht einfach nur Software, die im Moment funktioniert.

Gute Software ist Software, die sich anmutig weiterentwickelt, anstatt zu einem Albtraum zu werden.

Verwandte Artikel

  • Warum Frontend-Abstraktionen still und heimlich zu technischem Schuldenberg werden — Erfahren Sie, warum vorzeitige Frontend-Abstraktionen versteckte Komplexität hinzufügen und wie Sie beurteilen können, ob gemeinsam genutzte Komponenten, Hooks oder Utilities tatsächlich den Aufwand lohnen.
  • Sechs JavaScript-Designmuster zur Beseitigung von Spaghetti-Code — Es werden sechs praktische JavaScript-Muster erläutert – Strategy, Factory, Observer, Adapter, Composition und Pipeline – die verwickelten Code mit wartbaren Strukturen ersetzen.