Startseite / Artikel

SOLID als Frage nach Veränderung: Refaktorisieren eines Spring Boot Services

Lernen Sie, jedes SOLID-Prinzip in einem Spring Boot Backend anzuwenden, indem Sie herausfinden, was sich wahrscheinlich ändern wird, und wie man die Überengineering-Probleme vermeidet, zu denen SOLID oft führt.

Verlag: REACTAPP.TOP12 Min. Lesezeit2660 Wörter

Quellen: SOLID Principles for Java Developers: What 5 Years of Backend Development Taught Me

Die meisten Entwickler können auswendig nennen, was die fünf Buchstaben von SOLID bedeuten, doch nur sehr wenige können erklären, wie diese Prinzipien eine echte Spring Boot-Anwendung mit Repositorien, Zahlungsdienstleistern und Benachrichtigungskanälen gestalten sollten. Allein Definitionen helfen nicht, wenn ein Produktmanager am Freitagnachmittag nach WhatsApp-Benachrichtigungen verlangt. Dieser Leitfaden erläutert jedes Prinzip anhand typischer Backend-Codebeispiele, zeigt die Refaktorisierungen, die jedes Prinzip vorschlägt, und zeigt außerdem – genauso wichtig – auf, wo eine mechanische Anwendung des Prinzips die Codebasis verschlechtert.

Der gemeinsame Nenner der fünf Prinzipien ist weder „mehr Schnittstellen“ noch „sehr kleine Klassen“. Es geht darum, den Code so einfach wie möglich zu ändern, damit eine Änderung eines Verhaltens nicht Auswirkungen auf alles andere hat. Die untenstehenden Beispiele sind in Java geschrieben, doch die Logik lässt sich direkt auf TypeScript-Dienste in Node.js übertragen; falls Sie diese Umgebung bevorzugen, gibt es einen begleitenden Artikel unter SOLID durch praktische JavaScript-Beispiele.

Das Problem, das SOLID verhindern will

Begonnen wird mit einem Bestellservice, der alles, was für einen Checkout benötigt wird, in einer Methode erledigt: Er validiert die Daten, speichert sie, sendet E-Mails, erstellt Rechnungen, benachrichtigt und erfasst Analysedaten.

public class OrderService {
public void createOrder(Order order) {
        validateOrder(order);
        saveOrder(order);
        sendEmail(order);
        generateInvoice(order);
        sendNotification(order);
        updateAnalytics(order);
    }
}

Hier ist nichts kaputt. Die Logik wird von oben nach unten gelesen, würde bei einer schnellen Code-Überprüfung bestehen und könnte monatelang in der Produktion laufen. Probleme entstehen durch die anschließenden Anforderungen: Ein Interessengruppenmitglied möchte, dass Benachrichtigungen über WhatsApp statt per E-Mail versandt werden. Ein anderer wünscht, dass die Rechnungserstellung in einen Hintergrundprozess verlegt wird. Das Daten-Team kündigt eine Datenbankmigration an. Zudem muss die Analytik in Kafka veröffentlicht werden.

Jeder dieser Anfragen landet in derselben Klasse. Der Code funktioniert weiterhin; das Problem ist, dass jede kleine Änderung dazu zwingt, eine Einheit zu öffnen, zu bearbeiten und erneut zu testen – wobei diese Einheit mit fünf weiteren Aspekten verknüpft ist. Diese Kopplung, bei der eine Änderung viele andere mit sich zieht, ist genau das, was die SOLID-Prinzipien unter Kontrolle halten sollen.

Eine einzige Verantwortung: die Gründe für Änderungen zählen

Das Prinzip der einzigen Verantwortung besagt, dass eine Klasse nur eine Verantwortung haben sollte, was in der Praxis bedeutet, dass es nur einen Grund zum Ändern gibt. Oft wird es fälschlich als „eine Methode pro Klasse“ interpretiert. Diese Interpretation ist falsch: Eine Klasse mit zwanzig Methoden kann dennoch nur eine einzige Verantwortung haben, solange alle Methoden demselben Zweck dienen.

Die wichtige Frage ist nicht, wie groß die Klasse ist, sondern was Sie dazu zwingen könnte, sie zu ändern. Wenn Sie mehrere unzusammenhängende Gründe nennen können, trägt die Klasse wahrscheinlich mehrere Verantwortungen.

Mischt sich Verantwortungen?

Hier ist ein Benutzerregistrierungsdienst in einem Stil, wie man ihn in vielen Codebasen findet.

public class UserService {
public void registerUser(User user) {
        validate(user);
        saveToDatabase(user);
        sendEmail(user);
        generateReport(user);
    }
}

Es erfüllt vier unterschiedliche Aufgaben: die Validierung des Benutzers, seine Speicherung, den Versand von E-Mails sowie die Erstellung eines Berichts. Dadurch gibt es vier unabhängige Gründe für Änderungen. Ein neuer E-Mail-Dienstleister erfordert die Anpassung von UserService. Der Wechsel auf eine andere Datenbank erfordert ebenfalls die Anpassung von UserService. Eine Änderung im Berichtsformat erfordert erneut die Anpassung von UserService. Wenn unzusammenhängende Teams ständig am selben Datei arbeiten, weist das auf ein Designproblem hin.

Delegation an Kollegen

Durch das Refactoring bleibt die Orchestrierung in UserService, während die spezialisierten Aufgaben an über den Konstruktor injizierte spezielle Kollegen delegiert werden.

public class UserService {
private final UserRepository userRepository;
    private final EmailService emailService;
    public void registerUser(User user) {
        validate(user);
        userRepository.save(user);
        emailService.sendWelcomeEmail(user);
    }
}

Die entstehende Abhängigkeitsstruktur ist leicht vorstellbar:

UserService
     |
     +---- UserRepository
     |
     +---- EmailService

UserService entscheidet nun darüber, was während der Registrierung geschieht, wobei UserRepository für die Persistenz und EmailService für die Zustellung zuständig ist. Die Validierung bleibt im Service, da sie Teil der Registrierungsregeln selbst ist, und das Berichten bleibt vollständig außerhalb des Registrierungsweges.

Die empfohlene Vorgehensweise besteht darin, aufzuhören, Methoden zu zählen, und stattdessen zu prüfen, welche verschiedenen Faktoren dazu führen könnten, dass man eine Klasse ändert. Diese Frage weist auf echte Kopplungen hin, anstatt nur auf eine oberflächliche Größe.

Open/Closed: Verhalten hinzufügen, ohne stabilen Code erneut zu öffnen

Die formelle Formulierung, dass Softwareentitäten für Erweiterungen offen, aber für Änderungen geschlossen sein sollten, klingt abstrakt. In alltäglichen Begriffen ausgedrückt: Man sollte in der Lage sein, neues Verhalten hinzuzufügen, ohne den bereits funktionierenden und getesteten Code immer wieder zu bearbeiten.

Die wachsende Bedingung

Die Abwicklung von Zahlungen ist ein klassisches Beispiel. Eine erste Version verwendet oft eine Zeichenkette dafür.

public class PaymentService {
public void pay(String type) {
        if (type.equals("CARD")) {
            // card payment
        }
        if (type.equals("UPI")) {
            // UPI payment
        }
        if (type.equals("NET_BANKING")) {
            // net banking
        }
    }
}

Das funktioniert für drei Methoden. Danach verlangt das Unternehmen Wallets, PayPal, Apple Pay sowie Banküberweisungen. Jeder Zusatz bedeutet, PaymentService erneut zu öffnen, die Bedingungen zu erweitern und alle bereits vorhandenen Ausführungspfade erneut zu testen. Die Klasse wird allmählich zu einer langen if/else-Kette.

Auswechseln von Ausführungspfaden durch Polymorphie

Der Open/Closed-Ansatz führt eine Abstraktion ein, die beschreibt, was jede Zahlungsmethode tun muss.

public interface PaymentProcessor {
void process(Payment payment);
}

Jede Zahlungsmethode wird dann zu ihrer eigenen Implementierung. Der Kartenprozessor:

public class CardPaymentProcessor
        implements PaymentProcessor {
@Override
    public void process(Payment payment) {
        // card payment
    }
}

Und der UPI-Prozessor:

public class UpiPaymentProcessor
        implements PaymentProcessor {
@Override
    public void process(Payment payment) {
        // UPI payment
    }
}

Das Hinzufügen eines neuen Anbieters bedeutet heute, dass eine neue Klasse geschrieben werden muss anstelle der Bearbeitung bereits vorhandener Klassen. Die bereits in Produktion befindlichen Verarbeiter bleiben unberührt, wodurch der Einflussbereich jeder Änderung verringert wird. In einer Spring-Anwendung würden in der Regel alle Verarbeiter als Beans registriert und über ein mit dem Zahlungstyp versehenes Map der richtige Verarbeiter ausgewählt, sodass auch die Auswahllogik keinen neuen Zweig benötigt.

Wann eine Schnittstelle nichts nützt

Nichts davon bedeutet, dass jede Klasse eine Schnittstelle benötigt. Ein sehr häufiges Muster in Spring-Projekten ist die eins-zu-eins-Kopplung einer Schnittstelle mit ihrer einzigen Implementierung:

public interface UserService {
    void createUser();
}

mit einer einzelnen Klasse dahinter:

public class UserServiceImpl implements UserService {
}

Falls es keine realistischen Aussichten auf eine zweite Implementierung gibt und durch den Ersatz nichts gewonnen wird, ist diese Schnittstelle lediglich eine zusätzliche Datei, mit der man sich auseinandersetzen muss. Moderne Mocking-Bibliotheken können konkrete Klassen emulieren, wodurch die Testbarkeit allein selten ein überzeugendes Argument darstellt. SOLID bezieht sich auf das Entwerfen für die erwarteten Änderungen und nicht darauf, jede Klasse reflexartig in eine Abstraktion zu packen.

Liskovs Substitutionssatz: Das Vertragswerk einhalten, nicht nur die Signatur

Liskovs Substitutionssatz besagt, dass man jeden Untertyp anstelle seines übergeordneten Typs verwenden sollte, ohne die Korrektheit des Programms zu beeinträchtigen. Von den fünf Prinzipien wirkt dieser am akademischsten, weshalb es hilft, das Zahlungsbeispiel erneut zu verwenden.

Die Abstraktion bleibt wie zuvor:

public interface PaymentProcessor {
void process(Payment payment);
}

Eine Kartenimplementierung erfüllt das, was die Schnittstelle verspricht:

public class CardPaymentProcessor
        implements PaymentProcessor {
public void process(Payment payment) {
        // process payment
    }
}

Bis hierhin ist alles in Ordnung. Dann fügt jemand einen Prozessor für Barzahlungen hinzu, der eigentlich nichts online verarbeiten kann:

public class CashPaymentProcessor
        implements PaymentProcessor {
public void process(Payment payment) {
        throw new UnsupportedOperationException();
    }
}

Der Compiler ist zufrieden, weil die Methodensignatur übereinstimmt. Das Verhalten bricht jedoch das Versprechen. Jeder Aufrufer, der einen PaymentProcessor besitzt, geht zu Recht davon aus, dass ein Aufruf von process eine Zahlung verarbeiten wird. Mit dieser Implementierung kann eine auf den ersten Blick harmlose Schleife über alle Prozessoren plötzlich zur Laufzeit Fehler verursachen, wodurch die Aufrufer angefangen haben, instanceof-Prüfungen oder try/catch-Blöcke einzufügen, um sich zu schützen – was ein klares Zeichen dafür ist, dass die Abstraktion durchgebrochen hat.

Die Frage, die man sich bei der Erstellung einer Implementierung stellen sollte, ist, ob sie tatsächlich so funktionieren kann, wie die Abstraktion verspricht. Wenn die ehrliche Antwort Nein lautet, besteht die Lösung in der Regel darin, die Abstraktion zu ändern – beispielsweise indem man Offline-Zahlungen separat modelliert oder die Funktionalität in eine engere Schnittstelle aufteilt. Liskovs Prinzip bezieht sich auf die verhaltensmäßige Korrektheit, nicht darauf, ob das Schlüsselwort implements kompiliert.

Schnittstellensegmentierung: Verhindern, dass Nutzer von Dingen abhängig werden, die sie ignorieren

Durch die Schnittstellensegmentierung soll verhindert werden, dass Clients gezwungen werden, von Methoden abhängig zu sein, die sie nicht verwenden. Große, allumfassende Schnittstellen sind der Ort, an dem dieses Prinzip in der Regel zum Tragen kommt.

Eine Schnittstelle für alle

Betrachten wir eine Schnittstelle, die das Personalmanagement, die Lohnabrechnung, die Genehmigung von Urlauben sowie Berichterstattung zusammenfasst:

public interface EmployeeOperations {
void createEmployee();
    void updateEmployee();
    void deleteEmployee();
    void generateSalary();
    void approveLeave();
    void generateReport();
}

Ein Berichtskomponente benötigt lediglich eine einzige Aktion von ihr:

generateReport();

Doch da sie auf EmployeeOperations angewiesen ist, ist sie mit Logiken zur Erstellung, Löschung, Gehaltsberechnung und Urlaubsverwaltung verknüpft, die sie niemals aufrufen wird.

Trennung nach den Anforderungen der Nutzer

Eine bessere Struktur bietet getrennte, fokussierte Schnittstellen. Eine zur Verwaltung von Mitarbeiterdaten:

public interface EmployeeManagement {
void createEmployee();
    void updateEmployee();
    void deleteEmployee();
}

Und eine für die Berichterstellung:

public interface EmployeeReporting {
void generateReport();
}

Jeder Nutzer hängt nun nur von der Funktion ab, die er verwendet. Eine Klasse kann weiterhin beide implementieren; die Trennung betrifft lediglich das, was die Clients sehen.

Warum das bei Wachstum des Systems wichtig ist

In einer kleinen Anwendung ist eine überdimensionierte Schnittstelle ein geringfügiges Ärgernis. In einer großen Anwendung wird sie zu einem Netzwerk aus zufälligen Kopplungen, bei dem eine Änderung für einen Nutzer viele unverwandte Nutzer beeinflusst. Kleinere Schnittstellen machen Abhängigkeitsgrenzen deutlicher und erleichtern das Verständnis.

Die Vorsichtsmaßnahme ist dieselbe wie bei dem Prinzip Öffnen/Schließen: Teilen Sie Schnittstellen nicht einfach nur, um ein Kästchen zu ticken. Trennen Sie sie erst dann auf, wenn verschiedene Clients tatsächlich unterschiedliche Untergruppen an Funktionalitäten benötigen.

Abhängigkeitsumkehr: Lassen Sie die Geschäftslogik von Abstraktionen abhängen

Durch die Abhängigkeitsumkehr sollen sowohl der Geschäftscode als auch der Infrastrukturcode auf gemeinsamen Abstraktionen beruhen, anstatt dass die Steuerungsschicht auf konkreten, niederschwelligen Klassen verweist. In Spring Boot-Anwendungen ist dieses Prinzip wohl das bedeutendste der fünf.

Fest verdrahtete Infrastruktur

Hier instanziert der Bestelldienst sein eigenes Repository:

public class OrderService {
private final MySqlOrderRepository repository;
    public OrderService() {
        this.repository = new MySqlOrderRepository();
    }
}

Die Geschäftslogik weiß nun genau, welche Datenbanktechnologie ihre Daten speichert. Um MySQL durch eine andere Technologie zu ersetzen oder in einem Unit-Test ein falsches Repository einzusetzen, muss OrderService selbst geändert werden. Die hochlevelige Strategie ist somit mit einer niedrigleveligen Detailspezifikation verknüpft.

Umkehrung der Abhängigkeit

Zuerst wird definiert, was die Geschäftslogik unabhängig von der Speichertechnologie tatsächlich benötigt:

public interface OrderRepository {
void save(Order order);
}

Dann wird die für MySQL spezifische Klasse zu einer Implementierung dieses Vertrags:

@Repository
public class MySqlOrderRepository
        implements OrderRepository {
    @Override
    public void save(Order order) {
        // save order
    }
}

Schließlich erhält der Dienst die Abstraktion über seinen Konstruktor:

@Service
public class OrderService {
private final OrderRepository repository;
    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }
}

OrderService kümmert sich nicht mehr darum, wohin die Bestellungen gelangen. Die Implementierung kann MySQL, PostgreSQL, MongoDB sein, ein Aufruf eines anderen Services oder ein in-Memory-Mock für Tests. Die Richtung des Wissens hat sich umgekehrt: Die Infrastruktur passt sich einer von der Geschäftslogik definierten Schnittstelle an, anstatt dass sich die Geschäftslogik der Infrastruktur anpasst.

Wie Springs Abhängigkeitsinjektion diese Prinzipien unterstützt

Spring macht die Abhängigkeitsinversion natürlich, weil sein Container die Verbindungen herstellt. Wenn ein Service seine Abhängigkeiten im Konstruktor deklariert, wie unten gezeigt, findet Spring ein Bean, das zum Typ OrderRepository passt, und übergibt es.

@Service
public class OrderService {
private final OrderRepository repository;
    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }
}

Die Klasse erstellt niemals ihre eigenen Abhängigkeiten. Im Gegensatz dazu erfolgt bei direkter Instanziierung innerhalb des Geschäftscode die Erstellung dieser Abhängigkeiten direkt.

OrderRepository repository =
        new MySqlOrderRepository();

Diese einzige Zeile verbindet den Aufrufer mit einer konkreten Klasse und macht ein Ersetzen ohne Bearbeitung dieser Zeile unmöglich. Die Konstruktorinjektion hält die Kopplung auf der Ebene der Abstraktion und macht Abhängigkeiten in der Klassensignatur sichtbar – genau deshalb wird sie im Allgemeinen der Feldinjektion vorgezogen. Denken Sie daran, dass die Abhängigkeitsinjektion ein Mechanismus und die Abhängigkeitsumkehrung ein Entwurfsprinzip ist: Der Container hilft Ihnen dabei, Abstraktionen miteinander zu verbinden, aber Sie müssen weiterhin selbst entscheiden, welche Abstraktionen definiert werden sollen.

SOLID bedeutet nicht mehr Klassen

Teams, die gerade SOLID entdeckt haben, übertreiben manchmal. Eine einzelne Benutzerfunktion wird zu einem Dutzend verschiedenen Typen:

UserValidator
UserCreator
UserUpdater
UserRepository
UserMapper
UserConverter
UserEmailService
UserNotificationService
UserFactory
UserBuilder
UserManager
UserProcessor

Wenn man das auf eine gesamte Codebasis anwendet, erhält man am Ende vierzig Klassen, obwohl acht davon dasselbe Design klarer ausdrücken könnten. Das ist Überengineering, keine gute Architektur. Die Prinzipien dienen dazu, Komplexität zu verringern; wenn ihre Anwendung den Code schwerer verständlich macht, wird sie wahrscheinlich aus Gewohnheit und nicht als Reaktion auf eine tatsächliche Veränderungsquelle angewandt.

Beginnen Sie mit dem, was sich wahrscheinlich ändern wird

Anstatt zu fragen, wo SOLID angewandt werden könnte, beginnen erfahrene Backend-Entwickler in der Regel mit einer anderen Frage: Was in diesem Komponenten wird sich wahrscheinlich ändern? Die Antwort führt direkt zu den Prinzipien:

  • Die Datenbank kann sich ändern: Führen Sie eine Abstraktion für das Repository ein.
  • Die Zahlungsdienstleister können sich ändern: Platzieren Sie die Dienstleister hinter einer Abstraktion für Zahlungen.
  • Die Benachrichtigungskanäle können sich ändern: Trennen Sie das Verhalten der Benachrichtigungen vom Geschäftsablauf.
  • Die Geschäftsregeln nehmen weiter zu: Trennen Sie die Verantwortlichkeiten, bevor eine Klasse sie alle übernimmt.
  • Unterschiedliche Nutzer benötigen unterschiedliche Operationen: Teilen Sie die Schnittstellen, von denen sie abhängen.
  • Die Implementierungen verhalten sich nicht wirklich gleich: Überdenken Sie die Abstraktion, anstatt sie alle dazu zu zwingen.
  • So bleibt die Anwendung der Prinzipien fundiert. Fügen Sie Abstraktionen dort hinzu, wo tatsächlich Volatilität besteht, und lassen Sie stabile Codeabschnitte einfach bleiben.

    Anwendung derselben Denkweise auf Servicegrenzen

    Die Logik gilt nicht nur für einzelne Klassen. Stellen Sie sich einen einsetzbaren Service vor, der für all diese Bereiche verantwortlich ist:

    User
    Payment
    Notification
    Reporting
    Inventory
    Shipping
    

    Es kann perfekt funktionieren, doch Benutzer, Zahlungen, Benachrichtigungen, Berichte, Lagerbestände und Lieferungen ändern sich aus verschiedenen Gründen – oft zu unterschiedlichen Zeitpunkten und von verschiedenen Teams verwaltet. Die Logik hinter dem Prinzip der Einzelverantwortung kann dabei helfen, die natürlichen Grenzen zu erkennen.

    Das bedeutet jedoch nicht, dass jedes Domänenkonzept einen eigenen Microservice benötigt. SOLID ist keine Vorgabe für mehr Services; es handelt sich dabei um eine Anleitung, Verantwortlichkeiten und Abhängigkeiten gut voneinander zu trennen – unabhängig davon, ob diese Grenzen als Module, Pakete oder Services ausgedrückt werden.

    Überprüfungsliste für Backend-Komponenten

    Bevor Sie eine bedeutende Backend-Komponente als fertig betrachten, gehen Sie für jedes Prinzip eine Frage durch:

    • Einzelverantwortung: Hat diese Klasse mehrere unzusammenhängende Gründe für Änderungen?
  • Offen/Geschlossen: Kann eine neue Variante hinzugefügt werden, ohne die bereits stabile Logik zu ändern?
  • Liskovs Substitutionssatz: Hält jede Implementierung tatsächlich das Kontrakt ihrer Abstraktion ein?
  • Interfacedegregationssatz: Verlassen sich Clients auf Methoden, die sie niemals aufrufen?
  • Abhängigkeitsinversionsprinzip: Ist die Geschäftslogik mit einer konkreten Implementierung verknüpft, von der sie nichts wissen sollte?
  • Ehrliche Antworten auf diese Fragen sind wertvoller als das Auswendiglernen der Definitionen.

    Kernpunkte

    • Ziel aller fünf Prinzipien ist dasselbe: Die nächsten Änderungen kostengünstig und begrenzt zu gestalten, sodass das Wechseln einer Datenbank, das Hinzufügen eines Zahlungsdienstleisters oder die Änderung eines Benachrichtigungskanals wie eine Routineaufgabe und nicht als riskant wahrgenommen wird.
    • Lesen Sie jedes Prinzip als Frage. Ein umfangreicher Service stellt die Frage „Was ändert sich hier?“, eine Schnittstelle mit zehn Methoden fragt „Wer braucht all das?“, und ein new SomeImplementation() in der Geschäftslogik fragt „Muss diese Klasse davon wissen?“.
    • Führen Sie Abstraktionen ein, wo Änderungen wahrscheinlich sind, und vermeiden Sie sie dort, wo das nicht der Fall ist; eine Schnittstelle mit einer einzigen dauerhaften Implementierung oder ein Dutzend Klassen für eine einzige Funktion erhöhen den Aufwand ohne Schutz.
    • Liskov-Verstöße deuten in der Regel auf eine falsche Abstraktion hin, daher sollte das Modell korrigiert werden anstatt die Aufrufer zu patchen.
    • Schreiben Sie Code, der heute funktioniert, und strukturieren Sie ihn so, dass morgige Änderungen nicht schwieriger sind als die heutigen.