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:
record Order(string Number, string Customer, decimal Net);class InvoiceService{ public decimal Vat(Order order) { return order.Net * 0.20m; }
// Die Buchhaltung: eine Zeile für die Datei, die sie in ihre Software einliest. public string ExportLine(Order order) { decimal total = order.Net + Vat(order); return string.Join(";", order.Number, Money(order.Net), Money(Vat(order)), Money(total)); }
// Der Kunde: der Text der E-Mail mit der Rechnung. public string EmailText(Order order) { decimal total = order.Net + Vat(order); return $"Hallo, {order.Customer}! Bestellung {order.Number}: {Money(total)} €"; }
private string Money(decimal amount) { return amount.ToString("F2"); }}var invoices = new InvoiceService();var order = new Order("2026-0142", "Maria", 80);Console.WriteLine(invoices.ExportLine(order));Console.WriteLine(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:
private string Money(decimal amount){ if (amount == Math.Floor(amount)) { return amount.ToString("0"); } return amount.ToString("F2");}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:
record Order(string Number, string Customer, decimal Net){ public decimal Vat => Net * 0.20m; public decimal Total => Net + 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{ public string Line(Order order) { return $"{order.Number};{order.Net:F2};{order.Vat:F2};{order.Total:F2}"; }}
class InvoiceEmail{ public string Text(Order order) { return $"Hallo, {order.Customer}! Bestellung {order.Number}: {Money(order.Total)} €"; }
private string Money(decimal amount) { if (amount == Math.Floor(amount)) { return amount.ToString("0"); } return amount.ToString("F2"); }}var order = new Order("2026-0142", "Maria", 80);Console.WriteLine(new AccountingExport().Line(order));Console.WriteLine(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:
interface IDelivery{ decimal Cost(decimal amount);}
class ToAddress : IDelivery{ public decimal Cost(decimal amount) { return amount >= 100 ? 0 : 5.90m; }}
class ToOffice : IDelivery{ public decimal Cost(decimal amount) { return 3.90m; }}Die Summe weiß nicht, welche Lieferungen es gibt:
static decimal OrderTotal(decimal amount, IDelivery delivery){ return amount + delivery.Cost(amount);}Der Shop bekommt die Lieferung an eine Packstation. Das ist eine neue Klasse:
class ToLocker : IDelivery{ public decimal Cost(decimal amount) { return 2.90m; }}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 ein Interface, eine Klasse 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?
var deliveries = new Dictionary<string, IDelivery>
{
["Adresse"] = new ToAddress(),
["Büro"] = new ToOffice(),
["Packstation"] = ,
};
foreach (var (choice, delivery) in deliveries)
{
Console.WriteLine($"{choice}: {OrderTotal(80, delivery):F2} €");
}new ToLocker(): ein Objekt der neuen Klasse. Die Tabelle enthält Objekte, die IDelivery implementieren, und die Summe ruft ihre Methode auf, ohne die Klasse zu kennen.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:
interface IPayment{ // Erstattet dem Kunden das Geld auf dem Weg, auf dem er bezahlt hat. void Refund();}
record CardPayment(decimal Amount) : IPayment{ public void Refund() { Console.WriteLine($"Karte: erstattet {Amount:F2} €"); }}
record VoucherPayment(decimal Amount) : IPayment{ public void Refund() { throw new NotSupportedException("ein Gutschein wird nicht erstattet"); }}
record PointsPayment(decimal Amount) : IPayment{ public void Refund() { Console.WriteLine($"Punkte: erstattet {Amount:F2} €"); }}Die Bestellung geht ihre Zahlungen durch und ruft bei jeder Refund() auf:
class Order{ private readonly List<IPayment> payments; public string Status { get; private set; } = "bezahlt";
public Order(List<IPayment> payments) { this.payments = payments; }
public void Cancel() { foreach (var payment in payments) { payment.Refund(); } Status = "storniert"; }}var order = new Order(new List<IPayment>{ new CardPayment(60), new VoucherPayment(20), new PointsPayment(16),});try{ order.Cancel();}catch (NotSupportedException e){ Console.WriteLine("Fehler: " + e.Message);}Console.WriteLine("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
public void Cancel(){ foreach (var payment in payments) { payment.Refund(); } Status = "storniert";}
var order = new Order(new List<IPayment>{ new CardPayment(60), new VoucherPayment(20), new PointsPayment(16),});try{ order.Cancel();}catch (NotSupportedException e){ Console.WriteLine("Fehler: " + e.Message);}Console.WriteLine("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 IPayment.
- Die Kundin storniert. Cancel() geht die Zahlungen durch und ruft bei jeder Refund() auf.
- Die Karte erstattet 60.00 €. Sie tut genau das, was IPayment verspricht.
- Der Gutschein ist auch ein IPayment, aber sein Refund() wirft NotSupportedException. 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 Main 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:
record VoucherPayment(decimal Amount) : IPayment{ public void Refund() { Console.WriteLine($"Gutschein: neuer Gutschein über {Amount:F2} €"); }}var order = new Order(new List<IPayment>{ new CardPayment(60), new VoucherPayment(20), new PointsPayment(16),});try{ order.Cancel();}catch (NotSupportedException e){ Console.WriteLine("Fehler: " + e.Message);}Console.WriteLine("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“ (NotSupportedException) 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 is, 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
IList<T>, nimmt aber Add nicht an:IList<string> items = new[] { "Kaffee", "Tasse" };try{ items.Add("Tee");}catch (NotSupportedException e){ Console.WriteLine("Ein Array als IList: " + e.Message);}Ein Array als IList: Collection was of a fixed size.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?
class ToLocker : IDelivery{ private readonly bool full;
public ToLocker(bool full) { this.full = full; }
public decimal Cost(decimal amount) { if (full) { return -1; // -1 heißt „kein Platz“ } return 2.90m; }}
Console.WriteLine($"{OrderTotal(80, new ToLocker(true)):F2} €");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. Die Lieferung sagt mit einer eigenen Methode, ob sie möglich ist, und die Summe bietet sie nicht an.
Problem
Die SMS, die nie rausging
Der Shop schickt Kunden Benachrichtigungen. Jemand hat alles, was versendet werden kann, an einer Stelle beschrieben:
interface INotifier{ void Email(string to, string text);
void Sms(string phone, string text);}Probier es aus
Finde den Fehler
Der Kunde bekommt eine E-Mail, dass das Paket versandt wurde. Die SMS des Kuriers kommt nie:
INotifier 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 das Interface es verlangt hat. 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.
E-Mail und SMS werden zwei Interfaces:
interface IEmailSender{ void Email(string to, string text);}
interface ISmsSender{ void Sms(string phone, string text);}Jeder Anbieter macht nur, was er kann, und die Kurier-Nachricht verlangt nur SMS:
class MailProvider : IEmailSender{ public void Email(string to, string text) { Console.WriteLine("E-Mail an " + to + ": " + text); }}
class SmsGateway : ISmsSender{ public void Sms(string phone, string text) { Console.WriteLine("SMS an " + phone + ": " + text); }}
class ShippingNotice{ private readonly ISmsSender sender;
public ShippingNotice(ISmsSender sender) { this.sender = sender; }
public void CourierToday(string phone) { sender.Sms(phone, "Der Kurier kommt heute zwischen 14 und 16 Uhr."); }}IEmailSender 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, kompiliert das Programm nicht:
new ShippingNotice(new MailProvider()).CourierToday("0888 123 456");error CS1503: Argument 1: cannot convert from 'MailProvider' to 'ISmsSender'Der Fehler von der vorigen Karte ist unmöglich: Niemand verlangt eine leere Methode.
Kleine Interfaces helfen auch den Tests: Der Ersatz für die Nachricht implementiert eine Methode, nicht alles, was der Shop verschicken kann. Eine Klasse kann beide implementieren, wenn der Anbieter wirklich E-Mails und SMS verschickt: class MailAndSms : IEmailSender, ISmsSender.
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{ public void Send(string to, string text) { Console.WriteLine("SMTP an " + to + ": " + text); }}
class InvoiceService{ private readonly SmtpMailer mailer = new SmtpMailer();
public void Issue(Order order, string email) => mailer.Send(email, $"Rechnung {order.Number}: {order.Total:F2} €");}var 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.
interface IInvoiceSender{ void Send(string to, string text);}
class InvoiceService(IInvoiceSender sender){ public void Issue(Order order, string email) => sender.Send(email, $"Rechnung {order.Number}: {order.Total:F2} €");}Die Details stehen getrennt. Der Mailer und der Testersatz implementieren die Abstraktion des Service:
class SmtpSender : IInvoiceSender{ public void Send(string to, string text) => Console.WriteLine("SMTP an " + to + ": " + text);}
class FakeSender : IInvoiceSender{ public List<string> Sent { get; } = [];
public void Send(string to, string text) => Sent.Add(to + " | " + text);}var order = new Order("2026-0142", "Maria", 80);new InvoiceService(new SmtpSender()) .Issue(order, "maria@example.com");
var fake = new FakeSender();var service = new InvoiceService(fake);service.Issue(order, "maria@example.com");Console.WriteLine("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 €interface IInvoiceSender{ void Send(string to, string text);}
class InvoiceService(IInvoiceSender sender){ public void Issue(Order order, string email) => sender.Send(email, $"Rechnung {order.Number}: {order.Total:F2} €");}
class SmtpSender : IInvoiceSender{ public void Send(string to, string text) => Console.WriteLine("SMTP an " + to + ": " + text);}
var order = new Order("2026-0142", "Maria", 80);new InvoiceService(new SmtpSender()) .Issue(order, "maria@example.com");
var fake = new FakeSender();var service = new InvoiceService(fake);service.Issue(order, "maria@example.com");Console.WriteLine("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: IInvoiceSender, direkt bei sich.
- InvoiceService bekommt den Versand im Konstruktor und kennt nur IInvoiceSender. Von SMTP weiß er nichts.
- SmtpSender implementiert das IInvoiceSender des Service. Der Pfeil im Code 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 IInvoiceSender, 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:
InvoiceService(SmtpMailer mailer). Der Mailer kommt von außen, aber der Service kennt weiter die SMTP-Klasse, und ein Test ohne SMTP ist nicht möglich; - DIP ohne DI-Bibliothek: In Main 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
IInvoiceSender im Projekt (oder Namespace) der Rechnungen, und SmtpSender liegt im Mail-Projekt, das auf die Rechnungen verweist. Das Rechnungsprojekt verweist nicht auf die Mail. Das ist der umgekehrte Pfeil, sichtbar in den Projektverweisen.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:
Console.WriteLine("Der Test sah: " + fake.Sent[0]);var service = new InvoiceService(fake);var fake = new FakeSender();service.Issue(order, "maria@example.com");
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“:
interface ITaxPolicy{ decimal Vat(decimal net);}
class StandardVat : ITaxPolicy{ public decimal Vat(decimal net) { return net * 0.20m; }}
static class TaxPolicyFactory{ public static ITaxPolicy Create() { return new StandardVat(); }}Console.WriteLine($"MwSt.: {TaxPolicyFactory.Create().Vat(80):F2} €");MwSt.: 16.00 €Und dasselbe ohne Dogma:
const decimal VatRate = 0.20m;Console.WriteLine($"MwSt.: {80 * VatRate:F2} €");MwSt.: 16.00 €Die Ausgabe ist dieselbe. Das Erste ist ein Interface, 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 ein Interface mit einer Implementierung, 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