SOLID ohne Dogma
Kurz gesagt: SOLID sind fünf Prinzipien für Code, der Änderungen übersteht: ein Grund zur Änderung, Erweiterung ohne Änderung, austauschbare Untertypen, kleine Interfaces und Abhängigkeit von Abstraktionen.
Problem
Die E-Mail-Änderung, die die Buchhaltung gebrochen hat
Der Online-Shop schickt dem Kunden eine Rechnung per E-Mail. Jeden Abend gibt er der Buchhaltung außerdem eine Datei mit einer Zeile pro Bestellung. Eine Klasse macht beides:
class Order { constructor(number, customer, net) { this.number = number; this.customer = customer; this.net = net; }}class InvoiceService { vat(order) { return order.net * 0.2; }
// Die Buchhaltung: eine Zeile für die Datei, die sie in ihre Software einliest. exportLine(order) { const total = order.net + this.vat(order); return [order.number, this.#money(order.net), this.#money(this.vat(order)), this.#money(total)].join(";"); }
// Der Kunde: der Text der E-Mail mit der Rechnung. emailText(order) { const total = order.net + this.vat(order); return `Hallo, ${order.customer}! Bestellung ${order.number}: ${this.#money(total)} €`; }
#money(amount) { return amount.toFixed(2); }}const invoices = new InvoiceService();const order = new Order("2026-0142", "Maria", 80);console.log(invoices.exportLine(order));console.log(invoices.emailText(order));2026-0142;80.00;16.00;96.00Hallo, Maria! Bestellung 2026-0142: 96.00 €Marketing sagt: „Schreibt in der E-Mail bitte 96 €, nicht 96,00 €. Das sieht sauberer aus.“ Jemand ändert die Hilfsmethode Methode #money:
#money(amount) { if (Number.isInteger(amount)) { return String(amount); } return amount.toFixed(2);}2026-0142;80;16;96Hallo, Maria! Bestellung 2026-0142: 96 €Die E-Mail ist so, wie sie sie wollten. Am nächsten Morgen lehnt die Buchhaltungssoftware die Datei ab: Sie erwartet 80.00 und bekommt 80. Niemand hat die Zeile der Buchhaltung angefasst. Geändert hat sich die Hilfsmethode, die sich beide Zeilen teilen. Der Test der E-Mail läuft durch, und für die Datei gibt es keinen Test, denn „daran hat ja niemand etwas geändert“.
Die Idee
Ein Grund zur Änderung
Die Klasse bedient zwei Parteien, die einander nicht kennen. Die Buchhalterin braucht ein exaktes Format, das ihre Software liest. Marketing will einen angenehmen Text für den Kunden. Sie verlangen Änderungen zu verschiedenen Zeiten und aus verschiedenen Gründen. Solange ihr Code in einer Klasse lebt, kann eine Änderung für den einen den anderen brechen.
Das ist das erste der fünf SOLID-Prinzipien (gesammelt von Robert Martin; der Name entsteht aus ihren Anfangsbuchstaben). Das Single Responsibility Principle (SRP): Eine Klasse hat einen Grund zur Änderung.
„Grund“ heißt, wer die Änderung verlangt: eine Person, eine Abteilung, ein Gesetz. Es heißt nicht „eine Methode“, und es heißt nicht „eine kleine Klasse“. Die Frage an dich selbst: „Wer wird kommen und hier eine Änderung verlangen?“ Wenn die Antwort „die Buchhaltung oder das Marketing“ lautet, sind das zwei Klassen.
Sieh es dir an
Jeder bekommt seine eigene Klasse
Die Mehrwertsteuer und die Summe sind eine Regel des Steuerrechts, und beide Parteien brauchen sie. Sie gehören an einer Stelle in die Bestellung:
class Order { constructor(number, customer, net) { this.number = number; this.customer = customer; this.net = net; }
vat() { return this.net * 0.2; }
total() { return this.net + this.vat(); }}Die Zeile für die Buchhaltung und der Text der E-Mail werden getrennte Klassen. Jede formatiert Beträge auf ihre Art:
class AccountingExport { line(order) { return [order.number, order.net.toFixed(2), order.vat().toFixed(2), order.total().toFixed(2)].join(";"); }}
class InvoiceEmail { text(order) { return `Hallo, ${order.customer}! Bestellung ${order.number}: ${this.#money(order.total())} €`; }
#money(amount) { if (Number.isInteger(amount)) { return String(amount); } return amount.toFixed(2); }}const order = new Order("2026-0142", "Maria", 80);console.log(new AccountingExport().line(order));console.log(new InvoiceEmail().text(order));2026-0142;80.00;16.00;96.00Hallo, Maria! Bestellung 2026-0142: 96 €Die E-Mail hat ganze Beträge, und die Datei hat weiter zwei Nachkommastellen. Morgen ändert die Buchhaltung das Format, und du fasst nur AccountingExport an. Der Code der E-Mail liegt gar nicht vor dir.
Die zwei Arten, einen Betrag zu schreiben, werden nicht mehr geteilt. Das ist keine Duplizierung: Sie sehen heute ähnlich aus, aber es sind zwei Regeln, die sich aus verschiedenen Gründen ändern.
Wann es wehtut. Das andere Extrem ist eine Klasse für jede Handlung: VatCalculator, TotalCalculator, NumberFormatter, GreetingBuilder, je drei Zeilen. Sie ändern sich immer zusammen, weil dieselbe Person sie verlangt, und du öffnest vier Dateien, um eine Regel zu lesen. Eine Klasse mit 200 Zeilen, die eine Person bedient, ist besser als fünf Klassen, die du immer zusammen öffnest. Was sich zusammen ändert, bleibt zusammen.
Genauer, für Neugierige
- Robert Martin selbst schreibt, dass „eine Verantwortung“ oft als „macht eine Sache“ gelesen wird. Seine spätere Klarstellung: Ein Modul antwortet einem Akteur, einer Gruppe von Menschen, die Änderungen verlangen. „Macht eine Sache“ ist eine Regel für Funktionen, nicht für Klassen.
- „Don't repeat yourself“ (DRY) geht um Wissen, nicht um Text. Zwei Stücke Code, die gleich aussehen, sich aber aus verschiedenen Gründen ändern, sind keine Duplizierung. Wenn du sie in eine Hilfsmethode zusammenlegst, bindest du zwei Menschen mit einem Knoten aneinander. Genau das ist im Problem passiert.
Probier es aus
Wer verlangt die Änderung
Sieh es dir an
Eine neue Lieferung, ohne die Summe anzufassen
Das zweite Prinzip ist open/closed (Open/Closed Principle, OCP): Code ist offen für Erweiterung und geschlossen für Änderung. Eine neue Variante kommt als neuer Code, und der Code, der schon funktioniert und getestet ist, wird nicht angefasst.
Du hast es in der Lektion Strategy gesehen: Die Kette von ifs für die Zahlungsart wurde zu einer Tabelle Name → Strategie. Mit den Lieferungen ist es dasselbe. Jede Lieferung kennt ihren Preis:
function toAddress(amount) { return amount >= 100 ? 0 : 5.9;}
function toOffice(amount) { return 3.9;}Die Summe weiß nicht, welche Lieferungen es gibt:
function orderTotal(amount, delivery) { return amount + delivery(amount);}Der Shop bekommt die Lieferung an eine Packstation. Das ist eine neue Funktion:
function toLocker(amount) { return 2.9;}orderTotal, die Lieferung an eine Adresse und ins Büro werden nicht angefasst. Es bleibt, die neue Lieferung in die Tabelle zu setzen, aus der der Kunde wählt. Das ist die nächste Karte.
Wann es wehtut. Du kannst den Code nicht gegen alles schließen, und du sollst es auch nicht. Jeder Erweiterungspunkt ist eine Funktion und eine Zeile in einer Tabelle, die jemand lesen muss. Du schließt dort, wo Änderungen wirklich kommen: Neue Lieferungen kommen jede Saison. Die Faustregel: Beim ersten Mal schreibst du ein if. Wenn die dritte Variante kommt, machst du die Tabelle (Replace Conditional with Polymorphism aus der Lektion zum Refactoring).
Probier es aus
Eine neue Zeile in der Tabelle
Die Tabelle aus der Strategy-Lektion listet die Lieferungen auf, aus denen der Kunde wählt. Füge die Packstation hinzu.
Was kommt in die Lücke?
const deliveries = new Map([
["Adresse", toAddress],
["Büro", toOffice],
["Packstation", ],
]);
for (const [choice, delivery] of deliveries) {
console.log(`${choice}: ${orderTotal(80, delivery).toFixed(2)} €`);
}toLocker, ohne Klammern. Die Tabelle enthält die Funktion selbst. Mit Klammern enthielte sie ihr Ergebnis (2.9), und die Summe hätte nichts zum Aufrufen.Adresse: 85.90 €Büro: 83.90 €Packstation: 82.90 €Nur die Tabelle hat sich geändert, und sie zählt nur auf. OCP heißt nicht „keine einzige geänderte Zeile“. Es heißt, dass die Regeln, die schon funktionieren und getestet sind, für jede neue Variante unberührt bleiben.
Problem
Die Stornierung, die auf halbem Weg stehen blieb
Eine Kundin bezahlt eine Bestellung auf drei Arten: 60 € mit Karte, einen Gutschein über 20 € und 16 € in Punkten. Dann storniert sie. Jede Zahlungsart weiß, wie sie ihr Geld zurückgibt:
// Jede Zahlung hat refund(): Es erstattet dem Kunden das Geld auf dem Weg, auf dem er bezahlt hat.class Payment { constructor(amount) { this.amount = amount; }}
class CardPayment extends Payment { refund() { console.log(`Karte: erstattet ${this.amount.toFixed(2)} €`); }}
class VoucherPayment extends Payment { refund() { throw new Error("ein Gutschein wird nicht erstattet"); }}
class PointsPayment extends Payment { refund() { console.log(`Punkte: erstattet ${this.amount.toFixed(2)} €`); }}Die Bestellung geht ihre Zahlungen durch und ruft bei jeder refund() auf:
class Order { constructor(payments) { this.payments = payments; this.status = "bezahlt"; }
cancel() { for (const payment of this.payments) { payment.refund(); } this.status = "storniert"; }}const order = new Order([ new CardPayment(60), new VoucherPayment(20), new PointsPayment(16),]);try { order.cancel();} catch (e) { console.log(`Fehler: ${e.message}`);}console.log(`Status: ${order.status}`);Karte: erstattet 60.00 €Fehler: ein Gutschein wird nicht erstattetStatus: bezahltDie Karte ist erstattet, die Punkte nicht, und die Bestellung ist noch „bezahlt“. Das Lager sieht „bezahlt“ und verschickt das Paket. Der Code von cancel() ist richtig. Der Fehler steckt im Gutschein: Er ist ein Payment, tut aber nicht, was Payment verspricht.
Sieh es dir an
Der Untertyp, der das Versprechen bricht
cancel() { for (const payment of this.payments) { payment.refund(); } this.status = "storniert";}
const order = new Order([ new CardPayment(60), new VoucherPayment(20), new PointsPayment(16),]);try { order.cancel();} catch (e) { console.log(`Fehler: ${e.message}`);}console.log(`Status: ${order.status}`);Schritt für Schritt, in Worten
- Die Bestellung wurde auf drei Arten bezahlt: 60.00 € mit Karte, ein Gutschein über 20.00 € und 16.00 € in Punkten. Alle drei sind Payment.
- Die Kundin storniert. cancel() geht die Zahlungen durch und ruft bei jeder refund() auf.
- Die Karte erstattet 60.00 €. Sie tut genau das, was Payment verspricht.
- Der Gutschein ist auch ein Payment, aber sein refund() wirft Error. Das Versprechen ist gebrochen.
- Die Exception verlässt die Schleife und cancel(). Die Punkte (16.00 €) werden nicht erstattet, und die Zeile, die status auf „storniert“ setzt, läuft nie.
- Das catch in dem Programm fängt sie ab und gibt den Fehler aus. Das Programm läuft weiter.
- Der Status ist noch immer „bezahlt“. Das Lager verschickt die Bestellung, und 60.00 € sind schon wieder auf der Karte.
Das ist das dritte Prinzip, Barbara Liskovs Substitutionsprinzip (Liskov Substitution Principle, LSP): Überall, wo der Code mit einem Typ arbeitet, kann jeder Untertyp einspringen, und der Code merkt es nicht. Der Untertyp darf die Arbeit auf seine Art erledigen, aber er hält das Versprechen des Typs.
Payment verspricht „Ich gebe dem Kunden das Geld zurück“. Was heißt das für einen Gutschein in einem echten Shop? Einen neuen Gutschein über denselben Betrag:
class VoucherPayment extends Payment { refund() { console.log(`Gutschein: neuer Gutschein über ${this.amount.toFixed(2)} €`); }}const order = new Order([ new CardPayment(60), new VoucherPayment(20), new PointsPayment(16),]);try { order.cancel();} catch (e) { console.log(`Fehler: ${e.message}`);}console.log(`Status: ${order.status}`);Karte: erstattet 60.00 €Gutschein: neuer Gutschein über 20.00 €Punkte: erstattet 16.00 €Status: storniertcancel() hat sich nicht geändert. Der Untertyp passt jetzt dorthin, wo der Typ erwartet wird.
Eine Verletzung erkennst du an drei Zeichen:
- Der Untertyp wirft „nicht unterstützt“ (einen Fehler) bei einer Methode, die der Typ verspricht;
- der Untertyp liefert etwas, das der Typ nicht verspricht (
-1,null, einen leeren String), oder verlangt mehr vom Aufrufer; - der Code prüft mit instanceof, bevor er die Methode aufruft. Das ist ein Umweg um einen Untertyp, der nicht passt.
Wann es wehtut. Hier geht das Dogma in die andere Richtung: etwas in die Hierarchie zu zwingen, „weil es auch eine Zahlung ist“. Wenn etwas wirklich kein Geld zurückgeben kann (etwa ein Geschenk des Shops), ist es kein Payment mit refund. Gib ihm lieber einen eigenen Typ, statt eine Methode zu schreiben, die wirft.
Genauer, für Neugierige
Array (Array.isArray sagt true), nimmt aber push nicht an:const items = Object.freeze(["Kaffee", "Tasse"]);try { items.push("Tee");} catch (e) { console.log(`${e.name}: ${e.message}`);}TypeError: Cannot add property 2, object is not extensible"use strict") ist der Fehler ein TypeError. Außerhalb des Strict-Modus wirft push weiterhin, aber das Zuweisen eines Elements tut still nichts.Formal, nach Liskov: Ein Untertyp verlangt nicht mehr als der Typ (verschärft die Bedingungen an die Eingabe nicht), liefert nicht weniger (schwächt das Versprechen über das Ergebnis nicht ab) und bewahrt, was für den Typ immer gilt.
Probier es aus
Die stille Verletzung
Die Lieferung an die Packstation gibt -1 zurück, wenn die Station voll ist. Die Summe addiert es, wie immer. Was gibt das Programm aus?
function toLocker(full) { return (amount) => { if (full) { return -1; // -1 heißt „kein Platz“ } return 2.9; };}
console.log(`${orderTotal(80, toLocker(true)).toFixed(2)} €`);79.00 €. Die Summe ist für das Versprechen „der Preis der Lieferung, 0 oder mehr“ geschrieben. Sie addiert 80 und -1, und der Kunde zahlt 1 € weniger für eine Lieferung, die gar nicht stattfinden kann. Nichts wirft, und das macht diese Verletzung gefährlicher als die Exception. Die Lösung: Ein volles Fach ist kein Preis. Das volle Fach kommt nicht in die Tabelle der Lieferungen, die der Kunde wählen kann.
Problem
Die SMS, die nie rausging
Der Shop schickt Kunden Benachrichtigungen. Jemand hat alles, was versendet werden kann, an einer Stelle beschrieben:
// Benachrichtigungen: Jeder, der Benachrichtigungen verschickt, hat email(to, text) und sms(phone, text).notifier.sms(...) auf, und ohne die Methode bricht das Programm mit einem TypeError ab. Also hat jemand sie geschrieben.Probier es aus
Finde den Fehler
Der Kunde bekommt eine E-Mail, dass das Paket versandt wurde. Die SMS des Kuriers kommt nie:
const notifier = new MailProvider();notifier.email("maria@example.com", "Bestellung 2026-0142 ist versandt.");notifier.sms("0888 123 456", "Der Kurier kommt heute zwischen 14 und 16 Uhr.");E-Mail an maria@example.com: Bestellung 2026-0142 ist versandt.In welcher Zeile steckt der Fehler?
Wähle die Zeile mit dem Fehler.
Die SMS-Methode ist leer. Das ist eine Lüge, die niemand hört: Der Code ruft sie auf, nichts geht raus, und es gibt keinen Fehler. Der Anbieter hat sie geschrieben, weil der Code sms bei jedem aufruft, der Benachrichtigungen verschickt. Die leere Methode bricht auch LSP: Das Versprechen „Ich verschicke eine SMS“ wird nicht gehalten. Die echte Lösung steckt nicht in dieser Zeile, sondern im Vertrag: die nächste Karte.
Sieh es dir an
Kleine Interfaces
Das vierte Prinzip: Interface Segregation (Interface Segregation Principle, ISP). Niemand soll von Methoden abhängen, die er nicht nutzt. Der Vertrag wird aus der Sicht dessen geschrieben, der ihn nutzt, und er ist so groß, wie er ihn braucht.
JavaScript hat keine Interfaces. Der kleine Vertrag ist das, was der Code aufruft: Die Kurier-Nachricht ruft nursms auf, also passt jedes Objekt mit sms. Hier heißt das Prinzip: Verlange nicht mehr, als du aufrufst, und lass niemanden leere Methoden schreiben.Jeder Anbieter macht nur, was er kann, und die Kurier-Nachricht verlangt nur SMS:
class MailProvider { email(to, text) { console.log(`E-Mail an ${to}: ${text}`); }}
class SmsGateway { sms(phone, text) { console.log(`SMS an ${phone}: ${text}`); }}
class ShippingNotice { constructor(sender) { this.sender = sender; }
courierToday(phone) { this.sender.sms(phone, "Der Kurier kommt heute zwischen 14 und 16 Uhr."); }}const email = new MailProvider();email.email("maria@example.com", "Bestellung 2026-0142 ist versandt.");new ShippingNotice(new SmsGateway()).courierToday("0888 123 456");E-Mail an maria@example.com: Bestellung 2026-0142 ist versandt.SMS an 0888 123 456: Der Kurier kommt heute zwischen 14 und 16 Uhr.Wenn jemand den E-Mail-Anbieter übergibt, wo eine SMS gebraucht wird, bricht das Programm beim ersten Aufruf ab, statt still zu bleiben:
try { new ShippingNotice(new MailProvider()).courierToday("0888 123 456");} catch (e) { console.log(`${e.name}: ${e.message}`);}TypeError: this.sender.sms is not a functionKleine Interfaces helfen auch den Tests: Der Ersatz für die Nachricht implementiert eine Methode, nicht alles, was der Shop verschicken kann.
Wann es wehtut. Ein Interface für jede Methode „wegen ISP“ ist das andere Extrem: Readable, Writable, Deletable, Countable vor einem Repository, das an einer Stelle und immer als Ganzes benutzt wird. Wenn jeder, der den Vertrag nutzt, ihn ganz nutzt, ist es ein Vertrag.
Problem
Der Test, der eine echte E-Mail verschickt
Die Rechnung geht per E-Mail raus. Der Service baut sich seinen Mailer selbst:
class SmtpMailer { send(to, text) { console.log(`SMTP an ${to}: ${text}`); }}
class InvoiceService { constructor() { this.mailer = new SmtpMailer(); }
issue(order, email) { const total = order.total().toFixed(2); const text = `Rechnung ${order.number}: ${total} €`; this.mailer.send(email, text); }}const order = new Order("2026-0142", "Maria", 80);new InvoiceService().issue(order, "maria@example.com");SMTP an maria@example.com: Rechnung 2026-0142: 96.00 €Es funktioniert, aber:
- Der Test der Rechnung verschickt eine echte E-Mail, und auf einem Rechner ohne SMTP-Server schlägt er fehl;
- der Shop wechselt von SMTP zu einem Dienst mit API, und du änderst die Klasse mit den Regeln der Rechnung, um den Mailer zu wechseln.
Die Regel (was auf die Rechnung kommt) hängt vom Detail ab (wie die E-Mail rausgeht).
Sieh es dir an
Die Abstraktion gehört der Regel
Das fünfte Prinzip: Dependency Inversion (Dependency Inversion Principle, DIP). Der Code mit den Regeln hängt nicht von den Details ab. Beide hängen von einer Abstraktion ab, und sie gehört den Regeln: Sie sagt, was die Regeln brauchen („schicke diesen Text an diese Adresse“), nicht wie SMTP es macht.
class InvoiceService { // sender ist jedes Objekt mit einer Methode send(to, text). constructor(sender) { this.sender = sender; }
issue(order, email) { const total = order.total().toFixed(2); const text = `Rechnung ${order.number}: ${total} €`; this.sender.send(email, text); }}Die Details stehen getrennt. Der Mailer und der Testersatz haben die Methode, die der Service aufruft:
class SmtpSender { send(to, text) { console.log(`SMTP an ${to}: ${text}`); }}
class FakeSender { constructor() { this.sent = []; }
send(to, text) { this.sent.push(`${to} | ${text}`); }}const order = new Order("2026-0142", "Maria", 80);new InvoiceService(new SmtpSender()) .issue(order, "maria@example.com");
const fake = new FakeSender();const service = new InvoiceService(fake);service.issue(order, "maria@example.com");console.log(`Der Test sah: ${fake.sent[0]}`);SMTP an maria@example.com: Rechnung 2026-0142: 96.00 €Der Test sah: maria@example.com | Rechnung 2026-0142: 96.00 €class InvoiceService { // sender ist jedes Objekt mit einer Methode send(to, text). constructor(sender) { this.sender = sender; }
issue(order, email) { const total = order.total().toFixed(2); const text = `Rechnung ${order.number}: ${total} €`; this.sender.send(email, text); }}
class SmtpSender { send(to, text) { console.log(`SMTP an ${to}: ${text}`); }}
const order = new Order("2026-0142", "Maria", 80);new InvoiceService(new SmtpSender()) .issue(order, "maria@example.com");
const fake = new FakeSender();const service = new InvoiceService(fake);service.issue(order, "maria@example.com");console.log(`Der Test sah: ${fake.sent[0]}`);Schritt für Schritt, in Worten
- Vorher zeigten die Rechnungen nach unten, auf SmtpMailer. Jetzt schreibt der Service auf, was er braucht: ein Objekt mit send(to, text).
- InvoiceService bekommt den Versand im Konstruktor und kennt nur send(to, text). Von SMTP weiß er nichts.
- SmtpSender hat send(to, text), den Vertrag, den der Service beschrieben hat. Der Pfeil zeigt nach oben: Das Detail hängt von der Regel ab. Das ist die Umkehrung.
- Zur Laufzeit geht der Aufruf weiter nach unten: vom Service zu SMTP. Umgekehrt ist die Abhängigkeit im Code, nicht die Richtung des Aufrufs.
- Im Test bekommt derselbe Service FakeSender: ein zweites Detail, das auf dieselbe Abstraktion zeigt. Es geht keine E-Mail raus: Der Ersatz schreibt in sent auf, was er bekommen hat.
- Der Test sieht genau den Text der Rechnung, ohne Mailserver.
Die „Inversion“ betrifft den Pfeil im Code. Vorher kannten die Rechnungen SMTP. Jetzt kennt SMTP den Vertrag send(to, text), der neben den Rechnungen steht. Zur Laufzeit geht der Aufruf weiter von den Rechnungen zu SMTP.
DIP und DI. In der Lektion Singleton und Dependency Injection hast du die Übergabe von außen gesehen: Das Objekt bekommt seine Abhängigkeit im Konstruktor. Das ist der Mechanismus. DIP sagt, wovon man abhängen soll: von einer Abstraktion, die dir gehört. Das eine geht auch ohne das andere:
-
Übergabe ohne DIP: Der Service bekommt den Mailer von außen, aber seine Datei importiert weiter
import { SmtpMailer } from "./mail.js"und ruft etwas auf, das nur SMTP hat; - DIP ohne DI-Bibliothek: In dem Programm bekommt
InvoiceServicedenSmtpSender, und das ist die ganze Verdrahtung. Es gibt eine Stelle, an der das Programm die Objekte zusammensetzt.
Wann es wehtut. Ein Interface vor jeder Klasse „wegen DIP“ ist das häufigste Dogma. Eine Abstraktion lohnt sich vor einem Detail, das langsam, extern oder veränderlich ist: Mail, Zahlung, Datenbank, Uhr, Netzwerk. Vor Order oder vor dem Formatieren eines Betrags ist sie Code zum Lesen ohne Nutzen.
Genauer, für Neugierige
invoices.js nicht smtp.js. main.js importiert beide und übergibt new SmtpSender() an den Service. Es gibt keine Abstraktion als Typ: Es ist die Methode send(to, text), im Kommentar beschrieben und vom Test mit dem Ersatz geprüft.Probier es aus
Bring den Test in Ordnung
Der Test prüft die Rechnung ohne echten Mailer. Die order gibt es schon. Bring die Zeilen in die richtige Reihenfolge:
const service = new InvoiceService(fake);service.issue(order, "maria@example.com");console.log(`Der Test sah: ${fake.sent[0]}`);const fake = new FakeSender();
Der Ersatz kommt zuerst, weil der Service ihn im Konstruktor will. Dann wird die Rechnung ausgestellt, und erst dann gibt es etwas zu prüfen. Die Ausgabe: Der Test sah: maria@example.com | Rechnung 2026-0142: 96.00 €
Kurz gesagt
Wenn SOLID wehtut
Hier ist die Mehrwertsteuer, geschrieben „nach allen Regeln“:
class StandardVat { vat(net) { return net * 0.2; }}
class TaxPolicyFactory { static create() { return new StandardVat(); }}console.log(`MwSt.: ${TaxPolicyFactory.create().vat(80).toFixed(2)} €`);MwSt.: 16.00 €Und dasselbe ohne Dogma:
const VAT_RATE = 0.2;console.log(`MwSt.: ${(80 * VAT_RATE).toFixed(2)} €`);MwSt.: 16.00 €Die Ausgabe ist dieselbe. Das Erste ist eine Klasse und eine Fabrik für eine Zahl, die sich einmal in zehn Jahren ändert, und ohnehin an einer Stelle. Das ist der Smell namens spekulative Allgemeinheit (speculative generality): Code für Änderungen, die niemand verlangt hat. Wenn ein zweiter Satz kommt (9 % für Lebensmittel), ist das der Moment für eine Tabelle.
Die Prinzipien sind Fragen, keine Regeln. Stell sie, wenn der Code wehtut:
| Die Frage | Das Dogma, das du meiden sollst | |
|---|---|---|
| SRP | Wer wird hier eine Änderung verlangen? Eine Partei oder zwei? | Eine Klasse für jede Methode |
| OCP | Was kommt wirklich jeden Monat neu? | Erweiterungspunkte überall |
| LSP | Kann jeder Untertyp hier ohne ein if stehen? |
Eine Hierarchie wegen des Wortes „ist“ |
| ISP | Nutzt dieser Client alles, was er verlangt? | Ein Interface für jede Methode |
| DIP | Ist das Detail langsam, extern oder austauschbar? | Ein Interface vor jeder Klasse |
Übung
Eine Geschenkkarte, ohne die Stornierung anzufassen
Löse die Aufgabe in deinem Editor.
Geschenkkarte. Der Shop nimmt auch Geschenkkarten an. Bei einer Stornierung geht das Geld zurück auf die Karte. Füge eine GiftCardPayment hinzu, die das Versprechen von Payment hält, und storniere eine Bestellung, die mit 40 € per Karte und 25 € per Geschenkkarte bezahlt wurde. cancel() und die anderen Zahlungen werden nicht angefasst.
Karte: erstattet 40.00 €Geschenkkarte: 25.00 € zurück auf die KarteStatus: storniertWeitere Aufgaben
- Der Monatsbericht. Eine Klasse berechnet den Umsatz des Monats, schreibt eine CSV für die Buchhaltung und einen Text für die Geschäftsführung und verschickt die E-Mail selbst. Teile sie danach, wer die Änderungen verlangt. Was bleibt gemeinsam?
- Push-Nachricht. Füge in der mobilen App eine Benachrichtigung hinzu, wenn das Paket versandt wurde. Welchen kleinen Vertrag brauchst du, und welche Klassen fasst du nicht an?
- Eine Abstraktion entfernen. Suche in deinem eigenen Projekt eine Klasse oder Funktion, die „für alle Fälle“ eingepackt wurde, hinter der kein langsames oder externes Detail steckt. Entferne die Hülle und lass die Tests laufen.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Singleton und Dependency Injection · Funktionen: eine Aufgabe, wenige Parameter, keine versteckten Seiteneffekte · Code-Smells: lange Methode, große Klasse, Duplikate, Primitive statt Typen, lange if-Ketten · Refactoring in kleinen Schritten
- Geht weiter in: Übung: eine chaotische Klasse, Schritt für Schritt aufgeräumt · Schichten · Hexagonale und Clean Architecture · Monolith, modularer Monolith, Services
- Aufgaben zum Thema: Sauberer Code und Refactoring