Zum Inhalt
wannacode

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));
Ausgabe
2026-0142;80.00;16.00;96.00
Hallo, 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");
}
Ausgabe
2026-0142;80;16;96
Hallo, 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));
Ausgabe
2026-0142;80.00;16.00;96.00
Hallo, 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

1. Welche Frage verrät dir, ob eine Klasse nur einen Grund zur Änderung hat?
2. ShippingLabel berechnet den Preis der Lieferung (der Vertrieb verlangt Änderungen daran), zeichnet das Etikett (das Format gehört der Kurierfirma) und schreibt die Zeile fürs Lager (das Lager verlangt sie). Wie ordnest du das?

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} €");
}

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);
Ausgabe
Karte: erstattet 60.00 €
Fehler: ein Gutschein wird nicht erstattet
Status: bezahlt

Die 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
  1. 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.
  2. Die Kundin storniert. Cancel() geht die Zahlungen durch und ruft bei jeder Refund() auf.
  3. Die Karte erstattet 60.00 €. Sie tut genau das, was IPayment verspricht.
  4. Der Gutschein ist auch ein IPayment, aber sein Refund() wirft NotSupportedException. Das Versprechen ist gebrochen.
  5. 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.
  6. Das catch in Main fängt sie ab und gibt den Fehler aus. Das Programm läuft weiter.
  7. 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);
Ausgabe
Karte: erstattet 60.00 €
Gutschein: neuer Gutschein über 20.00 €
Punkte: erstattet 16.00 €
Status: storniert

Cancel() 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.
Der Compiler sieht keines der drei. Er prüft, dass die Methode geschrieben ist, nicht dass sie tut, was sie verspricht.

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
Die Standardbibliothek hat auch so einen Untertyp. Ein Array ist eine 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);
}
Ausgabe
Ein Array als IList: Collection was of a fixed size.
Deshalb sagt es die Bibliothek in ihrer Dokumentation: Die Methode ist eine „optionale Operation“. Wenn dein Typ eine Methode hat, die manchmal nicht funktioniert, sag es im Namen des Typs oder teile ihn in zwei.

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} €");
Antwort

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);
}
Der E-Mail-Anbieter verschickt nur E-Mails, aber das Interface verlangt auch SMS. Ohne die Methode kompiliert die Klasse nicht, 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:

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.");
Ausgabe
E-Mail an maria@example.com: Bestellung 2026-0142 ist versandt.

In welcher Zeile steckt der Fehler?

Wähle die Zeile mit dem Fehler.

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");
Ausgabe
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");
Fehler
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");
Ausgabe
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]);
Ausgabe
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
  1. Vorher zeigten die Rechnungen nach unten, auf SmtpMailer. Jetzt schreibt der Service auf, was er braucht: IInvoiceSender, direkt bei sich.
  2. InvoiceService bekommt den Versand im Konstruktor und kennt nur IInvoiceSender. Von SMTP weiß er nichts.
  3. 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.
  4. Zur Laufzeit geht der Aufruf weiter nach unten: vom Service zu SMTP. Umgekehrt ist die Abhängigkeit im Code, nicht die Richtung des Aufrufs.
  5. 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.
  6. 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 InvoiceService den SmtpSender, 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
In einem echten Projekt liegt 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:

  1. Console.WriteLine("Der Test sah: " + fake.Sent[0]);
  2. var service = new InvoiceService(fake);
  3. var fake = new FakeSender();
  4. service.Issue(order, "maria@example.com");

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} €");
Ausgabe
MwSt.: 16.00 €

Und dasselbe ohne Dogma:

const decimal VatRate = 0.20m;
Console.WriteLine($"MwSt.: {80 * VatRate:F2} €");
Ausgabe
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.

Ausgabe
Karte: erstattet 40.00 €
Geschenkkarte: 25.00 € zurück auf die Karte
Status: storniert

Weitere 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

1. Marketing verlangt eine Änderung am Text der E-Mail, und danach geht die Buchhaltungsdatei kaputt. Welches Prinzip ist verletzt?
2. Der Shop bekommt die Lieferung an eine Packstation. Was bedeutet „geschlossen für Änderungen“?
3. VoucherPayment implementiert Payment, aber refund wirft „nicht unterstützt“. Was passiert mit Code, der refund bei jeder Zahlung aufruft?
4. Der E-Mail-Anbieter muss eine leere Methode sms schreiben, um Notifier zu implementieren. Was sagt dir das?
5. InvoiceService(SmtpMailer mailer) bekommt den Mailer im Konstruktor. Folgt das DIP?
6. Wann lohnt sich ein Interface mit nur einer Implementierung?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·