IOSP und PoMO? Kann man das essen?

In einer drei wöchigen Pause zwischen meinem Leben als Microsoft "Premier Field Engineer" und als selbstständiger IT Berater hatte ich endlich mal Zeit mich um die richtig coolen Dinge zu kümmern. Mein erstes Item auf meiner ewig langen Todo Liste war ein cooles Beispiel zu implementieren mit dem man aufwendige Berechnungen mit dem Aktormodell von Akka.Net skalieren kann. Gesagt getan. Während des Programmierens allerdings hatte ich eine Diskussion mit Ralf Westphal über Slack wo es um die wohl für mich relativ neuen Prinzipien IOSP (Integration/Operation Segregation Principle) und PoMO (Principle of mutual oblivion) ging. Also quasi die Trennung von Integration und Operationen bei Methoden (und Klassen) und das "Prinzip der Gegenseitigen Nichtbeachtung". Also habe ich das Aktormodell Beispiel mal auf Seite gelegt. Dazu gibt es mehr in meinem zweiten Blogeintrag in Kürze. :-)

 

Nach erstem Durchlesen durch Ralfs Artikel dachte ich, der hat doch zuviel schlechtes Zeug geraucht (sorry Ralf). Alles viel zu umständlich und zu akademisch. Es gibt doch SRP (Single Responsibility Principle) und DIP (Dependency Inversion Principle). Hatte er das etwa ignoriert oder nicht verstanden. Totale Verständnislosigkeit machte sich breit. :) Wie wolle er denn die Abhängigkeiten regeln die es ja ohne Zweifel mindestens indirekt durch Interfaces gibt zwischen zwei Modulen? Wie soll eine Leben ohne DIP aussehen? Und wie solle man denn vernünftig testen können ohne richtiges Dependency Injection?

 

Fragen über Fragen. Also las ich den Artikel öfters und überlegte mir wie ich meinen Code den ich bisher geschrieben hatte in dieses Schema pressen könnte. Nach mehrmaligem Lesen und Ausprobieren dann erschien es mir doch einleuchtend - Nein sogar ziemlich genial. Ich fand sein Code Beispiel etwas verwirrend aufgrund der Tatsache, dass er anstatt des üblichen Request/Response Musters einen CPS (Continuation Passing Style) Ansatz wählte, der zwar schön ist, aber am Anfang mehr verwirrt als das er hilft. Deswegen habe ich mir mal ein Beispiel überlegt erstmal ohne CPS Ansatz aber trotzdem mit IOSP und PuMO (für die Operation Services). Im letzten Sample habe ich dann IOSP mit CPS erweitert zum Vergleich. Also, schreiben wir mal Code :).

 

Beispiel:

Denken wir uns den folgenden Business Services (BS) aus dessen Aufbau ich in der Praxis schon oft gesehen hab und den ich auch erstmal als SOLID bezeichnen würde. CustomerEarningsService führt irgendeine nicht weiter wichtige Domänen Logik aus. 

Kurzer SOLID Exkurs:

An dieser Stelle möchte ich noch erwähnen, dass für mich SOLID in unterschiedlichen Abtraktionsebenen verwendet werden sollte. SRP zum Beispiele sehe ich als ein Prinzip, dass auf atomarer Ebene Anwendung findet und da auch gut und richtig ist. Ein CSVReader darf keinen CSVWriter enthalten und umgekehrt, sonst wären sie nicht SRP. Ein Business Service der einen CSVReader und einen CSVWriter benutzt kann aber trotzdem SOLID sein, auch wenn er zwei Aspekte vereinigt. Sprich man darf Atome benutzen um Moleküle zu bauen, sonst wäre keine Applikation der Welt SRP, weil weiter oben immer Funktionalität zu etwas Größerem zusammengebaut wird. Dieses Missverständnis erlebe ich immer wieder bei meinen Kunden.

 

So, aber was fällt auf bei meinem Beispiel oben? Ist es SOLID? Ja definitiv! Haben wir die Abhängigkeiten aufgelöst? Hmm, nein eigentlich nicht. Zumindest nicht die Abhängigkeiten zu einem Interface und zur Semantik der aufzurufenden Methode. Können wir das "leicht" testen? Naja, können wir, aber alle Repositories und evtl. weitere vorhandene Services müssen gemocked werden. Das ist also erstmal unschön und nicht gerade optimal. Aber warum müssen wir denn soviel mocken? Naja wir mischen eben im CustomerEarningsService Integration mit Operation. CustomerEarningsService  integriert die Services und führt in seinen beiden Methoden auch Operationen aus. Und aufgrund des Integrierenden Teils brauchen wir die Mocks. Also was liegt denn näher als diese beiden Teile zu trennen - und das ist auch das was das IOSP uns sagen will. Also machen wir das doch einfach mal und erhalten dann (im Request/Response Stil) zwei Klassen: CustomerEarningsIntegrationService und CustomerEarningsOperationService:

 

 

Hier ist Integration und Operation getrennt. Integration enthält keine Kontrollstrukturen (if/else, for, try/catch, etc.) sondern nur Anweisungen. Integration steckt nur Services zusammen (andere Integration Services, Respositories, oder eben Operation Services). Der Operation Service hat keinerlei Abhängigkeiten. Und das darf er auch nicht haben. Damit ist er super leicht zu testen - ohne Mocks. Und der Integration Service braucht maximal einen Integration Test pro Methode. Auch hier braucht man keinerlei Mocks, da man ja die Integration testen will.

 

Das Ganze ist im Request/Response Stil geschrieben. Das finde ich auch absolut ausreichend. Wer möchte kann das Ganze noch steigern und es im CPS Stil schreiben. Dann ist der Integration Teil noch prägnanter, die Methodensignatur der Operation Services wird dann aber sehr Gewöhnungsbedürftig. Man sollte da wohl eher die Stile mixen wo es Sinn macht. Aber hier mal das Beispiel erweitert im CPS Stile:

Wie man sieht wird der Integration Service noch prägnanter. Allerdings kommen für den Operation Service einige Actions und Funcs hinzu die auf den ersten Blick etwas tricky aussehen, an die man sich aber mit ein wenig Übung gewöhnen kann.

 

Fazit:

Mein Fazit ist, dass die bewusste und immer durchgezogene Trennung von Integration und Operation Methoden ganz klar die Vorteile der besseren Testbarkeit von Operations-Methoden hat, da die Operation Services keine weiteren Services oder Repositories injiziert bekommen und damit die Erstellung von Mocks weg fällt. Außerdem sind die Operation Services dadurch beliebig durch Integration-Services nutzbar, woraus quasi direkt folgt dass die Operation Services auch PoMO sind, weil Sie eben keine semantischen Abhängigkeiten zu anderen Operation/Integration Services mehr aufweisen. Ziemlich cool also - genau das was man will. Keine ewiges Mocking mehr und klar strukturierte Klassen. Ein ganz klares Plus zur "normalen" SOLID Architektur.

 

Um es noch formaler zu machen würde ich sogar folgende Regeln festlegen:

  1. Operation Services dürfen keine anderen Integration oder Operation Services injiziert bekommen, sonst würde der Vorteil der besseren Testbarkeit verloren gehen.
  2. Integration Services dürfen andere Integration Services, Operationen Services oder Repositories injiziert bekommen.
  3. Integration Services dürfen aber keine Logik enthalten. Sprich keine if/else, for-Schleifen oder try/catches. Sie sollen nur andere Services zu etwas komplexeren zusammenbauen mittels Anweisungen.
  4. Integration Services dürfen laut 2.) in anderen Integration Services benutzt werden. Empfehlung wäre hier aber: Je flacher die Hierarchie desto verständlicher ist der Code.

Damit hat man echte lose Kopplung und hohe Kohäsion unter Operation Services erreicht und eine gleich gute lose Kopplung (über Interfaces und DIP) bei den Integration Services. Dadurch dass die Integration Services aber so einfach gehalten sind braucht man genau wie bei den Operation Services die keinerlei Abhängigkeiten mehr enthalten keine Mocks mehr. No mocking hell anymore. Genial! 

 

Das Beispiel findet ihr auch bei GitHub.

Kommentar schreiben

Kommentare: 6
  • #1

    Pg.99 (Dienstag, 13 Dezember 2016 08:21)

    Hallo Herr Nieveler,
    wie werden in dieser Architektur die Cross-Cutting Concerns(Logging, Validation, Security, etc.) gehandhabt? Sie schreiben ja das Operation Services keine weiteren Services injeziert bekommen.

    Mfg.

  • #2

    Jörg Nieveler (Dienstag, 13 Dezember 2016 22:25)

    Hallo

    Cross-Cutting-Concerns wie z.B. Logging können relativ einfach über den Operation Service abgehandelt werden, in dem man ihm eine Func<T> in den Konstruktor injected vom Integration Service aus. Der Integration Service bekommt das Logging Framework injected, über ein ILog Interface und dem OperationService gibt man dann einfach eine Lamda Funktion in den Konstruktor alá (data) => this.log.Write(data).

    Validierung würde ich über eine Func<T> in der entsprechenden Methode lösen (nicht über den Konstruktor). Ansonsten wieder das gleiche Schema. Der IntegrationService bekommt den ValidationService injected und gibt dem OperationService eine Lamda Funktion mit, die er bei aufrufen kann um zu validieren.

    Ist mein Punkt rüber gekommen? :)

    Viele Grüße
    Jörg Nieveler

  • #3

    Jörg Nieveler (Dienstag, 13 Dezember 2016 22:27)

    Bei Bedarf kann ich das GitHub Beispiel mal um Logging und Validation erweitern. :)

  • #4

    Pg.99 (Mittwoch, 14 Dezember 2016 08:58)

    Hallo Herr Nieveler,

    danke für die schnelle Antwort. Ja ihr Punkt ist rüber gekommen :)
    Eine Ergänzung im Github Repo wäre sicherlich nicht verkehrt.

    Besten dank :)

  • #5

    Horst (Freitag, 17 Dezember 2021 23:08)

    Ich weiß der Beitrag ist alt, aber ich lese hier nun gerade total ermüdet das x-te schlecht gewählte Beispiel.
    Ein Beispiel ohne eine einzige Verzweigung ist natürlich besonders bequem, hilft nur in der Realität kein bisschen weiter.

  • #6

    Jörg Nieveler (Samstag, 18 Dezember 2021 09:38)

    Hallo Horst

    Danke für deinen Kommentar. Das Beispiel sollte nur die grundsätzliche Idee erklären. IOSP geht auch grundsätzlich mit If-Anweisungen. Allerdings muss man dann unterscheiden welche Art von Ifs man hat. Ifs die eine andere Art der Verarbeitung erfordern - kann man mit DI lösen. Oder ifs die Unterscheidungen in den Operationen machen - kann man ganz unten in den Operation Klassen lösen.

    IOSP geht also auch in der "Realität", man muss sich dann nur gut überlegen welche Arten der Verzweigung eigentlich wohin gehört. Die Integrationen Klassen sollten auf jeden Fall keine Ifs enthalten, weil man sie sonst Unit testen muss.

    Viele Grüße
    Jörg Nieveler