Be Software Architecture Wise
Be Wise Apps

Read in English · Leer en español · Lire en français

Abschnitt 3.5 von 3.8 · Mittelstufe · 26 Min. Lesezeit

Die SOLID-Prinzipien auf Komponenten angewandt

Videolektion

5 Min. · auf Englisch gesprochen, mit Untertiteln
Nur anhören (Audio)
Transkript lesen

SOLID ist die bekannteste Sammlung von Designprinzipien und eine der umstrittensten. In dieser Lektion erfahren Sie, was jedes der fünf wirklich bedeutet, wie es für Komponenten und Services gilt und wann Vorsicht geboten ist.

Denken Sie an eine gut geführte Restaurantgruppe. Jede Küche untersteht einem Küchenchef, sodass ihr nie zwei Vorgesetzte widersprüchliche Anweisungen geben. Neue Gerichte kommen auf die Speisekarte, ohne dass die Küche umgebaut wird. Ein Vertretungskoch muss jedes Gericht genau wie der Stammkoch zubereiten, sonst merken es die Gäste. Jeder Lieferant bekommt nur das Bestellformular, das er braucht, nicht das ganze Firmenhandbuch. Und die Zentrale setzt die Standards, während sich die Lieferanten danach richten. SOLID sind diese fünf Gewohnheiten, aufgeschrieben für Software. Robert C. Martin trug die Prinzipien zusammen, und Michael Feathers bemerkte, dass sich ihre Anfangsbuchstaben zu SOLID umstellen ließen.

S steht für das Single Responsibility Principle, das am häufigsten missverstandene der fünf. Es besagt nicht, dass ein Modul nur eine Sache tun soll. Es besagt, dass ein Modul einem Akteur gegenüber verantwortlich sein soll: einer Gruppe von Menschen, die das System auf dieselbe Weise ändern wollen. Stellen Sie sich einen Restaurant-Service vor, der Speisekarten, Provisionsregeln und das Suchranking enthält. Restaurantbetreiber, Finanzteam und Growth-Team ändern ihn jeweils, aus voneinander unabhängigen Gründen. Teilen Sie ihn entlang dieser Akteure auf. Beachten Sie, dass das keine Regel ist, Services klein zu machen.

O steht für das Open-Closed Principle, zuerst formuliert von Bertrand Meyer: offen für Erweiterung, geschlossen für Änderung. Wenn eine Art von Variation zu erwarten ist, sollte die nächste Variante neuer Code sein, nicht ein weiterer Zweig in altem Code. Ein Bestellservice, der ein Event „Order Placed“ veröffentlicht, ist offen für Erweiterung. Das Team des Treueprogramms kann einen Consumer hinzufügen, der Punkte vergibt, ohne dass sich der Bestellservice überhaupt ändert. Fügen Sie Erweiterungspunkte aber nur für Änderungen hinzu, die tatsächlich eintreffen. L steht für das Liskov Substitution Principle, das auf Barbara Liskov zurückgeht. Code, der mit einem Typ funktioniert, muss mit jedem Subtyp weiter funktionieren, ohne zu wissen, welchen er bekommen hat. Die richtigen Methodennamen reichen nicht; das Verhalten muss kompatibel sein. Ein Ersatz darf nicht mehr verlangen, nicht weniger versprechen und die Invarianten nicht brechen. Genau das ist der Test für eine kompatible API-Version: kein neues Pflichtfeld, kein entferntes Antwortfeld.

I steht für Interface Segregation: Kein Client sollte gezwungen sein, von Operationen abzuhängen, die er nicht nutzt. Eine fette Schnittstelle koppelt jeden Client an jede Operation. Zwischen Services spricht das für clientspezifische APIs und fokussierte Event-Typen. Eine unternehmensweite Common-Bibliothek ist dasselbe Problem: Ein Fix in ihrem PDF-Code erzwingt neue Releases von Services, die nie PDFs erzeugen. D steht für Dependency Inversion. Fachlogik sollte nicht von Details wie Datenbanken abhängen. Beide sollten von Abstraktionen abhängen. Und der oft übersehene Punkt ist die Zuständigkeit, wie das nächste Diagramm zeigt.

Vorher importiert und nutzt die Bestellkomponente ein konkretes PostgreSQL-Repository in der Persistenzkomponente. Also wirkt eine Datenbankänderung bis in die Geschäftsregeln. Nachher besitzt die Bestellkomponente eine Schnittstelle für ein Order Repository, in ihren eigenen Begriffen formuliert. Die Persistenzkomponente implementiert sie, sodass der Quellcode-Pfeil jetzt zur Fachlogik zeigt. Wäre die Schnittstelle im Persistenzpaket geblieben, wäre nichts umgekehrt worden.

Martin ergänzt drei Prinzipien, mit denen sich entscheiden lässt, welche Klassen in eine Komponente gehören. Reuse/Release Equivalence: Die Einheit der Wiederverwendung ist die Einheit des Release, also sollte es sinnvoll sein, die Klassen einer Komponente gemeinsam zu releasen. Common Closure: Fassen Sie die Klassen zusammen, die sich aus denselben Gründen zu denselben Zeiten ändern. Das ist Single Responsibility für Komponenten. Common Reuse: Klassen, die zusammen genutzt werden, gehören zusammen, denn von einer Komponente abzuhängen heißt, von ihr als Ganzes abzuhängen. Sie ziehen in entgegengesetzte Richtungen, und jede Kante nennt den Preis für den Verzicht auf die gegenüberliegende Ecke. Am Anfang bevorzugen Teams Common Closure. Wenn andere von ihren Teilen abhängig werden, verschiebt sich das Gleichgewicht in Richtung Wiederverwendung.

Wenden wir sie an. Das Paket „shop core“ eines Onlineshops wird von fünf Services genutzt. Es enthält Preise, Steuern, Adressvalidierung, PDF-Rechnungen und einen Datenbank-Helper und hatte im letzten Quartal vierzig Releases. Vier verschiedene Teams wünschen Änderungen daran. Vier Akteure bedeuten vier unabhängige Änderungsgründe. Der Lagerservice nutzt nur die Adressvalidierung, und doch zwang ihn jedes dieser vierzig Releases zu Upgrade und erneuten Tests. Und der Datenbank-Helper importiert die eigenen Modelle der Services, sodass ein stabiles, weit verbreitetes Paket von volatilem Code abhängt. Der Pfeil zeigt in die falsche Richtung. Nach der Aufteilung führt eine Steuersatzänderung zum Release eines einzigen Pakets und berührt nur die Services, die Steuern berechnen. Der Preis sind mehr Pakete, die versioniert werden müssen. Beachten Sie, dass die Prinzipien die Antwort nicht vorgegeben haben. Sie lieferten ein Vokabular, um zu erklären, warum es Probleme machte. Dogmatisch angewandt erzeugt SOLID eine Schnittstelle für jede Klasse und Erweiterungspunkte, die niemand nutzt. Nutzen Sie es, um echte Probleme zu diagnostizieren.

Der vollständige Abschnitt zeigt jedes Prinzip in Python und legt die Kritik ausführlich dar. Als Nächstes lernen wir in Abschnitt drei Punkt sechs die Connascence kennen, ein feineres Maß für Kopplung. Bis gleich.

Was Sie wissen müssen

Fünf Prinzipien mit Geschichte

SOLID ist eine Merkhilfe für fünf Designprinzipien, die Robert C. Martin Ende der 1990er-Jahre zusammentrug und verfeinerte, am ausführlichsten in seinem Aufsatz Design Principles and Design Patterns aus dem Jahr 2000. Michael Feathers bemerkte, dass sich ihre Anfangsbuchstaben zum Wort SOLID umstellen ließen, und das Akronym setzte sich durch. Die Prinzipien wurden für objektorientierte Klassen geschrieben, doch Martins späteres Buch Clean Architecture (2017) argumentiert, dass jedes von ihnen auch für Komponenten gilt, und in der Praxis wendet man sie ganz selbstverständlich auch auf Services und APIs an.

Deshalb gehören sie in einen Architekturkurs. Jedes Prinzip ist im Kern eine Aussage darüber, wo Änderungen landen sollten und in welche Richtung Abhängigkeiten zeigen sollten, also über die beiden Anliegen der vorigen Abschnitte zu Kopplung und Kohäsion und zum Steuern von Abhängigkeiten. Dieser Abschnitt nimmt sich die Prinzipien nacheinander vor, zeigt jedes in wenigen Zeilen Python und dann, was es bedeutet, wenn die „Klasse“ eine deploybare Komponente oder ein Service eines anderen Teams ist. Zum Schluss kommen die Kohäsionsprinzipien für Komponenten und die Kritik an SOLID, die erheblich ist und die man kennen sollte.

S: Single Responsibility bedeutet ein Akteur

Das Single Responsibility Principle ist das meistzitierte und am häufigsten missverstandene der fünf. Es besagt nicht, dass ein Modul nur eine Sache tun sollte; das ist eine gute Regel für Funktionen, aber eine andere Regel. Martins eigene Formulierung entwickelte sich von der Aussage, eine Klasse solle nur einen Grund zur Änderung haben, hin zu der Aussage in Clean Architecture, ein Modul solle einem und nur einem Akteur gegenüber verantwortlich sein. Ein Akteur ist eine Gruppe von Personen (Nutzer oder Stakeholder), die das System auf dieselbe Weise ändern wollen. Das Prinzip handelt von Menschen und von der Richtung künftiger Änderungen.

Das klassische Beispiel ist eine Klasse Employee mit drei Methoden: calculate_pay, vorgegeben von der Finanzabteilung; report_hours, vorgegeben vom Betrieb; und save, vorgegeben von den Datenbankadministratoren. Teilen sich die Gehaltsberechnung und der Stundenbericht eine Hilfsfunktion zur Berechnung der regulären Stunden, verändert eine von der Finanzabteilung gewünschte Änderung stillschweigend den Bericht, auf den sich der Betrieb verlässt. Niemand war nachlässig; der Code vermischte einfach die Anliegen zweier Akteure, sodass eine Änderung auf Wunsch des einen dem anderen schaden konnte.

from dataclasses import dataclass

@dataclass
class EmployeeRecord:          # shared data, no behaviour
    id: str
    hourly_rate: float
    hours: list[float]

class PayCalculator:           # actor: finance
    def pay(self, e: EmployeeRecord) -> float:
        regular = sum(min(h, 8) for h in e.hours)
        overtime = sum(max(h - 8, 0) for h in e.hours)
        return (regular + 1.5 * overtime) * e.hourly_rate

class HoursReporter:           # actor: operations
    def weekly_report(self, e: EmployeeRecord) -> str:
        return f"{e.id}: {sum(e.hours):.1f} hours"

class EmployeeRepository:      # actor: database administrators
    def save(self, e: EmployeeRecord) -> None: ...
Aufteilung nach Akteur: Jede Klasse ändert sich nur, wenn ihre eigenen Stakeholder es wünschen, und die gemeinsamen Daten bleiben ein einfacher Datensatz.

Auf der Ebene von Komponenten und Services wird das Prinzip zu einem Test für Grenzen: Wessen Wünsche ändern diese Komponente? Auf einer Essenslieferplattform wird ein einzelner „Restaurant“-Service, der Speisekarten (geändert auf Wunsch der Restaurantbetreiber), Provisionsregeln (geändert vom Finanzteam) und das Suchranking (geändert vom Growth-Team) enthält, aus drei voneinander unabhängigen Gründen geändert, von drei Personengruppen, in drei Release-Rhythmen. Jedes Release gefährdet die beiden anderen Anliegen, und das verantwortliche Team wird zur Warteschlange. Eine Aufteilung entlang der Akteure, etwa in einen Speisekarten-Service, eine Provisionskomponente in der Finanzdomäne und einen Ranking-Service, richtet jede Grenze an einer einzigen Änderungsquelle aus. Das ist auch der Gedanke dahinter, Services an Geschäftsfähigkeiten auszurichten und, im Domain-Driven Design, an Bounded Contexts (behandelt in Kapitel 5).

Beachten Sie, dass das Prinzip nicht sagt: „Machen Sie Services klein.“ Ein großer Service, der einem einzigen Akteur gut dient, ist in Ordnung; ein winziger Service, den zwei Abteilungen ständig in gegensätzliche Richtungen ändern, ist es nicht.

O: offen für Erweiterung, geschlossen für Änderung

Das Open-Closed Principle wurde zuerst von Bertrand Meyer in Object-Oriented Software Construction (1988) formuliert: Ein Modul sollte offen für Erweiterung sein (man kann neues Verhalten hinzufügen), aber geschlossen für Änderung (sein bestehender, getesteter Quellcode muss dafür nicht bearbeitet werden). Meyer dachte an Vererbung; Martin deutete es im Sinne von Schnittstellen und Polymorphie neu. Die praktische Form lautet: Wenn eine Art von Variation zu erwarten ist, geben Sie ihr einen Erweiterungspunkt, damit die nächste Variante neuer Code ist und nicht ein weiterer Zweig in altem Code.

from typing import Protocol

class Carrier(Protocol):
    def quote(self, weight_kg: float) -> int: ...   # price in pence

class RoyalMail:
    def quote(self, weight_kg: float) -> int:
        return 350 + int(weight_kg * 120)

class Courier:
    def quote(self, weight_kg: float) -> int:
        return 600 + int(weight_kg * 80)

CARRIERS: dict[str, Carrier] = {"royal_mail": RoyalMail(), "courier": Courier()}

def cheapest(weight_kg: float) -> tuple[str, int]:
    return min(((name, c.quote(weight_kg)) for name, c in CARRIERS.items()),
               key=lambda pair: pair[1])
Einen Versanddienstleister hinzuzufügen heißt, eine neue Klasse zu registrieren; der Code für das Angebot ändert sich nie.

Auf Architekturebene erscheint dieselbe Idee als Erweiterungspunkte, die andere Teams nutzen können, ohne Sie um eine Änderung Ihres Codes zu bitten. Ein Bestellservice, der ein OrderPlaced-Event veröffentlicht, ist offen für Erweiterung: Das Team des Treueprogramms kann einen neuen Consumer hinzufügen, der Punkte vergibt, und der Bestellservice wird weder geändert noch erneut getestet oder neu deployt. Eine Plug-in-Architektur (der Microkernel-Stil) gibt dasselbe Versprechen für Features. Bei APIs übersetzt sich das Prinzip in additive Weiterentwicklung: Neue optionale Felder, neue Endpoints und neue Event-Typen erweitern einen Vertrag, ohne zu ändern, worauf sich bestehende Clients verlassen.

Der Haken ist die Vorhersage. Ein Erweiterungspunkt ist nur für die Arten von Änderungen wertvoll, die tatsächlich eintreffen. Eine Registry von Versanddienstleistern lohnt sich, wenn der Shop alle paar Monate neue hinzufügt; eine generische „Preisregel-Engine“, gebaut, weil die Preisgestaltung eines Tages variieren könnte, ist spekulative Maschinerie. Fachleute fügen den Erweiterungspunkt beim zweiten oder dritten Auftreten einer Variation hinzu, nicht vorher.

L: Liskov Substitution und Verhaltensverträge

Das Liskov Substitution Principle geht auf Barbara Liskovs Keynote Data Abstraction and Hierarchy auf der OOPSLA-Konferenz 1987 zurück und wurde von Liskov und Jeannette Wing in A Behavioral Notion of Subtyping (ACM Transactions on Programming Languages and Systems, 1994) präzisiert. Informell: Wenn Code mit Objekten vom Typ T funktioniert, muss er weiter funktionieren, wenn er ein Objekt eines beliebigen Subtyps S erhält, ohne zu wissen, welchen er bekommen hat. Die richtigen Methodennamen reichen nicht; das Verhalten muss kompatibel sein.

Die Regeln der verhaltensbasierten Subtypisierung hängen direkt mit Design by Contract zusammen (Abschnitt 3.3). Ein Subtyp darf Vorbedingungen nicht verstärken (vom Aufrufer mehr verlangen), darf Nachbedingungen nicht abschwächen (im Gegenzug weniger versprechen) und muss Invarianten erhalten (die Tatsachen, die für den Typ immer gelten). Liskov und Wing ergänzten eine History Constraint: Ein Subtyp darf keine Zustandsänderungen zulassen, die der Supertyp verbietet, weshalb sich ein veränderlicher Punkt nicht als unveränderlicher ausgeben kann.

class Discount:
    """Postcondition: 0 <= result <= total."""
    def apply(self, total: int) -> int:
        return total

class PercentOff(Discount):
    def __init__(self, pct: int):
        self.pct = pct
    def apply(self, total: int) -> int:
        return total - total * self.pct // 100      # keeps the contract

class FixedVoucher(Discount):
    def __init__(self, pence: int):
        self.pence = pence
    def apply(self, total: int) -> int:
        return total - self.pence                    # can go below zero!

# FixedVoucher(500).apply(300) == -200: the checkout now issues a refund.
# Fix: return max(total - self.pence, 0), honouring the base postcondition.
Der Basisvertrag besagt, dass ein Rabatt die Summe nie negativ macht. Ein Subtyp, der das bricht, lässt Aufrufer scheitern, die dem Vertrag vertraut haben.

Das Symptom einer LSP-Verletzung sind Typprüfungen in Aufrufern: if isinstance(d, FixedVoucher): .... Jede solche Prüfung bedeutet, dass einer Abstraktion nicht mehr zu trauen ist, und das Wissen um ihre Ausnahmen verbreitet sich in der gesamten Codebasis.

Auf Service-Ebene ist Ersetzbarkeit die Frage hinter jeder kompatiblen API-Änderung. Soll Version 2.3 einer Zahlungs-API die Version 2.2 ersetzen, ohne dass Clients es merken, ist sie ein verhaltensbasierter Subtyp von 2.2, und es gelten dieselben Regeln. Sie darf Vorbedingungen nicht verstärken: kein neues Pflichtfeld in der Anfrage, keine strengere Validierung bestehender Felder, kein niedrigeres Rate Limit. Sie darf Nachbedingungen nicht abschwächen: keine entfernten Antwortfelder, keine langsamere garantierte Antwortzeit, kein Verlust der Idempotenz. Sie muss Invarianten erhalten: Ein Betrag ist weiterhin in der kleinsten Währungseinheit angegeben, eine Bestellkennung ändert sich weiterhin nie. Einen neuen Wert in eine Aufzählung in einer Antwort aufzunehmen, ist ein subtiler Fall: Es erweitert, was der Client erhalten kann, und bricht damit Clients, die die alte Menge für vollständig hielten. Dieselbe Überlegung gilt, wenn eine Implementierung eines Service eine andere hinter einem Load Balancer ersetzt oder wenn Sie einen Adapter für einen Zahlungsanbieter gegen einen anderen austauschen. Consumer-Driven-Contract-Tests sind im Grunde automatisierte Liskov-Prüfungen, und Hyrums Gesetz (Abschnitt 3.3) erinnert daran, dass sich Clients auf Verhalten verlassen können, das Sie nie versprochen haben.

I: Interface Segregation und clientspezifische APIs

Das Interface Segregation Principle besagt, dass kein Client gezwungen sein sollte, von Operationen abzuhängen, die er nicht nutzt. Eine fette Schnittstelle koppelt jeden Client an jede Operation: Ändert sich eine Operation, sind alle Clients betroffen, zumindest dadurch, dass sie neu kompiliert, getestet oder deployt werden müssen, selbst jene, die sie nie aufrufen. In kompilierten Sprachen ist diese Kopplung wörtlich zu nehmen (eine Änderung an einem Header oder einem gemeinsamen JAR erzwingt den Neubau aller davon abhängigen Module); in Python zeigt sie sich in Test Doubles, die zwanzig Methoden implementieren müssen, um Code zu testen, der zwei davon aufruft.

from typing import Protocol

class OrderReader(Protocol):
    def get(self, order_id: str) -> dict: ...
    def list_for_customer(self, customer_id: str) -> list[dict]: ...

class OrderWriter(Protocol):
    def save(self, order: dict) -> None: ...
    def cancel(self, order_id: str) -> None: ...

def monthly_statement(orders: OrderReader, customer_id: str) -> int:
    return sum(o["total"] for o in orders.list_for_customer(customer_id))

# The reporting job cannot cancel an order, and its tests
# need a fake with two read methods, not the whole repository.
Jeder Client hängt nur von der Rolle ab, die er braucht; eine Klasse kann trotzdem beide Rollen implementieren.

Auf Service-Ebene ist Interface Segregation das Argument für clientspezifische APIs. Eine einzige Allzweck-API, die die mobile App, den Webshop, das Lagersystem und Partnerintegrationen bedient, wächst meist zu einem Entwurf auf dem kleinsten gemeinsamen Nenner heran: Jeder Client erhält Felder, die er ignoriert, und jede Änderung muss mit allen abgestimmt werden. Das Muster Backend for Frontend (benannt von Phil Calçado bei SoundCloud und beschrieben von Sam Newman) gibt jeder Art von Client eine eigene dünne API-Schicht, die dem Team gehört, das diesen Client baut, und die Aufrufe an die darunterliegenden Services zusammensetzt. GraphQL geht dasselbe Problem anders an, indem jeder Client genau die Felder auswählt, die er braucht. Für Events legt es nahe, mehrere fokussierte Event-Typen zu veröffentlichen (OrderPlaced, OrderShipped) statt eines einzigen OrderChanged-Events mit allen Feldern, das jeden Consumer zwingt, alles zu parsen und zu filtern.

Die architektonische Fassung gilt auch für gemeinsame Bibliotheken. Eine unternehmensweite common-Bibliothek mit Logging, Geldbeträgen, PDF-Erzeugung und HTTP-Clients koppelt jeden Service an jeden ihrer Teile: Ein Sicherheitsfix im PDF-Code erzwingt ein neues Release von Services, die nie PDFs erzeugen. Sie in kleine Bibliotheken aufzuteilen, die Clients unabhängig voneinander übernehmen, ist Interface Segregation auf Paketebene und nimmt das Common Reuse Principle weiter unten vorweg.

D: Dependency Inversion und wem die Schnittstelle gehört

Das Dependency Inversion Principle hat zwei Teile: High-Level-Module (Fachlogik) sollten nicht von Low-Level-Modulen abhängen (Details wie Datenbanken, Message Broker und Web-Frameworks); beide sollten von Abstraktionen abhängen. Und Abstraktionen sollten nicht von Details abhängen. Das Wort „Inversion“ bezieht sich auf die Richtung der Quellcode-Abhängigkeit: In einem naiven Entwurf ruft die Fachlogik das Detail auf und importiert es deshalb; nach der Umkehr importiert das Detail eine Schnittstelle, die die Fachlogik definiert, sodass der Quellcode-Pfeil entgegen dem Kontrollfluss zeigt.

Der entscheidende und oft übersehene Punkt ist die Zuständigkeit. Die Schnittstelle gehört der High-Level-Komponente und ist in deren Begriffen formuliert (OrderRepository.save(order), nicht PostgresClient.execute(sql)). Liegt die Schnittstelle im Persistenzpaket, haben Sie eine Abstraktion hinzugefügt, aber nichts umgekehrt: Die Fachlogik hängt weiterhin vom Persistenzpaket ab.

# orders/ports.py  (high-level policy owns this)
from typing import Protocol
class PaymentPort(Protocol):
    def charge(self, order_id: str, pence: int) -> str: ...  # returns payment id

# orders/service.py
class Checkout:
    def __init__(self, payments: PaymentPort):
        self.payments = payments
    def pay(self, order_id: str, pence: int) -> str:
        return self.payments.charge(order_id, pence)

# infrastructure/stripe_adapter.py  (detail depends on policy)
from orders.ports import PaymentPort
class StripeAdapter(PaymentPort):
    def charge(self, order_id: str, pence: int) -> str:
        ...  # call the provider's API, map its errors to ours
Das Bestellpaket definiert den Port, den es braucht; die Infrastruktur implementiert ihn und wird beim Start eingebunden.

Eine Abhängigkeit über eine Komponentengrenze hinweg umkehren

Abhängigkeitsumkehr zwischen einer Bestellkomponente und einer Persistenzkomponente, vorher und nachherVorher: Fachlogik hängt an DetailsBestellkomponenteOrderServiceimportiert/nutztdie konkrete KlassePersistenzkomponentePostgresOrderRepositoryDB-Änderung wirkt bis in BestellungenNachher: Detail hängt an FachlogikBestellkomponente (besitzt Schnittstelle)OrderService«interface»OrderRepositorysetzt umPersistenzkomponentePostgresOrderRepositoryPfeil zeigt jetzt zur Fachlogik
Wird die Schnittstelle in die Bestellkomponente verlegt, kehrt sich die Quellcode-Abhängigkeit um, sodass sich die Persistenz ändern kann, ohne die Fachlogik zu berühren.

Dependency Inversion ist nicht dasselbe wie Dependency Injection, auch wenn beide meist zusammen auftreten. Injection ist eine Technik, einem Modul seine Kollaborateure von außen zu übergeben (das Konstruktorargument payments oben); Inversion ist eine Entscheidung darüber, in welche Richtung Quellcode-Abhängigkeiten zeigen. Sie können eine konkrete Klasse injizieren, ohne irgendetwas umzukehren.

Auf Architekturebene ist Dependency Inversion die ordnende Regel der hexagonalen, Onion- und Clean Architecture: Die Domäne sitzt im Zentrum und definiert Ports; Adapter für Datenbanken, Queues und Benutzeroberflächen sitzen außen und hängen nach innen ab. Zwischen Services lautet dieselbe Frage: „Wem gehört der Vertrag?“ Definiert ein zentraler Abrechnungsservice ein BillingEvent-Schema und muss sich jedes Produktteam daran anpassen, ist die Abrechnung die stabile Fachlogik und die Produkte hängen von ihr ab, was oft richtig ist. Liest die Abrechnung stattdessen die internen Tabellen jedes Produkts, zeigt die Abhängigkeit vom Stabilen zum Volatilen, und jede Schemaänderung eines Produkts bricht die Abrechnung. Abschnitt 3.4 hat gezeigt, wie sich das mit dem Stable Dependencies Principle quantifizieren lässt.

Die Kohäsionsprinzipien für Komponenten: REP, CCP und CRP

SOLID sagt Ihnen, wie Sie Klassen und ihre Abhängigkeiten gestalten. Martin stellt ihm drei Prinzipien zur Seite, mit denen sich entscheiden lässt, welche Klassen zusammen in eine Komponente gehören, wobei eine Komponente eine Einheit für Release und Deployment ist, etwa ein JAR, ein Python-Paket oder eine .NET-Assembly.

Das Reuse/Release Equivalence Principle (REP) besagt, dass die Einheit der Wiederverwendung die Einheit des Release ist: Sollen andere eine Komponente wiederverwenden, braucht sie Versionsnummern, Release Notes und ein stimmiges Thema, also sollte es sinnvoll sein, ihre Klassen gemeinsam zu releasen. Das Common Closure Principle (CCP) ist das Single Responsibility Principle für Komponenten: Fassen Sie in einer Komponente die Klassen zusammen, die sich aus denselben Gründen zu denselben Zeiten ändern, damit eine typische Änderung nur eine Komponente berührt und nur diese ein Release braucht. Das Common Reuse Principle (CRP) ist das Interface Segregation Principle für Komponenten: Klassen, die zusammen genutzt werden, gehören zusammen, und Klassen, bei denen das nicht so ist, sollten nicht zusammen verpackt werden, denn von einer Komponente abzuhängen heißt, von ihr als Ganzes abzuhängen.

Die drei ziehen in entgegengesetzte Richtungen. REP und CCP sind inklusiv (sie machen Komponenten größer); CRP ist exklusiv (es macht sie kleiner). Martin zeichnet sie als Dreieck, in dem jede Kante den Preis dafür nennt, das Prinzip an der gegenüberliegenden Ecke aufzugeben.

Das Spannungsdreieck

Das Spannungsdreieck zwischen dem Reuse/Release Equivalence, dem Common Closure und dem Common Reuse PrincipleREPReuse/Release EquivalenceCCPCommon ClosureCRPCommon ReuseZu viele unnötigeReleasesZu vieleKomp. ändern sichReuse schwierigJede Kante nennt die Kostendes Verzichts auf das Prinzipan der Ecke gegenüber
Wer sich auf zwei beliebige Kohäsionsprinzipien konzentriert, zahlt den Preis, der auf der Kante zwischen ihnen steht; Architekten wählen eine Position, und die Position wandert, während das Projekt reift.

Martin beobachtet, dass sich die Position eines Projekts im Dreieck mit der Zeit verschieben sollte. Am Anfang zählt die Entwickelbarkeit am meisten, also bevorzugen Teams CCP: nach Änderung gruppieren und in Kauf nehmen, dass Komponenten schwer wiederzuverwenden sind. Wenn ein System reift und andere Teams beginnen, von seinen Teilen abzuhängen, verschiebt sich das Gleichgewicht in Richtung REP und CRP. Dieselbe Spannung tritt zwischen Services auf. Zwei Services zusammenzulegen, die sich immer gemeinsam ändern (CCP), verringert koordinierte Releases, macht das Ergebnis aber weniger wiederverwendbar; einen Service so aufzuteilen, dass Consumer nur von dem abhängen, was sie nutzen (CRP), erhöht die Zahl der deploybaren Einheiten, die eine einzelne fachliche Änderung berührt.

Ausgearbeitetes Beispiel

Ausgearbeitetes Beispiel: eine gemeinsame Komponente in einem Onlineshop prüfen

Ein Onlineshop hat ein Paket shop-core, das von fünf Services genutzt wird. Es enthält Preisregeln, Steuerberechnung, Adressvalidierung, die Erzeugung von PDF-Rechnungen und einen Datenbank-Helper. Im letzten Quartal hatte es 40 Releases. Wenden Sie die Prinzipien an.

Akteure (SRP, CCP)Die Preise ändert das Vertriebsteam, die Steuern die Finanzabteilung, wenn sich Steuersätze ändern, das PDF-Layout das Marketing, den Datenbank-Helper das Plattformteam. Vier Akteure bedeuten vier unabhängige Änderungsgründe, also ist das Paket nicht im Sinne des CCP geschlossen.
Nutzer (ISP, CRP)Der Lagerservice nutzt nur die Adressvalidierung, und doch zwang ihn jedes der 40 Releases zu einem Upgrade und erneuten Tests. Nach dem CRP waren für ihn rund 40 Releases abzüglich der wenigen, die Adressen betrafen, unnötig.
Releases (REP)Die Release Notes haben kein stimmiges Thema, sodass Consumer nicht beurteilen können, ob ein Release für sie relevant ist.
Abhängigkeiten (DIP, SDP)Der Datenbank-Helper importiert die ORM-Modelle der Services, sodass ein stabiles, weit verbreitetes Paket von volatilem Service-Code abhängt: Der Pfeil zeigt in die falsche Richtung.
Vorgeschlagene Aufteilungpricing (gehört der Vertriebsdomäne), tax (Finanzen), address, invoice-pdf, und der Datenbank-Helper wandert in jeden Service oder in eine Plattformbibliothek ohne Domänenimporte.
Erwartete WirkungJeder Service hängt nur von den Paketen ab, die er nutzt. Eine Steuersatzänderung führt zum Release eines einzigen Pakets und berührt nur die Services, die Steuern berechnen.

Die Prinzipien haben die Antwort nicht vorgegeben; sie lieferten ein Vokabular, um zu erklären, warum das aktuelle Paket Probleme macht und was jede Option kostet.

Die Aufteilung erhöht die Zahl der Pakete, die versioniert und veröffentlicht werden müssen: die Kante „zu viele Komponenten ändern sich“ im Dreieck. Das ist hier akzeptabel, weil die Änderungen überwiegend unabhängig voneinander sind.

Kritik und Grenzen

SOLID wird viel gelehrt und viel kritisiert, und beide Reaktionen haben ihre Berechtigung. Die gewichtigsten Kritikpunkte sind diese.

Die Prinzipien sind so vage, dass sie fast alles rechtfertigen. Dan North hat das in Konferenzvorträgen und in seinem Essay von 2022 vertreten, mit dem er CUPID vorstellte (composable, Unix philosophy, predictable, idiomatic, domain-based). Er argumentiert, „ein Grund zur Änderung“ sei willkürlich, da sich fast jedem Code viele Änderungsgründe zuschreiben lassen, und dass eine eifrige Anwendung künstliche Nahtstellen zwischen Code erzeugt, der sich tatsächlich gemeinsam ändert, etwa zwischen Inhalt und Format eines Berichts. Er schlägt Eigenschaften vor, die man anstreben sollte, statt Regeln, die man befolgen muss. Martin antwortete auf diese Kritik 2020 in einem Beitrag über die Relevanz von SOLID: Die Prinzipien blieben gültig, weil sich die zugrunde liegenden Probleme der Abhängigkeitssteuerung nicht geändert hätten, und wer Leuten ohne Anleitung sage, sie sollten „es einfach einfach halten“, erzeuge ein Chaos.

Dogmatische Anwendung führt zu Überabstraktion. Eine Schnittstelle für jede Klasse, eine Factory für jede Schnittstelle und ein Erweiterungspunkt für jede denkbare Variation führen zu Code, bei dem man zehn Dateien öffnen muss, um einem einzigen Verhalten zu folgen. John Ousterhouts Unterscheidung zwischen tiefen und flachen Modulen (Abschnitt 3.3) ist ein nützliches Gegengewicht: Eine Abstraktion sollte viel hinter einer kleinen Schnittstelle verbergen, und viele von SOLID inspirierte Klassen sind flach. Spekulative Erweiterungspunkte verstoßen gegen YAGNI (Abschnitt 3.8) und verursachen echte Kosten.

Die Prinzipien wurden für ein einziges Paradigma geschrieben. In Sprachen mit First-Class-Funktionen und struktureller Typisierung schrumpfen viele SOLID-Muster darauf zusammen, eine Funktion zu übergeben oder sich auf Duck Typing zu verlassen. Die zugrunde liegenden Ideen (nach Akteur trennen, von stabilen Verträgen abhängen, Schnittstellen klein halten) gelten weiterhin; das Zeremoniell oft nicht.

Die ausgewogene professionelle Sicht behandelt SOLID als diagnostisches Vokabular: Wenn ein Entwurf Probleme macht, helfen die Prinzipien zu erklären, warum, und zeigen eine Richtung auf. Sie sind keine Checkliste, die jede Klasse erfüllen muss, und „verstößt gegen das OCP“ ist für sich allein kein Argument; „jeder neue Versanddienstleister erforderte bisher Änderungen an dieser Funktion und verursachte zwei Incidents“ dagegen schon.

Entscheidungshilfe

Entscheidungshilfe: Sollte ich hier eine Abstraktion einführen?

Nutzen Sie diese Hilfe, wenn ein Prinzip nach einer neuen Schnittstelle, einem Erweiterungspunkt oder einer Aufteilung zu verlangen scheint.

  1. Ist diese Art von Variation tatsächlich mindestens zweimal aufgetreten, oder ist sie sicher (zum Beispiel, weil ein Vertrag mit einem zweiten Anbieter unterzeichnet ist)?
    JaEin Erweiterungspunkt ist wahrscheinlich gerechtfertigt; weiter.
    NeinBehalten Sie den konkreten Code, halten Sie ihn leicht refaktorierbar und warten Sie auf echte Belege (YAGNI).
  2. Werden die Teile, die Sie trennen wollen, von verschiedenen Akteuren oder in verschiedenen Rhythmen geändert?
    JaTrennen Sie sie (SRP, CCP); die Grenze verringert den Abstimmungsaufwand.
    NeinLassen Sie sie zusammen; eine Aufteilung würde Nahtstellen schaffen, die jede Änderung überqueren muss.
  3. Importiert eine High-Level-Fachlogik derzeit ein volatiles Detail wie eine Datenbank, ein Anbieter-SDK oder ein Framework?
    JaDefinieren Sie eine Schnittstelle, die der Seite der Fachlogik gehört, und implementieren Sie sie im Detail (DIP).
    NeinKeine Umkehr nötig; eine direkte Abhängigkeit von einem stabilen Modul ist in Ordnung.
  4. Wird die neue Schnittstelle erhebliche Komplexität hinter wenigen Operationen verbergen?
    JaEs ist ein tiefes Modul; es lohnt sich.
    NeinEs ist ein flacher Durchreicher; überdenken Sie es oder führen Sie es mit seinem Aufrufer zusammen.

SOLID auf drei Ebenen

PrinzipKlassenebeneKomponenten- oder Service-Ebene
Single ResponsibilityEin Akteur pro KlasseService oder Komponente, ausgerichtet an einer Geschäftsfähigkeit und einem verantwortlichen Team
Open-ClosedNeues Verhalten durch neue Implementierungen einer SchnittstelleEvents, Plug-ins und additive API-Änderungen, mit denen andere erweitern können, ohne Sie zu ändern
Liskov SubstitutionUnterklassen halten den Vertrag der Basisklasse einNeue API-Versionen und Ersatzimplementierungen halten den alten Vertrag ein: keine neuen Pflichteingaben, keine entfernten Ausgaben
Interface SegregationKleine RollenschnittstellenClientspezifische APIs (Backend for Frontend), fokussierte Event-Typen, kleine gemeinsame Bibliotheken
AbhängigkeitsumkehrVon eigenen Abstraktionen abhängenDie Domäne definiert Ports; Adapter und Infrastruktur hängen nach innen ab; stabile Eigentümer definieren Verträge

Prüfen Sie Ihr Verständnis

Eine Klasse formatiert eine Rechnung und berechnet auch deren Steuer. Verstößt das automatisch gegen das Single Responsibility Principle?

Nicht automatisch. Es verstößt dagegen, wenn die Formatierung und die Steuerregeln von verschiedenen Akteuren geändert werden (etwa Marketing und Finanzabteilung), weil dann Änderungen auf Wunsch des einen den anderen beschädigen können; gehört beides einer Gruppe, kann die Klasse in Ordnung sein.

Warum bricht ein neues Pflichtfeld in einer API-Anfrage die Ersetzbarkeit?

Es verstärkt die Vorbedingung: Aufrufer, die den alten Vertrag erfüllten, scheitern jetzt, sodass die neue Version die alte nicht mehr vertreten kann.

Was ist der Unterschied zwischen Dependency Inversion und Dependency Injection?

Bei Inversion geht es um die Richtung von Quellcode-Abhängigkeiten, wobei die Abstraktion der Seite der Fachlogik gehört; Injection ist eine Technik, Kollaborateure von außen zu übergeben. Sie können eine konkrete Klasse injizieren, ohne irgendetwas umzukehren.

Ein Team gruppiert Klassen strikt danach, was sich gemeinsam ändert, und andere Teams klagen, die Komponenten seien unmöglich wiederzuverwenden. Auf welcher Kante des Spannungsdreiecks befindet sich das Team?

Auf der Kante CCP-CRP, beschriftet mit „Reuse schwierig“: Es hat das Reuse/Release Equivalence Principle vernachlässigt.

Weiter · 3.6ConnascenceMeilir Page-Jones’ Connascence benennt die verborgenen Absprachen zwischen Teilen eines Systems, ordnet sie nach Stärke und beurteilt sie nach Lokalität und Grad, von Positionsparametern bis zum Timing zwischen Services.Weiter →

Quellen und weiterführende Literatur

Haben Sie eine Frage zu diesem Abschnitt? ArchitectBot, der KI-Lernassistent des Kurses, antwortet zuerst aus dem Kurs.

Fragen: „Unsere gemeinsame „common“-Bibliothek wird von zwölf Microservices genutzt, und jedes Release zwingt alle zum Upgrade. Wie sollten wir sie aufteilen, und welche Prinzipien sollten die Aufteilung leiten?“