Über unsMediaKontaktImpressum
Matthias Eschhold 13. Juni 2023

Clean Architecture-as-Code

Prinzipien und Muster der Clean Architecture

Das Clean Architecture Pattern ist ein fortgeschrittenes Architekturmuster zur Strukturierung von Softwaresystemen. Die Vordenker der Architektur-Szene versprechen bei Anwendung der Clean Architecture ein wartbares, testbares und flexibles Softwaresystem – und das nicht nur in den frühen Phasen der Systementwicklung, sondern über den gesamten Lebenszyklus des Systems hinweg. Dies klingt mal wieder nach einem Alleskönner! Also Hand auf's Herz! Wie soll dies möglich sein und wo ist der Haken?

Ein smarter Konstruktionsplan für Software-Architektur

Bestehen hohe Qualitätsanforderungen hinsichtlich Wartbarkeit, Erweiterbarkeit, Flexibilität sowie Testbarkeit, braucht es eine smarte Architekturidee, die in ihrer Basis auf diese Qualitätsziele ausgerichtet ist. Dabei müssen in der Software-Architektur eine ganze Reihe von Prinzipien und Mustern auf unterschiedlichen Architekturebenen angewendet und kombiniert werden. Die Anzahl der Zeilen einer Methode sollte möglichst gering sein, Klassenvariablen sollten einen aussagekräftigen Namen haben und einfache Entwürfe sind zu bevorzugen. Dies sind Beispiele für gute Praktiken, die inzwischen fester Bestandteil eines modernen Entwickler-Mindsets sind. Die wohl bekannteste Gruppierung von Entwurfsprinzipien sind die SOLID-Prinzipien [1]. Steigender Beliebtheit erfreut sich CUPID, das als Gegendarstellung oder Bereicherung von SOLID verstanden werden kann – je nach persönlicher Perspektive [2]. Entwurfsmuster beschreiben Lösungsansätze für Klassenstrukturen hinsichtlich einer bestimmten Zielsetzung, wie beispielsweise die Adaption unterschiedlicher Objekte durch das Adapter Pattern oder die Erweiterung einer Methode mittels Dekorierung (Decorator Pattern). Entwurfsmuster bauen auf Entwurfsprinzipien auf [3].

Ein Architekturmuster beschreibt einen ganzheitlichen und fundamentalen Strukturierungsansatz eines Software-Systems. Dies beinhaltet Prinzipien für die Moduldefinition, Regeln für die Abbildung von Modulabhängigkeiten sowie Lösungsstrategien für den Aufbau darunterliegender Codestrukturen. Im Architekturmuster Schichtenarchitektur ist eine Modularisierung anhand technischer Merkmale vorgesehen. Daraus ergeben sich Schichten, die sich anhand definierter Regeln aufrufen dürfen. Das Ziel der Schichtenarchitektur – die Austauschbarkeit einer Schicht – kann durch Anwendung des Prinzips der Trennung der Schnittstelle von der Implementierung unterstützt werden. Ein Architekturmuster kombiniert folglich eine Reihe von Prinzipen und Mustern zu einem sinnvollen strukturellen Gesamtbild [4;5].

Das Architekturmuster Clean Architecture verteilt die verschiedenen Verantwortungen eines Software-Systems auf Ringe (s. Abb. 1). Diese sind konzentrisch angeordnet. Im Zentrum des Ringmodells befindet sich der Domänen-Ring. Der Use-Case-Ring beheimatet Schnittstellen der Domäne. Über diese Schnittstellen interagiert die Domäne mit dem Framework-Ring. Auf diesem befinden sich Elemente für die Integration der technischen Außenwelt des Software-Systems. Die strukturellen Beziehungen zwischen den Elementen der Ringe müssen dabei stets in Richtung des Domänen-Rings gerichtet sein [6].

Der Domänen-Ring

Abb. 2 beschreibt die fachliche Fragestellung der Domäne als Domain Story, die mittels Software digitalisiert wird. Dargestellt ist der fachliche Ablauf für die Erstellung eines Werkstattauftrags für ein Fahrzeug in einem Autohaus. Dargestellt sind Akteure, wie die Kund:in und die Service-Manager:in, sowie Domänenobjekte, wie z. B. der Auftrag und das Fahrzeug. Aktivitäten, abgebildet als Pfeile, verknüpfen diese Elemente und drücken das Verhalten der Domäne aus. Ein Beispiel ist die Erstellung des Werkstattauftrags im sechsten Schritt des Szenarios. Die Lösung für das fachliche Problem wird in der Clean Architecture durch den Klassenstereotyp Entity realisiert. Orientiert an Praktiken des taktischen Domain-driven Designs wird zusätzlich der Stereotyp Value Object für die Implementierung des Domänenmodells verwendet. Eine Entität hat einen Lebenszyklus sowie eine eindeutige Identität. Sie beschreibt sich durch eine Menge von Value Objects. Value Objects sind fachliche Werte, die dem Prinzip der Unveränderbarkeit (Immutability) folgen.

Der daraus resultierende Objektgraph benötigt eine Wurzelentität (Root Entity), wie der Auftrag aus Listing 1. Die Root Entity agiert als Fassade (Acts as a Facade) und wendet das Geheimnisprinzip (Information Hiding) auf die enthaltenen Fachwerte und die damit verbundenen fachlichen Funktionen an. Ein Objektgraph kann auch Sub-Entitäten beinhalten, die eine eigene Identität haben. Ihre Existenz ist allerdings nur innerhalb der Root Entity gegeben. Es gilt als gute Praxis, diese Komplexität zu vermeiden und innerhalb einer Root Entity nur Value Objects zu verwenden. Dies setzt voraus, dass die Fachlichkeit dies ermöglicht [7]. Für die Root Entity Auftrag wird diese Praxis angewendet.

Listing 1:

@Getter
public class Auftrag {
private Erstellungsdatum erstellungsdatum;
private Auftragsnummer auftragsnummer;
private List<Auftragsposition> auftragspositionen; 
	private Auftragsfreigabe auftragfreigabe;
   
   //…
}

Der Objektgraph wird implementiert konform zum Prinzip des Rich Domain Models. Dieses Prinzip besagt, dass fachliche Objekte ihre inhärenten Eigenschaften sowie dazugehöriges Verhalten selbst implementieren [7]. Zum Verhalten gehört die fachliche Validierungslogik. Value Objects folgen hier dem Prinzip der Selbst-Validierung (Self-Validation). Dies bedeutet, dass die Validität eines fachlichen Wertes jedes Value Object bei seiner Erzeugung prüft. Listing 2 zeigt das Value Object Auftragsnummer. Dieses prüft bei Erzeugung die Validität des übergebenen Wertes. In diesem Fall wird geprüft, ob der Wert für die Auftragsnummer dem erwarteten Format entspricht. Zusammengesetzte Value Objects, also Value Objects, die aus anderen Value Objects bestehen, wie z. B. die Auftragsposition in Listing 3, sowie Entitäten, wie der Auftrag in Listing 1, haben die Verantwortung, die Existenz und das richtige fachliche Zusammenspiel der Fachwerte zu validieren [7]. So kann zum Beispiel im fachlichen Szenario aus Abb. 2 ein Auftrag nicht freigegeben werden. Ist dies der Fall, wird ein Ablehnungsgrund benötigt. Listing 4 zeigt das Value Object Auftragsfreigabe, welches diese Geschäftsregel in sich kapselt und bei der Objekterzeugung ausführt. Dass der Wert des Value Objects Ablehnungsgrund nicht leer ist oder nur aus Leerzeichen besteht, prüft wiederum das Value Object selbst (siehe Listing 5).

Listing 2:

public record Auftragsnummer(String value) {
    public Auftragsnummer {
        if(!value.matches("([A-Z]{2})-([0-9]{7})-([0-9]{4})")) {
            throw new IllegalStateException("Auftragsnummer entspricht 
nicht dem Format!");
        }
    }
}

Listing 3:

public record Auftragsposition(Bezeichnung bezeichnung, Menge menge, 
      Zeitbedarf zeitbedarf) {
    public Auftragsposition {
        if(bezeichnung == null || menge == null) {
            throw new IllegalStateException("Auftragsposition darf nicht mit 
Null-Werten erzeugt werden!");
        }
    }
}

Listing 4:

public record Auftragsfreigabe(FreigabeStatus freigabeStatus, 
Ablehnungsgrund ablehnungsgrund) {

    public Auftragsfreigabe {
        checkMandatoryFields();
        performAblehnungsgrundRule();
    }

    private void performAblehnungsgrundRule() {
        if(freigabeStatus.isAbgelehnt() && ablehnungsgrund == null) {
            throw new IllegalStateException("Ein abgelehnter Auftrag benötigt 
                  einen Ablehnungsgrund!");
        }
    }

    private void checkMandatoryFields() {
        if(freigabeStatus == null) {
            throw new IllegalStateException("Freigabestatus darf nicht 
Null sein!");
        }
    }
}

Listing 5:

public record Ablehnungsgrund(String value) {
    public Ablehnungsgrund {
        if(value.isBlank()) {
            throw new IllegalStateException("Der Ablehnungsgrund stellt 
kein valide Wert dar!");
        }
    }
}

Nicht alle Aspekte der Domäne lassen sich mit Entitäten oder Fachwerten realisieren. Es gibt Geschäftslogik, welche kein inhärentes Verhalten eines Domänenobjekts darstellt. Dazu gehört zum einen die fachliche Ablaufsteuerung der Anwendungsfälle. Für diese Aufgabe ist der Stereotyp Service vorgesehen. Dieser ist ebenfalls ein Element des Domänen-Rings [8]. Zum anderen ist die Verortung der Ermittlung von fachlichen Werten sowie von fachlichen Kalkulationen nicht immer eindeutig. Eine weitere Leitlinie des Rich Domain Models hilft an dieser Stelle. Es besagt, dass Entitäten nur das Verhalten umsetzen sollen, das sie ohne weitere Abhängigkeiten, d. h. rein auf Basis ihrer Eigenschaften, implementieren können. Können die beschriebenen Bedingungen nicht erfüllt werden, ist die Geschäftslogik in einem Service zu verorten [7]. Listing 6 zeigt die Root Entity Auftrag mit der Methode calculateGesamtzeit. Ein Auftrag besteht aus mindestens einer Auftragsposition. Dienstleistungen am Fahrzeug, wie z. B. ein Ölwechsel, werden als Auftragsposition erfasst und haben einen festgelegten Zeitbedarf. Auf dieser Basis kann die Gesamtzeit für einen Auftrag ermittelt werden. Die Gesamtzeit (s. Listing 7) stellt eine inhärente Eigenschaft des Auftrags dar und kann rein auf Basis der innewohnenden Eigenschaften ermittelt werden. Es werden keine weiteren Abhängigkeiten zu einem Service oder einer anderen Root Entity bzw. Komponente benötigt.

Listing 6:

@Getter
public class Auftrag {
private List<Auftragsposition> auftragspositionen; 
private Gesamtzeit gesamtzeit;
//…

public void calculateGesamtzeit() {
  gesamtzeit = new Gesamtzeit(auftragspositionen.stream()
                .map(auftragsposition ->  
auftragsposition.zeitbedarf().value())
 .collect(Collectors.toList())
               .stream()
.mapToDouble(Double::doubleValue)
.sum());
}
}

Listing 7:

record Gesamtzeit(Double value) {
    public Gesamtzeit {
        if(value <= 0.25) {
            throw new IllegalStateException("Der kleinste erlaubte Wert 
               für die Gesamtzeit beträgt 15 Minuten!");
        }
    }
}

Listing 8 zeigt den WerkstattRoutenService als Beispiel für einen Service, der die Aufgabe hat, den fachlichen Wert Werkstattroute bzw. ein Set von WerkstattRouten zu ermitteln. Die WerkstattRouten sind für die weitere Ablaufsteuerung und Kapazitätsplanung der Werkstattbereiche Karosserie, Diagnose, Wartung und Reparatur notwendig. Die WerkstattRouten werden auf Basis der Art der einzelnen Auftragspositionen ermittelt. Die WerkstattRoute wird jedoch nicht als natürliche Eigenschaft der Entität Auftrag angesehen.

Listing 8:

public class WerkstattRoutenService {
    public Set<WerkstattRoute> determineWerkstattRouten(
      List<Auftragsposition> auftragspositionen) {
        Set<WerkstattRoute> werkstattRouten = new HashSet<>();
        auftragspositionen.stream()
.forEach(p -> werkstattRouten.add(
new WerkstattRoute(p.typ().value().toString())));
        return werkstattRouten;
    }
}

Der AuftragService (s. Listing 9) bietet die Methode create an und realisiert den fachlichen Ablauf bestehend aus den Aktivitäten:

  1. Erzeugung der Auftragsnummer und des Erstellungsdatums
  2. Erstellung des Domänenobjekts Auftrag
  3. Anlage des Auftrags in der Datenbank
  4. Ermittlung der WerkstattRouten
  5. Setzen der WerkstattRouten in einer Workflow-Engine

Listing 9:

@RequiredArgsConstructor
public class AuftragService implements CreateAuftrag {
    private final ErstellungsdatumFactory erstellungsdatumFactory;
    private final AuftragsnummerFactory auftragsnummerFactory;
    private final SaveAuftrag saveAuftrag;
    private final SaveWerkstattRouten saveWerkstattRouten;
    private final WerkstattRoutenService werkstattRoutenService;

    @Override
    public Auftrag create(List<Auftragsposition> auftragspositionen) {
        Auftragsnummer auftragsnummer = auftragsnummerFactory.create();
        Erstellungsdatum erstellungsdatum = erstellungsdatumFactory.create();
        Auftrag auftrag = new Auftrag(auftragsnummer, erstellungsdatum, 
auftragspositionen);
        auftrag = saveAuftrag.save(auftrag);
        saveWerkstattRouten.save(
werkstattRoutenService.determineWerkstattRouten(auftragspositionen));
        return auftrag;
    }
}

Die Abhängigkeiten SaveWerkstattRouten und SaveAuftrag stellen ausgehende Use Cases dar. Diese werden aus der Perspektive der Domäne definiert und sind auf dem Use-Case-Ring verortet. Der Domänen-Ring mit dem Stereotyp Service darf außerhalb der Domäne nur zu Use Cases Abhängigkeiten aufbauen. Die Erstellung der Auftragsnummer und des Erstellungsdatums wird hier vernachlässigt und vereinfacht über eine Factory der Domäne dargestellt. Ist hier ebenfalls eine Separierung von Domäne und Technik notwendig, gelten die Prinzipien und Muster der Clean Architecture in gleicher Weise.

Der Use-Case-Ring

Der Use-Case-Ring umschließt den Domänen-Ring und hat die Verantwortung, Schnittstellen der Domäne für die technische Außenwelt bereitzustellen. Typische Komponenten der technischen Außenwelt eines Software-Systems finden sich in der Web-Infrastruktur mit HTTP sowie in Datenbanken, Message Brokern, Caches oder externen Web-Services wieder. Dabei gibt es technische Komponenten, die die Domäne aufrufen und solche, die von der Domäne genutzt werden. Eine Schnittstelle der Domäne wird als Use Case bezeichnet und als ein- oder ausgehender Use Case klassifiziert [8].

Das Clean Architecture Pattern hat das Ziel, einen von der technischen Außenwelt unabhängigen Domänen-Ring zu entwickeln. Für den Domänen-Ring spielt es dabei keine Rolle, welche technische Komponente hinter einem Use Case steht. Eine Domänen-Funktion ist auf gleiche Weise zu nutzen, unabhängig davon, ob der Konsument eine Browser-Anwendung, einen Test-Runner oder eine Smartphone-App darstellt. Dies geht zurück auf die Idee des Ports und Adapters Pattern[9]. Dieses Muster sieht für die Komponenten der technischen Außenwelt Adapter vor, die Ports der Domäne nutzen oder implementieren. Diese Adaption ist kombiniert mit einer Datentransformation. Um die gewünschte Entkopplung zwischen Domäne und Außenwelt zu erreichen, muss jeder Ring auf seinem eigenen Modell arbeiten. Die Clean Architecture basiert auf dem Ports und Adapters Pattern. Anstelle des Begriffs Port wird in der Clean Architecture der Begriff Use Case verwendet [8].

Listing 10 und 11 zeigen die ausgehenden Use Cases SaveAuftrag und SaveWerkstattRouten, gegen die der AuftragService implementiert. Hinzu kommt der eingehende Use Case CreateAuftrag. Dieser wird vom AuftragService implementiert (s. Listing 9 und 12). Durch die Nutzung des Use Cases CreateAuftrag kann nun die Funktionalität der Domäne konsumiert werden.

Listing 10:

public interface SaveAuftrag {
    Auftrag save(Auftrag auftrag);
}

Listing 11:

public interface SaveWerkstattRouten {
    void save(Set<WerkstattRoute> werkstattRouten);
}

Listing 12:

public interface CreateAuftrag {
    Auftrag create(List<Auftragsposition> auftragspositionen);
}

Use Cases schneiden

Wenn verschiedene Konsumenten dieselbe Domänen-Funktionalität nutzen, entsteht eine indirekte Kopplung zwischen diesen Konsumenten. Über den Use-Case-Schnitt kann diese Kopplung bewusst gestaltet werden. Eine geringere Kopplung erhält die Flexibilität der Domäne hinsichtlich Erweiterungen und Änderungen. Das Risiko von Seiteneffekten wird reduziert. Der Use-Case-Schnitt wird durch die Anwendung des Interface Segregation Principles unterstützt. Dieses Prinzip besagt, dass die Verantwortung von Schnittstellen möglichst klein, d. h. fachlich reduziert auf das notwendigste, sein sollte. Sind Konsumenten von Eingabeparametern oder Rückgabewerten abhängig, die sie nicht benötigen, besteht das Risiko, dass diese Abhängigkeiten Probleme und Anpassungsbedarf verursachen [8]. Das Interface Segregation Principle ist Bestandteil von SOLID.

Listing 13 und 14 zeigen den Use Case AuftragCommand und den Use Case AuftragQuery. Dies veranschaulicht die Möglichkeit, Use Cases anhand der Trennung von Command- und Query-Funktionen zu schneiden. Dies fördert die Lesbarkeit und beugt großen und schwer verständlichen Services vor. Eine granularere Variante ist der bereits dargestellte Schnitt von Use Cases anhand der Funktion. Dies bedeutet, dass die Schnittstelle in einem eigenen Interface abgebildet wird. Der Name des Interfaces drückt dann bereits den funktionalen Charakter aus [8]. Beide Varianten, Use Cases zu schneiden, können sinnvoll kombiniert werden. Für Use Cases, die ausschließlich das eigene Frontend konsumiert, ist eine Trennung in Command und Query ein guter und oft ausreichender Lösungsansatz. Konsumieren zusätzlich externe Systeme Funktionen der Domäne, ist empfohlen, an dieser Stelle dedizierte eingehende Use Cases anzubieten. Dies minimiert die indirekte Kopplung zwischen den Konsumenten. Gleiches gilt für die ausgehende Seite. Bei der Integration der eigenen Datenbank ist eine Trennung in Command und Query in den meisten Fällen ausreichend. Werden spezifische Funktionalitäten von externen Systemen konsumiert, kann durch einen funktionalen Use-Case-Schnitt die Verständlichkeit erhöht werden.

Listing 13:

public interface AuftragCommand {
Auftrag create(List<Auftragsposition> auftragspositionen);
void start(Auftragsnummer auftragsnummer);
void close(Auftragsnummer auftragsnummer)
//…
}

Listing 14:

public interface AuftragQuery {
Auftrag findByAuftragsnummer(Auftragsnummer auftragsnummer);
List<Auftrag> findByAuftragsstatus(Auftragsstatus auftragsstatus);
List<Auftrag> findAll();

//…
}

Der Framework-Ring

Der Framework-Ring ist der äußerste Ring der Clean Architecture und beheimatet die bereits erwähnten Adapter. Analog zu den Use Cases werden auch Adapter in ein- und ausgehend bzw. in Input- und Output-Adapter unterschieden. Ein sehr bekannter Vertreter für einen Input-Adapter ist die REST-Schnittstelle. Nahezu jedes Framework unterstützt die Implementierung dieser Schnittstellen. Dennoch entsteht hier Quellcode, der technische Belange in Bezug auf HTTP-Kommunikation und den REST-Architekturstil abbildet. Dieser technische Code ist von der Domäne zu trennen. Dies reduziert Komplexität und fördert die Verständlichkeit. Die Domäne wird im Input-Adapter durch die Nutzung eines eingehenden Use Cases aufgerufen. Dies zeigt der AuftragController in Listing 15.

Das Ziel, einen von der Technik isolierten Domänen-Ring zu erhalten, bedingt, dass der Framework-Ring und der Domänen-Ring auf ihrem eigenen Modell arbeiten und eine Transformation zwischen diesen Datenrepräsentationen an der Grenze des Framework-Rings stattfindet [8]. Im Falle einer REST-Schnittstelle werden Ressourcen, wie die AuftragResource aus Listing 16, verwendet. Ein Mapper implementiert die Transformation zwischen dem Modell des Domänen-Rings und des Framework-Rings. Dies wird implementiert im AuftragToAuftragResourceMapper in Listing 17. Das Mapping zwischen den Ring-Grenzen führt zu der Fähigkeit, das Domänen- und das Kommunikationsmodell unabhängig voneinander zu entwickeln sowie das Kommunikationsmodell zu vereinfachen. Dies erhöht zum einen die Anpassungsfähigkeit der Domäne und ermöglicht zum anderen konzeptionelle (z. B. Umstellung von einem synchronen auf ein asynchrones Kommunikationsmuster) und technologische (z. B. Refactoring von einer REST-basierten zu einer event-getriebenen Input-Adapter-Implementierung) Veränderungen im Framework-Ring mit reduziertem Zeitbedarf und Risiko.

Listing 15:

@RestController
@RequiredArgsConstructor
public class AuftragController {
   private final CreateAuftrag createAuftrag;
   private final AuftragToAuftragResourceMapper mapper;
	
	@PostMapping
   public AuftragResource create(List<AuftragspositionResource>  
               auftragspositionen) {
       Auftrag createdAuftrag =  createAuftrag.create(
       mapper.mapResourceListToAuftragspositionen(auftragspositionen));
       return mapper.mapAuftragToAuftragResource(createdAuftrag);
  }
}

Listing 16:

public record AuftragResource(String auftragsnummer, String erstellungsdatum,
                              List<AuftragspositionResource> auftragspositionen,
                              double gesamtzeit, String auftragsstatus,
                              AuftragsfreigabeResource auftragsfreigabe) {}

Listing 17:

public class AuftragToAuftragResourceMapper {

  public List<Auftragsposition> mapAuftragspositionen(
           List<AuftragspositionResource> resourceList) {
           return auftragspositionResourceList
          .stream()
          .map(this::mapAuftragspositionResourceToAuftragsposition)
          .collect(Collectors.toList());
  }

  public AuftragResource mapAuftrag(Auftrag auftrag) {
          return new AuftragResource(auftrag.getAuftragsnummer().value(),
          auftrag.getErstellungsdatum().value(),
          auftrag.getAuftragspositionen()
          .stream()
          .map(this::mapAuftragspositionToAuftragspositionResource)
          .collect(Collectors.toList()),
          auftrag.getGesamtzeit().value(),
          auftrag.getAuftragsstatus().value().toString(),
          new AuftragsfreigabeResource(
          auftrag.getAuftragsfreigabe()
          .getFreigabeStatus().value(),
          auftrag.getAuftragsfreigabe()
          .getAblehnungsgrund().value()));
  }

}

Eine Adaption auf der ausgehenden Seite des Software-Systems wird anhand des Beispiels einer relationalen Datenbank beschrieben. Viele Frameworks unterstützen die technische Integration von Datenbanken sehr gut. Analog der REST-Schnittstelle ergeben sich trotz allem Datenklassen und SQL-Statements als Bestandteil des Quellcodes. Der Domänen-Ring darf diesen Code nicht selbst implementieren und auch nicht direkt von diesem abhängig sein. Die technische Anbindung der Datenbank erfolgt in einem Output-Adapter, der einen korrespondierenden ausgehenden Use Case implementiert. Dadurch wird die strukturelle Abhängigkeit zwischen Service- und Output-Adapter auf Basis des ausgehenden Use Cases umgekehrt. Diese Lösungsstrategie nennt sich Dependency Inversion Principle und ist ein Element der SOLID-Prinzipien [1]. Die Umsetzung des Output-Adapters erfolgt durch den Klassenstereotyp Repository. Dies ist beispielhaft dargestellt anhand des AuftragRepository in Listing 18. Durch Dependency Injection wird die Abhängigkeit zu einer Adapter-Implementierung zur Laufzeit aufgelöst. Durch die Auslagerung der Abhängigkeit auf den Dependency Injector wird der Domänen-Ring noch stärker entkoppelt, auch wenn die Abhängigkeit der Domäne zum Dependency Injector bleibt.

Wie auf der eingehenden Seite bereits verdeutlicht, ist eine vollständige Entkopplung des Domänen- und Framework-Rings nur möglich, wenn jeder Ring sein eigenes Modell realisiert und eine Transformation an der Ring-Grenze stattfindet. Dies verdeutlicht die AuftragDbEntity in Listing 19 und der AuftragToAuftragDbEntityMapper in Listing 20.

Listing 18:

@RequiredArgsConstructor
public class AuftragsRepository implements SaveAuftrag {
    final AuftragToAuftragDbEntityMapper mapper;

    @Override
    public Auftrag save(Auftrag auftrag) {
        var dbEntity = mapper.mapAuftragToAuftragDbEntity(auftrag);
        return mapper.mapAuftragDbEntityToAuftrag(saveDbEntity(dbEntity));
    }

    private AuftragDbEntity saveDbEntity(AuftragDbEntity dbEntity) {
        //... technischer Code
        return dbEntity;
    }
}

Listing 19:

@Getter
@Setter
@Entity
@Table(”AuftragTbl”)
public class AuftragDbEntity {
@Id
@Column(name=”auftragsnummer”, nullable=false)  
private String auftragsnummer;
	@Column(…)
   private String erstellungsdatum;
   @Column(…)
 	private String auftragsstatus;
   @Column(…)
private String freigabeStatus;
@Column(…)
   private String ablehnungsgrund;
@Column(…)
private List<AuftragspositionDbEntity> auftragspositionen;
}

Listing 20:

public class AuftragToAuftragDbEntityMapper {

    public AuftragDbEntity mapAuftrag(Auftrag auftrag) {
      var dbEntity = new AuftragDbEntity();
      dbEntity.setAblehnungsgrund(
      auftrag.getAuftragsfreigabe().getAblehnungsgrund().value());
      dbEntity.setAuftragsnummer(auftrag.getAuftragsnummer().value());
        
      //…
      return dbEntity;
    }

    public Auftrag mapAuftragDbEntityToAuftrag(AuftragDbEntity 
      auftragDbEntity) {
      Auftragsnummer auftragsnummer = new Auftragsnummer(auftragDbEntity.getAuftragsnummer());
      Erstellungsdatum erstellungsdatum =
      new Erstellungsdatum(auftragDbEntity.getErstellungsdatum());
      Auftragsstatus auftragsstatus = new Auftragsstatus(
      AuftragsstatusEnum.valueOf(auftragDbEntity.getAuftragsstatus()));
      return new Auftrag(auftragsnummer, erstellungsdatum, auftragsstatus,
      mapAuftragspositionen(auftragDbEntity.getAuftragspositionen()));
    }
}

In diesem Ansatz wird die Idee des Single Responsibility Principles angewendet. Das Single Responsibility Principle ist ein Prinzip aus SOLID und besagt, dass eine Klasse genau einen Grund haben darf, sich zu ändern [1]. In der Clean Architecture wird das Single Responsibility Principle angewendet, indem die technischen und fachlichen Belange separiert werden und jede technische Komponente eine eigene Adapter-Implementierung darstellt. Im beschriebenen Beispiel erfolgt eine Aufteilung der Verantwortungen mit dem Ziel, eine Single Responsibility durch Aufteilung der Aspekte zu erreichen:

  • HTTP-Integration mit dem Stereotyp Controller
  • Datenbank-Integration mit dem Stereotyp Repository
  • Domänen-Logik verteilt auf die Stereotypen Service, Entity und Value Object

North argumentiert nachvollziehbar, dass das Single Responsibility Principle zu vage sei [2]. Denn die Frage stellt sich, worin letztendlich "dieser einzige Grund für eine Änderung" besteht? Ist dies, wie hier beschrieben, bezogen auf die Aspekte HTTP, Datenbank und Domänen-Logik im Kontext einer Root Entity? Oder müsste jede HTTP-Methode, wie z. B. GET (für Read) oder POST (für Create), in separaten Klassen implementiert werden? Neben der bereits beschriebenen Entkopplung reduziert die Trennung von technischem und fachlichem Code gemäß Single Responsibility signifikant die Komplexität des Quellcodes. North empfiehlt generell, sich darauf zu besinnen, einfachen Quellcode zu schreiben [2]. Hier ergänzen sich SOLID und CUPID also sinnvoll, da die Anwendung des Single Responsibility Princples, wie hier beschrieben, zu einfachem Code führt.

Fachliche Modularisierung und Abhängigkeiten zwischen Wurzelentitäten

Das fachliche Szenario aus Abb. 2 zeigt neben der Root Entity Auftrag auch die Root Entity Fahrzeug. Ein Auftrag bezieht sich auf ein Fahrzeug. Für ein Fahrzeug gibt es im Zeitverlauf mehrere Aufträge. Beide Entitäten haben einen unabhängigen Lebenszyklus. Kommen neue Auftragspositionen zu einem Auftrag hinzu, hat dies keine Auswirkung auf das Fahrzeug. Eine Zustandsveränderung im Fahrzeug, wie z. B. eine Erhöhung des Kilometerstands, hat keinerlei Auswirkung auf dem Fahrzeug zugeordnete Aufträge. Da Wurzelentitäten auf Basis ihres eigenständigen Lebenszyklus' eine fachliche Konsistenzgrenze darstellen, sammeln sich mehrere eindeutig zuordenbare Anwendungsfälle um sie herum. Die folgenden dienen hier als Beispiel:

  • Die Anlage eines Fahrzeugs oder eines Auftrags
  • Das Hinzufügen oder das Entfernen von Auftragspositionen
  • Die Aktualisierung von Fahrzeugbewegungsdaten (z. B. der Kilometerstand und das Fahrzeugkennzeichen)
  • Die Archivierung von Auftragsdaten konform zu gesetzlichen Vorgaben

Mit dem Ziel einer fachlich modularen Architektur müssen die Anwendungsfälle sinnvoll gruppiert werden. Als Leitlinie hilft das Separation of Concerns Principle. Dies besagt, dass unterschiedliche Belange oder Verantwortungen voneinander getrennt werden sollen [1]. Die unterschiedlichen Belange sind auf verschiedene Module aufzuteilen. Weitere Orientierung bietet das Common Closure Principle. Selbiges besagt, dass die Code-Artefakte, die sich gemeinsam ändern, auch gemeinsam gebündelt werden sollten. Um die beschriebenen Anwendungsfälle entstehen Code-Artefakte, die Belange des Fahrzeugs oder des Auftrags umsetzen [10]. Der Großteil des Quellcodes kann eindeutig einer Root Entity zugeordnet werden. Die Code-Artefakte, die derselben Root Entity zugeordnet werden, ändern sich potenziell gemeinsam. Daraus ergibt sich ein Entwurf, bei dem beide Common Closures, oder besser ausgedrückt fachliche Module, nicht beeinträchtigt sind von Änderungen im anderen Modul (s. Abb. 3).

North beschreibt in CUPID die Unix Philosophy[2]. Diese beschreibt die Eigenschaft von Komponenten, nur eine Sache zu tun und diese dafür sehr gut zu können. Das lässt sich auf fachliche Modularisierung wie hier beschrieben übertragen. Dass die Modulschnitte fachlich erfolgen sollten, drückt das CUPID-Modell durch das Prinzip Domain-based aus [2]. Abschließend sei angemerkt, dass auch der Erfinder des Clean Architecture Pattern, Robert C. Martin, mit dem Prinzip der Screaming Architecture die Empfehlung einer fachlichen Modularisierung zum Ausdruck bringt. Er beschreibt mit diesem Prinzip die Eigenschaft der Architektur, ihre Fachlichkeit einem Betrachter entgegenschreien zu können [10]. Dies ist nur möglich, wenn die Modularisierung anhand fachlicher Gesichtspunkte, wie in Abb. 3 dargestellt, durchgeführt wurde.

Die Domain Story aus Abb. 2 zeigt jedoch auch einen fachlichen Zusammenhang zwischen diesen Geschäftsobjekten. Beispielsweise benötigt der Auftrag eine bestimmte Menge an Fahrzeugdaten. Dies führt zu Abhängigkeiten zwischen Modulen, die durch wohldefinierte Schnittstellen aufgelöst werden müssen. Die Kopplung ist dabei so minimal wie möglich zu gestalten. Unterschiedliche Ansätze, um fachliche Modulabhängigkeiten abzubilden, sind in [3] beschrieben.

Die Clean Architecture im Überblick

Abb. 4 zeigt das Ringmodell der Clean Architecture für das fachliche Modul Auftragserstellung und -bearbeitung. Visualisiert werden die im Artikel beschriebenen Klassenstereotypen und die zwischen ihnen existierenden Nutzt- und Implementiert-Beziehungen. Ein Überblick der im Artikel beschriebenen Prinzipien und Muster ist ebenfalls im Architekturmodell dargestellt.

Fazit

Die Clean Architecture ist ein mächtiges Muster, das es Softwaresystemen ermöglicht, hohen Anforderungen an Wartbarkeit, Erweiterbarkeit, Testbarkeit und Flexibilität gerecht zu werden. Wieso? Wegen der smarten Kombination von Prinzipien und Mustern, die das Clean Architecture Pattern in sich vereint und der damit einhergehenden notwendigen Disziplin für die musterkonforme Implementierung der fachlichen Anforderungen. Hierin und im notwendigen Wissensaufbau versteckt sich der zu Beginn erwähnte "Haken". Weicht die Komplexität des Problems zu stark von der Komplexität der Lösung ab, bewegt sich das Projekt im Bereich des Over-Engineerings. Dies gilt es natürlich zu vermeiden. Meine persönliche Erfahrung ist jedoch, dass der Projektkontext in der Regel nur zu Beginn nicht ausreichend komplex für die Anwendung des Clean Architecture Pattern erscheint. Bei wachsendem System ändert sich das. Darüber hinaus waren bisher nahezu alle Architekt:innen und Entwickler:innen, mit denen ich in den letzten Jahre zusammenarbeiten durfte, am Ende von den Vorteilen der Clean Architecture überzeugt. Die zu Beginn gefühlt aufwändigere Implementierung geht nach einer angemessenen Lernkurve einfach von der Hand und die Vorteile loser Kopplung und guter Verständlichkeit werden von Sprint zu Sprint sichtbarer. Mein Rat an euch: Probiert es aus!

Quellen
  1. Clean Code Developer: SOLID
  2. D. Terhost-North: CUPID – the back story
  3. E. Freeman, E. Freeman, K. Sierra & B. Bates: Entwurfsmuster von Kopf bis Fuß, O’Reilly 2004
  4. C. Lilienthal: Langlebige Software-Architekturen, dpunkt.verlag, 2019
  5. G. Starke: Effektive Softwarearchitekturen, Hanser Verlag, 2020
  6. R. C. Martin: The Clean Architecture
  7. M. Plöd: Hands-on Domain-driven Design – by example, Leanpub, 2020
  8. T. Hombergs: Get Your Hands Dirty on Clean Architecture, Packt Publishing, 2019
  9. A. Cockburn: The Pattern: Ports and Adapters
  10. R. C. Martin: Clean Architecture, Pearson Education, 2018
  11. M. Eschhold: Flexible Softwarearchitektur mit der Clean Architecture, Java Aktuell, 2022

Autor

Matthias Eschhold

Matthias Eschhold ist Lead Architect der Domäne E-Mobility bei der EnBW AG. Seit 2016 beschäftigt er sich mit den Herausforderungen des agilen Architekturentwurf mittels Domain-driven Design.
>> Weiterlesen
Das könnte Sie auch interessieren
Kommentare (0)

Neuen Kommentar schreiben