Anzeige
Anzeige
Anzeige
Anzeige
Anzeige
Anzeige
Lesedauer 6 Min.

Bausteine guter Architektur

Code sauberer gestalten anhand von wenigen Patterns und Grundhaltungen.
© dotnetpro
Sie haben sich in der vorigen dotnetpro-Ausgabe bereits (wieder) mit den SOLID-Prinzipien vertraut gemacht [1] – fünf wichtige Grundsätze, um Software mit guter Architektur zu ­bauen. In diesem Artikel werden wir weitere Patterns und Prinzi­pien besprechen, die über SOLID hinausgehen. Einige werden Sie bereits kennen; andere sind etwas ungewöhnlicher, und man wird sie nur selten verwenden. Beginnen möchte ich mit etwas sehr Bekanntem, der Kapselung.

Kapselung

Mit Kapselung verbergen Sie interne Zustände und Verhaltensweisen, die nicht von außen zugänglich sein dürfen. Statt internen Werten werden über Set und Get öffentliche Methoden zur Interaktion mit diesen Werten zur Verfügung gestellt.In Listing 1 lässt sich der Kontostand nicht direkt von außen verändern. Der Zusatz private im Setter verhindert dies. Änderungen am Kontostand erfolgen ausschließlich durch die Methoden Einzahlen und Abheben, die Geschäftslogik enthalten, um ungültige Operationen zu verhindern. Somit kann der Kontostand hier nie negativ werden.
Listing 1: Der Kontostand kann nur vom Konto selbst verändert werden
<span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">Bankkonto</span><br/>{<br/>  <span class="hljs-keyword">private</span> <span class="hljs-keyword">decimal</span> kontostand;<br/>  <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">Bankkonto</span>(<span class="hljs-params"><span class="hljs-keyword">decimal</span> anfangsSaldo</span>)</span><br/><span class="hljs-function">  </span>{<br/>    kontostand = anfangsSaldo;<br/>  }<br/>  <span class="hljs-keyword">public</span> <span class="hljs-keyword">decimal</span> Kontostand<br/>  {<br/>    <span class="hljs-keyword">get</span> { <span class="hljs-keyword">return</span> kontostand; }<br/>    <span class="hljs-keyword">private</span> <span class="hljs-keyword">set</span> { kontostand = <span class="hljs-keyword">value</span>; }<br/>  }<br/>  <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">Einzahlen</span>(<span class="hljs-params"><span class="hljs-keyword">decimal</span> betrag</span>)</span><br/><span class="hljs-function">  </span>{<br/>    <span class="hljs-keyword">if</span> (betrag > <span class="hljs-number">0</span>)<br/>    {<br/>      kontostand += betrag;<br/>    }<br/>  }<br/>  <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">bool</span> <span class="hljs-title">Abheben</span>(<span class="hljs-params"><span class="hljs-keyword">decimal</span> betrag</span>)</span><br/><span class="hljs-function">  </span>{<br/>    <span class="hljs-keyword">if</span> (betrag > <span class="hljs-number">0</span> && kontostand >= betrag)<br/>    {<br/>      kontostand -= betrag;<br/>      <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>;<br/>    }<br/>    <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>;<br/>  }<br/>} 
Die Kapselung ermöglicht eine Validierung, sodass ungültige Werte nicht gesetzt werden können. Beispielsweise lässt sich so verhindern, dass ein negatives Alter gesetzt wird.

<span class="hljs-keyword">public</span> <span class="hljs-keyword">int</span> Alter 
{ 
  <span class="hljs-keyword">set</span>{
    <span class="hljs-keyword">if</span>(<span class="hljs-keyword">value</span> &gt; <span class="hljs-number">0</span> &amp;&amp; <span class="hljs-keyword">value</span> &lt; <span class="hljs-number">150</span>) 
      <span class="hljs-keyword">this</span>._alter = <span class="hljs-keyword">value</span>;
    <span class="hljs-function"><span class="hljs-keyword">else</span> </span>
<span class="hljs-function">      throw new <span class="hljs-title">Error</span>(<span class="hljs-params">“Ungültiges Alter”</span>)</span>;
  }
 
  <span class="hljs-keyword">get</span>{ 
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">this</span>._alter; 
  }
} 

Basisklassen

Wenn zwei Klassen ähnliche Grundaufgaben übernehmen, kann man diese Aufgaben in eine Basisklasse auslagern. Beispiel: Wir haben einen Preiskalkulator für EK- und einen für VK-Belege. Dann kann der EKPreiskalkulator vom Preiskalkulator erben und gewisse Methoden, die sich beide Klassen teilen, stehen dann in der Basisklasse Preiskalkulator. Beide Klassen können immer über base.METHODE auf eine Methode der Basisklasse zugreifen. Eine Basisklasse kann auch über die abstract-Deklaration eine abgeleitete Klasse zwingen, diese Methode zu implementieren. Ein Beispiel sehen Sie in Listing 2: Hund und Katze sind Tiere, also müssen sie beide essen. Diese Methode muss nicht für jede Klasse einzeln entwickelt werden. MacheTypischesGeräusch muss dagegen von jeder abgeleiteten Klasse implementiert werden.
Listing 2: Basisklasse und abgeleitete Klassen
&lt;span class="hljs-regexp"&gt;//&lt;/span&gt; Abstrakte Basisklasse&lt;br/&gt;public abstract class Tier&lt;br/&gt;{&lt;br/&gt;  public string Name { get; set; }&lt;br/&gt;  &lt;span class="hljs-regexp"&gt;//&lt;/span&gt; Abstrakte Methode&lt;br/&gt;  public abstract void MacheTypischesGeraeusch();&lt;br/&gt;  public void Essen()&lt;br/&gt;  {&lt;br/&gt;    Console.WriteLine($&lt;span class="hljs-string"&gt;"{Name} isst."&lt;/span&gt;);&lt;br/&gt;  }&lt;br/&gt;}&lt;br/&gt;&lt;span class="hljs-regexp"&gt;//&lt;/span&gt; Abgeleitete Klasse Hund&lt;br/&gt;public class Hund : Tier&lt;br/&gt;{&lt;br/&gt;  public override void MacheTypischesGeraeusch()&lt;br/&gt;  {&lt;br/&gt;    Belle(); &lt;span class="hljs-regexp"&gt;//&lt;/span&gt; Ruft die spezifische Methode für &lt;br/&gt;             &lt;span class="hljs-regexp"&gt;//&lt;/span&gt; Hund auf&lt;br/&gt;  }&lt;br/&gt;  &lt;span class="hljs-regexp"&gt;//&lt;/span&gt; Spezifische Methode für Hund&lt;br/&gt;  public void Belle()&lt;br/&gt;  {&lt;br/&gt;    Console.WriteLine($&lt;span class="hljs-string"&gt;"{Name} bellt."&lt;/span&gt;);&lt;br/&gt;  }&lt;br/&gt;}&lt;br/&gt;&lt;span class="hljs-regexp"&gt;//&lt;/span&gt; Abgeleitete Klasse Katze&lt;br/&gt;public class Katze : Tier&lt;br/&gt;{&lt;br/&gt;  public override void MacheTypischesGeraeusch()&lt;br/&gt;  {&lt;br/&gt;    Schnurre(); &lt;span class="hljs-regexp"&gt;//&lt;/span&gt; Ruft die spezifische Methode für &lt;br/&gt;                &lt;span class="hljs-regexp"&gt;//&lt;/span&gt; Katze auf&lt;br/&gt;  }&lt;br/&gt;  &lt;span class="hljs-regexp"&gt;//&lt;/span&gt; Spezifische Methode für Katze&lt;br/&gt;  public void Schnurre()&lt;br/&gt;  {&lt;br/&gt;    Console.WriteLine($&lt;span class="hljs-string"&gt;"{Name} schnurrt."&lt;/span&gt;);&lt;br/&gt;  }&lt;br/&gt;}&lt;br/&gt;public class Programm&lt;br/&gt;{&lt;br/&gt;  public static void Main(string[] args)&lt;br/&gt;  {&lt;br/&gt;    Tier meinHund = new Hund { Name = &lt;span class="hljs-string"&gt;"Buddy"&lt;/span&gt; };&lt;br/&gt;    Tier meineKatze = new Katze { Name = &lt;span class="hljs-string"&gt;"Whiskers"&lt;/span&gt; };&lt;br/&gt;    meinHund.Essen(); &lt;span class="hljs-regexp"&gt;//&lt;/span&gt; Geerbt von Tier&lt;br/&gt;    meinHund.MacheTypischesGeraeusch();  &lt;br/&gt;      &lt;span class="hljs-regexp"&gt;//&lt;/span&gt; Bellt, da Belle() aufgerufen wird&lt;br/&gt;    meineKatze.Essen(); &lt;span class="hljs-regexp"&gt;//&lt;/span&gt; Geerbt von Tier&lt;br/&gt;    meineKatze.MacheTypischesGeraeusch(); &lt;br/&gt;      &lt;span class="hljs-regexp"&gt;//&lt;/span&gt; Schnurrt, da Schnurre() aufgerufen wird&lt;br/&gt;  }&lt;br/&gt;} 
Die Überlegung hinter der Basisklasse ist das DRY-Prinzip.

DRY (Don’t repeat yourself!)

Immer wenn ein Codeabschnitt sich wiederholt, sollten die Alarmglocken klingen. Überlegen Sie, wie Sie diesen Code in eine gemeinsame Methode oder Klasse extrahieren können.Wenn wir etwa immer wieder aus einem Excel-File die Zeilen in ein Array laden, sollten wir dazu eine eigene Funktion schreiben. Sind die Fälle nicht zu 100 Prozent gleich, kann die Funktion über Parameter Varianten aufnehmen.

Object[] ReadValuesFromFile(
  string fileName, bool IncludeHeaders) 

Auslagern von Aufgaben nach Extensions

Eine weitere Methode, geschwätzigen Code auszulagern, besteht über Extensions. Das ermöglicht auch die Erweiterung von Klassen, die nicht in Ihrer Assembly liegen, wie IQuery­able oder das Belegobjekt in einem ERP-System. Extensions sind auch leicht zu lesen, und sie stehen unmittelbar beim Objekt zur Verfügung.

public static IEnumerable&lt;T&gt; FilterByTime&lt;T&gt;(this 
    IList&lt;T&gt; qry, IContext context) where T : IDuration
{
  return qry.Where(_ =&gt; (_.Start &lt;= context.CurrentTime) 
    &amp;&amp; (_.End &gt;= context.CurrentTime));
} 
Die IDuration kennen Sie aus dem vorigen Artikel [1], wo wir komplexe Interfaces in einfache Interfaces zerlegt haben. Hier sehen Sie den Mehrwert. Der Aufruf ist recht simpel:

var aktuelle = buchungen.filterByTime(context); 
Und auch hier ist wieder wichtig, dass wir nicht mit Date­Time.Now arbeiten, um testbaren Code zu bekommen.

YAGNI (You ain’t gonna need it!)

Es frisst am Ende nur Zeit, eine Funktionalität zu implementieren, die aktuell nicht benötigt wird, nur weil man denkt, dass sie in Zukunft vielleicht einmal sinnvoll sein könnte. ­YAGNI hält den Code einfacher und fokussierter.Wie kommt man zu minimalem Code? Sobald ein Issue ein Akzeptanzkriterium enthält, das genau beschreibt, was erreicht werden soll, kann der Code sich darauf konzentrieren, und man vermeidet überflüssige Arbeit. Es muss nur so weit entwickelt werden, dass das Akzeptanzkriterium erfüllt ist.Ein konkretes Beispiel: Das Issue in Git heißt: „Zu einer Liste sollen Währungen hinzugefügt werden.“ Die Akzeptanz­krite­rien dazu schildern, wie der Tester vorgehen würde: Durch die Eingabe von EUR und den Klick auf Hinzufügen erscheint EUR in der Liste. Wenn ich auf das X neben EUR ­klicke, verschwindet es aus der Liste. Klicke ich auf das X auf der letzten Währung in der Liste, darf sie nicht verschwinden. Das Issue ist erfüllt, wenn die Akzeptanzkriterien erfüllt sind. Und nicht mehr! Es steht nicht im Issue, dass es einen Button geben muss, mit dem man alle bis auf die letzte Währung ­löschen können soll. Diese Funktion könnte man implizit erwarten, wenn man nur von einer Liste von Währungen spricht. Sobald man aber sagt, was erfüllt sein soll, passiert das nicht mehr. Es steht da auch nichts von Tastatursteuerung. Sonst hätte man das in den Akzeptanzkriterien ergänzen müssen.

Singleton

Das Singleton-Pattern stellt sicher, dass eine Klasse nur eine Instanz hat, und bietet einen globalen Zugriffspunkt darauf. Beispiel: Eine Datenbankverbindung, die in einer Anwendung nur einmal initialisiert werden sollte.

public class DatabaseConnection {
  private static DatabaseConnection instance;
  private DatabaseConnection() {}
  
  public static DatabaseConnection getInstance() {
    if (instance == null) {
      instance = new DatabaseConnection();
    }
    return instance;
  }
} 
Jeder, der eine Datenbankverbindung braucht, holt sie sich über getInstance.

Decorator

Fügt einem Objekt dynamisch neue Funktionalitäten hinzu, ohne die Struktur zu ändern. Beispiel: Ein Verschlüsselungs-Decorator, der einem Stream zusätzliche Verschlüsselungsfunktionen hinzufügt.

public interface IStream {
  void Write(string data);
}
public class FileStream : IStream {
  public void Write(string data) { /* Write to file */ }
}
public class EncryptionDecorator : IStream {
  private IStream stream;
  public EncryptionDecorator(IStream stream) {
    this.stream = stream;
  }
  public void Write(string data) {
    // Encrypt data
    stream.Write(Encrypt(data));
  }
  private string Encrypt(string data) {
    // Encryption logic
    return data; // Return encrypted data
  }
} 

State

Während das Strategy-Pattern, das Sie im vorangegangenen Artikel [1] kennengelernt haben, den Algorithmus ändert, kann man mit einem State-Pattern je nach Zustand (State) das Verhalten ändern. Eine gewisse Ähnlichkeit beider Patterns ist durchaus vorhanden, und die Unterschiede sind eher akademischer Natur. Trotzdem möchte ich beide in Reinform hier einmal vorstellen. Beispiel: Eine Netzwerkverbindung, die je nach Zustand (verbunden, getrennt, im Wartezustand) unterschiedliche Operationen ausführt.

public interface IState {
  void Handle(Context context);
}
public class Context {
  private IState state;
  public void SetState(IState state) {
    this.state = state;
  }
  public void Request() {
    state.Handle(this);
  }
}
public class Connected : IState {
  public void Handle(Context context) {
  }
} 
Zu beachten ist hier, dass der State den Kontext übergeben bekommt und der State den Kontext ändern kann, nicht aber der Kontext abhängig vom State etwas tut. Wenn Sie lange Switch-Conditions in Ihrem Code finden, wäre die Frage, ob nicht ein State das handeln könnte. Mit einem State brauchen Sie dann den Kontext nicht zu ändern, wenn ein neuer State hinzukommt.

Builder-Prinzip

Diese Technik ermöglicht die schrittweise Erstellung eines komplexen Objekts. Beispiel: Ein Beleg-Builder, der aus verschiedenen Informationen erstellt wird.

public class BelegBuilder {
  private Beleg beleg = new Beleg ();
  
  public BelegBuilder SetKunde(string kunde) {
    beleg.SetKunde(kunde);
    return this;
  }
  
  public BelegBuilder SetBelegart(string art) {
    beleg.Belegart = art;
    return this;
  }
 
  public Beleg Build() {
    // ist der Beleg nicht gültig, wird eine Exception 
    // geworfen
    if (beleg.IsValid())
    return beleg;
  }
} 
Der Aufruf sieht dann so aus:

var beleg = new BelegBuilder().
  SetKunde("D1000").
  SetBelegart("VVA").
  Build() 
Der Vorteil ist, dass am Ende eine Build-Methode noch einmal schauen kann, ob alles gültig ist, und nur in diesem Fall das Belegobjekt zurückliefert. Vorher steht es keinem zur Verfügung. Ein Belegobjekt ist mit einem Builder-Pattern ­also immer gültig.

Observer

Ermöglicht einem oder mehreren Objekten, auf Änderungen eines anderen Objekts zu reagieren. Beispiel: Ein Event-­Management-System, das Benachrichtigungen sendet, wenn bestimmte Ereignisse eintreten.

public interface IObserver {
  void Update(string message);
}
public class Subject {
  private List&lt;IObserver&gt; observers = 
    new List&lt;IObserver&gt;();
  
  public void Attach(IObserver observer) {
    observers.Add(observer);
  }
  public void Notify(string message) {
    foreach (var observer in observers) {
      observer.Update(message);
    }
  }
} 
Wenn also jemand Interesse an einem Ereignis hat, dann ­registriert er sich am Subject. Alle Events in .NET funktionieren nach diesem Pattern.

Attribute

Oft hat man Aspekte, die nicht direkt die (operative) Funk­tionalität betreffen, aber auf Klassen, Properties oder Methoden zutreffen. In einer Benutzerklasse möchte man angeben, dass der Name mindestens drei Zeichen enthalten soll und Pflichtfeld ist. So etwas kann man über Attribute lösen:

public class Benutzer
{
  [Required]
  [StringLength(100, MinimumLength = 3)]
  public string Name { get; set; }
  [EmailAddress]
  public string Email { get; set; }
} 
Oder das Verhalten bei der Serialisierung:

public class Person
{
  [JsonProperty("name")]
  public string Name { get; set; }
  [JsonIgnore]
  public int Alter { get; set; }
} 

Verständliche Namensgebung

Klare und aussagekräftige Namen für Klassen, Methoden und Variablen helfen, zum Verständnis beizutragen. Namen sollten den Zweck oder die Funktion widerspiegeln, die sie erfüllen. Ein Beispiel: Statt GetData() sind spezifischere Namen wie LoadUserDetails() sinnvoll.

Fazit

Aufbauend auf den SOLID-Prinzipien haben wir gezeigt, dass gute Architektur die richtige Mischung aus verschiedenen Aspekten ist: Kapselung und Observer als Patterns, DRY und YAGNI als Grundhaltungen, Attribute und Extensions als Möglichkeiten, Code sauberer zu gestalten.

Fussnoten

  1. Bernhard Pichler, SOLIDe Architektur, dotnetpro 4–5/2025, Seite 88 ff., http://www.dotnetpro.de/A2504-05SOLID

Neueste Beiträge

DWX hakt nach: Wie stellt man Daten besonders lesbar dar?
Dass das Design von Websites maßgeblich für die Lesbarkeit der Inhalte verantwortlich ist, ist klar. Das gleiche gilt aber auch für die Aufbereitung von Daten für Berichte. Worauf besonders zu achten ist, erklären Dr. Ina Humpert und Dr. Julia Norget.
3 Minuten
27. Jun 2025
DWX hakt nach: Wie gestaltet man intuitive User Experiences?
DWX hakt nach: Wie gestaltet man intuitive User Experiences? Intuitive Bedienbarkeit klingt gut – doch wie gelingt sie in der Praxis? UX-Expertin Vicky Pirker verrät auf der Developer Week, worauf es wirklich ankommt. Hier gibt sie vorab einen Einblick in ihre Session.
4 Minuten
27. Jun 2025
„Sieh die KI als Juniorentwickler“
CTO Christian Weyer fühlt sich jung wie schon lange nicht mehr. Woran das liegt und warum er keine Angst um seinen Job hat, erzählt er im dotnetpro-Interview.
15 Minuten
27. Jun 2025
Miscellaneous

Das könnte Dich auch interessieren

UIs für Linux - Bedienoberflächen entwickeln mithilfe von C#, .NET und Avalonia
Es gibt viele UI-Frameworks für .NET, doch nur sehr wenige davon unterstützen Linux. Avalonia schafft als etabliertes Open-Source-Projekt Abhilfe.
16 Minuten
16. Jun 2025
Mythos Motivation - Teamentwicklung
Entwickler bringen Arbeitsfreude und Engagement meist schon von Haus aus mit. Diesen inneren Antrieb zu erhalten sollte für Führungskräfte im Fokus stehen.
13 Minuten
19. Jan 2017
Evolutionäres Prototyping von Business-Apps - Low Code/No Code und KI mit Power Apps
Microsoft baut Power Apps zunehmend mit Features aus, um die Low-Code-/No-Code-Welt mit der KI und der professionellen Programmierung zu verbinden.
19 Minuten
Anzeige
Anzeige
Anzeige
Anzeige
Anzeige