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 UntertitelnNur 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
- SOLID bezeichnet fünf von Robert C. Martin zusammengetragene Prinzipien: Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation und Dependency Inversion; jedes gilt für Klassen, Komponenten und Services.
- Single Responsibility bedeutet einen Akteur pro Modul: Code, der auf Wunsch verschiedener Personengruppen geändert wird, sollte getrennt werden, und es bedeutet nicht, dass ein Modul nur eine Sache tun darf.
- Liskov Substitution verlangt Verhaltenskompatibilität: keine stärkeren Vorbedingungen, keine schwächeren Nachbedingungen und erhaltene Invarianten, und genau das ist der Test für eine abwärtskompatible API-Version.
- Interface Segregation spricht für kleine Rollenschnittstellen im Code und, zwischen Services, für clientspezifische APIs, fokussierte Events und kleine Bibliotheken.
- Dependency Inversion lässt Details von Abstraktionen abhängen, die der Seite der Fachlogik gehören; wem die Schnittstelle gehört, ist wichtiger als die Tatsache, dass es eine gibt.
- REP, CCP und CRP entscheiden, was in eine Komponente gehört, und ziehen in entgegengesetzte Richtungen; das richtige Gleichgewicht verschiebt sich mit zunehmender Reife eines Systems von CCP zu REP und CRP.
- Dogmatisch angewandt erzeugt SOLID flache, spekulative Abstraktionen; am besten dient es als Vokabular, um Entwürfe zu diagnostizieren, die echte Probleme verursachen.
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: ...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])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.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.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 oursEine Abhängigkeit über eine Komponentengrenze hinweg umkehren
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
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.
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.
- 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).
- 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.
- 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.
- 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
| Prinzip | Klassenebene | Komponenten- oder Service-Ebene |
|---|---|---|
| Single Responsibility | Ein Akteur pro Klasse | Service oder Komponente, ausgerichtet an einer Geschäftsfähigkeit und einem verantwortlichen Team |
| Open-Closed | Neues Verhalten durch neue Implementierungen einer Schnittstelle | Events, Plug-ins und additive API-Änderungen, mit denen andere erweitern können, ohne Sie zu ändern |
| Liskov Substitution | Unterklassen halten den Vertrag der Basisklasse ein | Neue API-Versionen und Ersatzimplementierungen halten den alten Vertrag ein: keine neuen Pflichteingaben, keine entfernten Ausgaben |
| Interface Segregation | Kleine Rollenschnittstellen | Clientspezifische APIs (Backend for Frontend), fokussierte Event-Typen, kleine gemeinsame Bibliotheken |
| Abhängigkeitsumkehr | Von eigenen Abstraktionen abhängen | Die 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.
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?“