Bild von SOLID Principles

28. Mär 2022

SOLID Principles

Oder wie Code länger als eine Masterarbeit lebt.

Seit über 20 Jahren bilden die SOLID Principles die Basis, wie man beim objektorientierten Programmieren (OOP) Code schreibt, der sich über eine lange Zeit anpassen und weiterverwenden lässt. Sie bilden einen fundamentalen Grundstein in der Arbeit als Software Engineer. Mit Beispielen erkunden wir, wie die 5 Prinzipien dazu beitragen, Software lesbar, wartbar und korrekt zu halten.

SOLID ist ein Akronym, das aus den Folgenden Teilen besteht:

  • Single Responsibility Principle
  • Open-Closed Principle
  • Liskov Substitution Principle
  • Interface Segregation Principle
  • Dependency Inversion Principle

Mit einigen negativen Beispielen werden wir diese fünf Prinzipien erkunden und zeigen wie wir sie einhalten können. Die Beispiele sind in einfachem C# gehalten, damit sie ohne viel Aufwand in andere Sprachen mit Klassen wie Java, C++, JavaScript (ES6+)/TypeScript, Python, etc. übersetzt werden können. So sind zum Beispiel überall Arrays verwendet, wo wir in produktiven Umgebungen List oder IEnumerable benutzen würden.

Single Responsibility Principle (SRP)

"Es sollte nie mehr als einen Grund dafür geben, eine Einheit zu ändern." - Robert C. Martin

Was “Uncle Bob” damit sagt, ist, dass wir beim Erstellen von Funktionen, Klassen oder Modulen darauf achten sollen, dass alle Unterteilungen dieser (also Variablen/Statements, Membervariablen/Methoden beziehungsweise Klassen) stark überlappen. Denn wenn die Kohäsion niedrig ist, können wir die Einheit auch von Anfang an aufteilen und so die Modularität der Software erhöhen.

Als Beispiel betrachten wir eine Service-Klasse, die Mitarbeiter-Informationen hinzufügen, mutieren und später wieder abrufen kann. Ob dies nun über Dateien im Betriebssystem, einer Datenbank, einen Webservice oder andere Möglichkeiten passiert, ist für dieses Beispiel nicht von Bedeutung, darum betrachten wir nur die Signaturen der Methoden, die bereitgestellt werden. Dies kann dann etwa so wie unten aussehen.

public class Service
{
  public void SaveEmployee(Employee employee) …
  public Employee GetEmployee(int employeeNumber) …
}

Später im Projekt sind dann Bestellungen dazugekommen, die wir ebenfalls persistieren wollen. In einem ersten Schritt können wir die Service-Klasse mit Methoden dazu anreichern, da diese Klasse ja bereits die Möglichkeit zum Speichern und Laden von Daten hat.

public class Service
{
  public void SaveEmployee(Employee employee) …
  public Employee GetEmployee(int employeeNumber) …
  public void SaveOrder(Order order) …
  public Order GetOrder(int orderNumber) …
}

Nun können wir aber bereits bemerken, dass wir hiermit das SRP verletzt haben: Service muss angepasst werden, wenn es Änderungen der Anforderungen sowohl für Mitarbeiter als auch für die Bestellungen gibt. Zudem wäre es auch nichts anderes als konsistent, wenn die Operationen für andere neue Entitäten auch in dieser Klasse implementiert werden.

Um das SRP wieder einzuhalten, können wir den Service in zwei Klassen aufteilen, eine EmployeeService Klasse, die Mitarbeiter speichert und lädt, und eine OrderService Klasse, die dasselbe mit Bestellungen macht.

public class EmployeeService
{
  public void SaveEmployee(Employee employee) …
  public Employee GetEmployee(int employeeNumber) …
}

public class OrderService
{
  public void SaveOrder(Order order) …
  public Order GetOrder(int orderNumber) …
}

Wenn wir uns in einem zweiten Beispiel die Employee-Klasse von vorher anschauen, kann diese womöglich so aussehen:

public class Employee
{
  public int employeeNumber;
  public string firstName;
  public string lastName;
  public Employee manager;
  public Employee[] subordinates;
}

Wir bemerken aber hier, dass wir wiederum das SRP verletzen: Wir haben sowohl Grundinformationen (Mitarbeiternummer und Name) wie auch Hierarchiestrukturen (Führungskräfte und Untergebene) in der gleichen Einheit. Wie vorhin können wir diese unabhängigen Verantwortungen unterschiedlichen Klassen zuordnen.

public class Employee
{
  public int employeeNumber;
  public string firstName;
  public string lastName;
}

public class Organigram 
{
  public Employee GetManager(Employee employee);
  public Employee[] GetSubordinates(Employee employee);
}

 

Open-Closed Principle (OCP)

“Einheiten sollten sowohl offen (für Erweiterungen), als auch geschlossen (für Modifikationen) sein.” - Bertrand Meyer

Der Hauptpunkt des Open-Closed Principles (OCP) ist, dass wir Klassen-Vererbung benutzen sollen (das ist ja schliesslich einer der Hauptpunkte von OOP). Das heisst, dass wir unsere Klassen idealerweise so schreiben sollen, dass für ein neues Feature nur eine neue Subklasse mit wenigen neu implementierten Methoden nötig ist.

Als Beispiel haben wir Teile einer Software im Warenhandel. Wir betrachten Produkte, für welche wir die Mehrwertsteuer (MWST, English: Value added tax oder VAT) berechnen. Zur Zeit ist der eidgenössische MWST-Satz für normale Güter bei 7.7%, darum kann die Berechnung in unserer Firma wie folgt gemacht werden.

public class Product
{
  public string name;
  public decimal price;
}

public class VatCalculator
{
  public decimal GetTotalVat(Product[] products) 
  {
    decimal totalVat = 0.0M;
    foreach (Product product in products) 
    {
      totalVat += product.price * 0.077M;
    }
    return totalVat;
  }
}

Zukünftig kann es dann sein, dass wir irgendwann beginnen zusätzlich Bücher zu verkaufen. Bücher sind auch Produkte, aber für diese möchten wir noch ihre Autoren hinterlegen. Dafür können wir ganz einfach von Product erben, um eine neue Klasse zu machen.

public class Book : Product
{
  public string author;
}

Kaum sind unsere Bücher auf dem Markt erhältlich, bekommen wir auch schon die ersten Reklamationen: Bücher sind Druckerzeugnisse und haben einen reduzierten MWST-Satz von nur 2.5%. Um dieses Problem zu lösen, bauen wir in unserer Berechnungsklasse, VatCalculator einen Spezialfall ein.

public class VatCalculator
{
  public decimal GetTotalVat(Product[] products) 
  {
    decimal totalVat = 0.0M;
    foreach (Product product in products) 
    {
      if (product is Book)
      {
        totalVat += product.price * 0.025M;
      }
      else
      {
        totalVat += product.price * 0.077M;
      }
    }
    return totalVat;
  }
}

Nun funktioniert die Berechnung wieder so, wie sie sollte. Allerdings sollten wir uns an dieser Stelle fragen, wie zukunftssicher diese Implementation ist (auch wenn wir einmal davon absehen, dass sich MWST-Sätze ändern können). Was passiert, wenn das Geschäft auch noch Lebensmittel verkaufen möchte? In dem Fall müssen wir wieder eine neue Klasse erstellen, die wir dann sofort zur if-Kondition im vorhergehenden Code-Block auf Zeile 8 hinzufügen müssen. Dies ist nicht nur mühsam, sondern wir laufen dann auch Gefahr, dass wir (oder Mitarbeitende, die Jahre später diese Änderungen machen) den zweiten Schritt vergessen und neue Güter falsch besteuert werden. Wäre es nicht schön, wenn wir nur die neue Klasse erstellen müssen und nicht daran denken müssen, bestehenden Code anzupassen? Genau dafür können wir eine abstrakte Basisklasse wie folgt verwenden:

public abstract class ProductBase
{
  public string name;
  public decimal price;
  public abstract decimal GetVatRate();
}

public class Product : ProductBase
{
  public override decimal GetVatRate()
  {
    return 0.077M;
  }
}

public class Book : ProductBase
{
  public string author;
  
  public override decimal GetVatRate()
  {
    return 0.025M;
  }
}

public class VatCalculator
{
  public decimal GetTotalVat(ProductBase[] products) 
  {
    decimal totalVat = 0.0M;
    foreach (ProductBase product in products) 
    {
      totalVat += product.price * product.GetVatRate();
    }
    return totalVat;
  }
}

Nun können wir ohne weiteres neue Unterklassen von ProductBase erstellen und der Compiler oder IDE warnt uns sofort, dass wir noch GetVatRate implementieren müssen: Somit können wir das schlicht und einfach nicht vergessen. Zudem sehen wir, dass die VatCalculator-Klasse angepasst werden muss, wenn es Änderungen an Produkten bedarf. Dies sorgt auch für die Einhaltung des SRP dieser Klasse.

Wie wir nun noch mit ändernden MWST-Sätzen umgehen, ist den interessierten Lesern als Übung überlassen. 🙂

Properties

Bis anhin waren alle Membervariablen in den Beispielklassen einfache, nicht eingeschränkte (public) Felder. Dies verstösst aber auch gegen das OCP, da Unterklassen diese 1:1 vererbt bekommen und das Verhalten dieser nicht erweitert (oder eingeschränkt) werden kann. Es hat sich unter anderem aus diesem Grund etabliert, dass Members privat gehalten werden und andere Klassen via zwei Methoden (typischerweise Getter und Setter genannt) auf sie zugreifen.

public class FooWithField
{
  public Bar baz;
}

public class FooWithGetterAndSetterProperty
{
  private Bar baz;
  
  public virtual Bar GetBaz()
  {
    return this.baz;
  }
  
  public virtual void SetBaz(Bar baz)
  {
    this.baz = baz;
  }
}

Ein weitere Vorteil davon ist, dass wir so auch weitere Berechnungen machen können. Zum Beispiel, um den Speicherverbrauch zu optimieren (auf Kosten von Laufzeit) oder um Datenvalidierung* in der Klasse zu halten.

*Ob Validierungslogik in einer separaten Klasse besser angesiedelt wäre, ist umstritten. Die eine Seite vertritt die Meinung, dass Objekte immer in einem “erlaubten” Zustand sein müssen, die andere, dass Validierung SRP-Konform von den Daten gelöst werden muss.

public class BookContent
{
  private byte[] compressed;
  
  public string GetContent() 
  {
    return CompressionLibrary.Decompress(compressed);
  }
  
  public void SetContent(String content) 
  {
    this.compressed = CompressionLibrary.Compress(content);
  }
}

public class Circle
{
  private double radius;
  
  public double GetRadius()
  {
    return this.radius;
  }
  
  public void SetRadius(double radius)
  {
    if (0.0 <= r)
    {
      this.radius = radius;
    }
    else
    {
      throw new ArgumentOutOfRangeException(
        nameof(radius), 
        "Circle radii must be non-negative."
      );
    }
  }
}

Da wir meist aber für jedes Feld genau einen Setter und einen Getter schreiben, haben viele Programmiersprachen auch quality-of-life-Syntax um dies zu vereinfachen: in C# zum Beispiel wurde der Support dafür bereits in v1.0 hinzugefügt. In v6.0 sind auch Auto-Properties zur Sprache dazu gekommen.

public class FooWithProperty
{
  private Bar baz;
  
  public virtual Bar Baz
  {
    get
    {
      return baz;
    }
    
    set
    {
      this.baz = value;
    }
  }
}

public class FooWithAutoProperty
{
  public virtual Bar Baz {get; set;}
}

In anderen Sprachen sieht das etwa so aus:

Java ohne Project Lombok
public class Foo
{
  private Bar baz;
  
  public Bar getBaz() {
    return baz;
  }
  
  public void setBaz(Bar baz) {
    this.baz = baz;
  }
}
Java mit Project Lombok
public class @Data Foo
{
  private Bar baz;
}
TypeScript
export class Foo
{
  private _baz: Bar;
  
  get baz(): Bar {
    return this._baz;
  }
  
  set baz(baz: Baz): void {
    this._baz = baz;
  }
}
Python
class Foo:
  def __init__(self, baz = None):
    self._baz = baz
  
  @property
  def baz(self):
    return self._baz
    
  @baz.setter
  def baz(self, baz):
    self._baz = baz

 

Liskov Substitution Principle

"S ist ein Subtyp vom Typ T wenn für alle beweisbar wahren Eigenschaften ɸ(x) für x vom Type T, ɸ(y) eine beweisbare wahre Eigenschaft für alle y vom Type S ist. ” - Barbara Liskov

Der amerikanische Poet James Whitcomb Riley hat schon vor über hundert Jahren gesagt: “When I see a bird that walks like a duck and swims like a duck and quacks like a duck, I call that bird a duck.” In der Natur kann es sich bei so einer “Ente” aber auch um ein Teichralle (siehe Foto) handeln.

 

Teichralle (Foto: Von Derek Keats from Johannesburg, South Africa - Common Moorhen, Gallinula chloropus with juvenile at Marievale Nature Reserve, Gauteng, South Africa, CC BY-SA 2.0, https://commons.wikimedia.org/w/index.php?curid=20893628 )

 

Wir als Programmierer sind Menschen, genauso wie Riley, die Annahmen treffen, die nicht unbedingt wahr sind. Meist wissen wir auch nicht, welche Annahmen wir treffen und sie fallen uns erst auf, wenn etwas nicht mehr funktioniert. Wenn wir eine Klasse, die Ente heisst oder von so einer erbt, benutzten, gehen wir häufig davon aus, dass diese auch eine Ente abbildet, ohne dies zu hinterfragen.

Wenn wir Software schreiben, die auch von unseren Mitarbeitern, Benutzern unserer Libraries oder auch unserem zukünftigen Selbst (welches dann nur noch gefährliches Halbwissen über den Code besitzt) benutzt, erweitert und verstanden werden muss, ist es nötig, dass unsere Einheiten mehr als nur den “Duck-Test” bestehen. Ansonsten können unbewusste oder bewusste Annahmen zu unerwarteten Ergebnissen (also Bugs) führen.

Das Liskov Substitution Principle (LSP) ist darum sehr genau zu lesen: alle Eigenschaften eines Typs müssen auch von dessen Subtypen erfüllt werden, insbesondere semantische.

Das LSP-Standardbeispiel (von mir zum ersten mal in Clean Architecture - Robert C. Martin gesehen) hierfür ist altbekannt: Ist ein Quadrat ein Rechteck oder ein Rechteck ein Quadrat? Im Geometrieunterricht ist diese Frage klar mit der ersten Option zu beantworten. In einem Beispiel implementiert sieht dass dann wie folgt aus.

public class Rectangle 
{
  public virtual double Width { get; set; }
  public virtual double Height { get; set; }
  
  public double GetArea() 
  {
    return this.Width * this.Height;
  }
}

public class Square : Rectangle
{
  public override double Height 
  {
    get { return Width; }
    set { this.Width = value; }
  }
}

Wenn wir diese Implementation aber unter einer LSP-Lupe betrachten, sehen wir, dass es hier Probleme gibt. In einem Rectangle sind die Höhe und Breite voneinander unabhängig, in einem Square aber nicht. Sobald nun ein Benutzer davon ausgeht, dass dies der Fall ist, können Bugs entstehen.

public class RectangleScaler
{
  void Scale(Recangle rectangle, double factor) 
  {
    rectangle.Width *= factor;
    rectangle.Height *= factor;
  }
}

Wenn wir nun Scale mit einem Square und 2.0 als factor aufrufen, wird die Grösse des Quadrats vervierfacht, nicht nur verdoppelt. Augenscheinlich ist also die Implementation falsch und wir beginnen von neuem, dieses Mal jedoch andersrum: Ein Rechteck ist jetzt ein Quadrat.

public class Square
{
  public double Height { get; set; }
  
  public virtual double GetArea()
  {
    return this.Height * this.Height;
  }
}

public class Rectangle : Square
{
  public double Width { get; set; }
  
  public override double GetArea()
  {
    return this.Height * this.Width;
  }
}

Aber auch hier stossen wir auf Probleme. Offensichtlich ist ein geometrisches Rechteck mit unterschiedlicher Höhe und Breite kein geometrisches Quadrat, also ist es nur eine Frage der Zeit, bis jemand Square benutzt und nicht damit rechnet, dass es sich auch um ein Rectangle-Objekt handeln könnte.

Aber welches der beiden ist jetzt das richtige Design? Die Antwort ist weder noch: beim Programmieren ist ein Quadrat kein Rechteck und ein Rechteck kein Quadrat. Die Frage nach der richtigen Antwort impliziert bereits die Annahme, dass es eine der beiden Möglichkeiten sein muss, und verleitet Leser, die das Beispiel noch nie gesehen haben, auch dazu diese Annahme zu treffen. Das richtige Design ist, wie wir in der OCP-Sektion gesehen haben, das gemeinsame Interface herauszunehmen und die Klassen unabhängig voneinander zu implementieren.

public interface IShape
{
  public double GetArea();
}

public class Square
{
  public double SideLength { get; set; }
  
  public double GetArea()
  {
    return this.Height * this.Height;
  }
}

public class Rectangle
{
  public double Height { get; set; }
  public double Width { get; set; }
  
  public double GetArea()
  {
    return this.Height * this.Width;
  }
}

Zur Verdeutlichung, hat nun Square auch ein SideLength-Property, dass sowohl die Höhe als auch die Breite eines Quadrats modelliert.

 

Interface Segregation Principle

“ Clients sollten nicht dazu gezwungen werden, von Interfaces abzuhängen, die sie nicht verwenden. ” - Robert C. Martin

Beim Interface Segregation Principle (ISP) geht es unter anderem darum, nur zu fordern, was wir auch wirklich brauchen und diese Forderungen separat zu stellen. Der Vorteil liegt darin, dass wir dann diese Forderungen, wie naiv implementiert, in einer einzigen Klasse haben können, aber auch fähig sind, sie in separaten Einheiten zu erfüllen, welche dann weniger Gefahr laufen das SRP zu verletzen.

Im Beispiel betrachten wir Rezepte, welche wir mit Hilfe einer Küchenmaschine zubereiten.

public class CookingMachine
{
  public Mousse Blend(Ingredient[] ingredients) …
  public Dough Mix(Ingredient[] ingredients) …
  public Pasta Cut(Dough dough, PastaShape shape) …
}

public abstract class Recipe
{
  public abstract Meal Prepare();
}

public class SmoothieRecipe : Recipe
{
  private CookingMachine cookingMachine;
  private Ingredient[] fruit;
  private Glass glass;

  public SmoothieRecipe (CookingMachine cookingMachine, Ingredient[] fruit, Glass glass) …
  
  public override Meal Prepare() 
  {
    Mousse mousse = cookingMachine.Blend(fruit);
    return glass.FillWith(mousse);
  }
}

public class SpaghettiRecipe : Recipe
{
  private CookingMachine cookingMachine;
  …
  
  public SpaghettiRecipe(CookingMachine cookingMachine, …) …
  
  public override Meal Prepare() {
    …
    Mixture dough = cookingMachine.Mix(doughIngredients);
    Pasta spaghetti = cookingMachine.Cut(dough, PastaShape.Spaghetti);
    …
    return cookedSpaghetti;
  }
}

Wir sehen, dass hier das ISP von beiden Rezepten verletzt wird. So braucht beispielsweise das SmoothieRecipe nur eine der drei Methoden, die von der CookingMachine zur Verfügung gestellt wird. Um das ISP einzuhalten, machen wir, was das Prinzip schon im Namen sagt: Wir teilen das Interface auf, hier jenes der Küchenmaschine.

public interface IBlender
{
  Mousse Blend(Ingredient[] ingredients);
}
public interface IMixer
{
  Dough Mix(Ingredient[] ingredients);
}
public interface IPastaCutter
{
  Pasta Cut(Dough dough,PastaShape shape);
}

public class CookingMachine : IBlender, IMixer, IPastaCutter
{
  public Mousse Blend(Ingredient[] ingredients) …
  public Dough Mix(Ingredient[] ingredients) …
  public Pasta Cut(Dough dough, PastaShape shape) …
}

Da es nun diese geteilten Interfaces gibt, können wir auch die Anforderungen der Rezepte an die Maschine aufteilen und reduzieren.

public class SmoothieRecipe : Recipe
{
  private IBlender blender;
  private Ingredient[] fruit;
  private Glass glass;

  public SmoothieRecipe (IBlender blender, Ingredient[] fruit, Glass glass) …
  
  public override Meal Prepare() 
  {
    Mousse mousse = blender.Blend(fruit);
    return glass.FillWith(mousse);
  }
}

public class SpaghettiRecipe : Recipe
{
  private IMixer doughMixer;
  private IPastaCutter pastaCutter;
  …
  
  public SpaghettiRecipe(IMixer doughMixer, IPastaCutter pastaCutter, …) …
  
  public override Meal Prepare() {
    …
    Mixture dough = doughMixer.Mix(doughIngredients);
    Pasta spaghetti = pastaCutter.Cut(dough, PastaShape.Spaghetti);
    …
    return cookedSpaghetti;
  }
}

Nun sind wir frei, das Spaghetti-Rezept mit separatem Teigmischer und Pastaschneider zuzubereiten, aber wir können auch weiterhin mit new SpaghettiRecipe(cookingMachine, cookingMachine, ...) die einzelne Küchenmaschine benutzen. Der Vorteil liegt darum klar darin, dass wir nun eine höhere Modularität erzielt haben.

 

Dependency Inversion Principle

“Module hoher Ebenen sollten nicht von Modulen niedriger Ebenen abhängen. Beide sollten von Abstraktionen abhängen.
Abstraktionen sollten nicht von Details abhängen. Details sollten von Abstraktionen abhängen.” - Robert C. Martin

Erst ein wenig Begriffserklärung: Sehr häufig ist die Architektur von Software in Ebenen unterteilt. Dabei spricht man von hohen Ebenen, wenn diese nahe am Benutzer sind und von diesem direkt oder per Konfigurations-Datei manipuliert werden können. In tieferen Ebenen findet mehr Businesslogik statt, die für den Benutzer “einfach funktioniert”. Dort ist die “Magie” versteckt.

Entgegen dem Instinkt, dass wir genau wissen wollen, wie etwas funktioniert, sollten also Software-Komponenten nur von Black Boxen abhängen. Welche Box genau benutzt wird und was darin vorgeht, sollte Module in einer höheren Ebene nicht interessieren.

Aber auch in der realen Welt benutzen wir tagtäglich Abstraktionen. Schliesslich laden wir unser Mobiltelefon nicht, in dem wir es mit Drähten an offene Stromquellen anlöten. Wir haben dafür, sogar mehrere, klar definierte Abstraktionen in Form von genormten Steckern und Buchsen (für Mobiltelefone typischerweise USB-C, USB-A und Eurostecker) die es uns einfach machen verschiedenste Geräte an verschiedensten Orten mit Strom zu versorgen.

Im Beispiel schauen wir eine Tabelle an, die Daten von Mitarbeitern nach ihrem Namen filtern und in einer GUI darstellen kann.

// GUI Modul (hoch)
public class EmployeeTableDisplay
{
  private Employee[] employees;
  
  private string nameFilter;
  private HumanResourceService humanResourceService;
  
  public EmployeeTableDisplay()…
  
  public void Update()
  {
    this.employees = humanResourceService.GetEmployees(this.nameFilter);
  }
  
  public void DisplayEmployees()…
}
// HR-Service Modul (tiefer)
public class HumanResourceService
{
	public Employee[] GetEmployees(string nameFilter)…
}

Hier sehen wir, dass in Zeile 7 des oberen Codeblocks ganz explizit der HumanResourceService erwähnt wird und somit das DIP nicht erfüllt ist. Um dies zu beheben, erstellen wir die von “Uncle Bob” geforderte Abstraktion, hier ein Interface.

// GUI Modul (hoch)
public class EmployeeTableDisplay
{
  private Employee[] employees;
  
  private string nameFilter;
  private IEmployeeProvider employeeProvider;
  
  public EmployeeTableDisplay()…
  
  public void Update()
  {
    this.employees = employeeProvider.GetEmployees(this.nameFilter);
  }
  
  public void DisplayEmployees()…
}

public interface IEmployeeProvider
{
  Employee[] GetEmployees(string nameFilter);
}

Wir bemerken auch, dass dieses Interface im höheren Layer angesiedelt ist, die Gründe dazu sehen wir gleich. Zuerst versehen wir aber noch die Klasse im Personalmodul mit diesem Interface.

// HR-Service Modul (tiefer)
public class HumanResourceService : IEmployeeProvider
{
	public Employee[] GetEmployees(string nameFilter)…
}

Der Vorteil liegt nun darin, dass wir ganz einfach auch weitere IEmployeeProvider's implementieren und konfigurieren können. Zum Beispiel können wir für einen Benutzer aus dem Rechnungswesen andere Datenquellen benutzen, da es auch bei der Personalabteilung vermerkte Mitarbeiter gibt, die nicht direkt angestellt sind (zum Beispiel externe Berater) und darum nirgends in der Lohnverwaltung vorkommen sollten. In einem Diagramm dargestellt sehen die beiden Implementationen wie folgt aus:

Diagram SalaryService

 

 

 

 

Hier wird klar, wieso das Provider-Interface auf der Consumer-Seite angelegt ist. Für das neue Rechnungswesen-Modul muss nur der SalaryService implementiert werden. Das GUI-Modul kann ohne irgendeine Änderung genau so weiter verwendet werden.

 

Schlusswort

Wir haben mit verschiedenen Beispielen beobachtet, wie wir objektorientierte Software langfristig modularer machen und dadurch nützlich halten können. Es ist dabei aber noch anzumerken: dies sind keine in Stein gemeisselten Gesetze. Wenn es gute Gründe dazu gibt (Proof-of-Concept-Phase; einmalig benutzte Scripts; viel Duplikation, um das SRP einzuhalten), kann man jedes der fünf verletzen. Man sollte sich dessen aber bewusst sein und bleiben (daher am besten gleich im Backlog des Issue-Tracking-Systems vermerken).


Schliessen
Stamp Icon-Print Icon-Clear
S
M
L
XL
XXL