Hexagonal architecture met Java en Gradle

Artikel uit Java magazine 3 2021

Alle code is geschreven volgens impliciete of expliciete richtlijnen. Hoe explicieter en consequenter, hoe eerder je kunt spreken van een daadwerkelijke architectuur, in plaats van een serie van ad hoc beslissingen.

 

Dit artikel gaat in op een architectuur met de naam ‘hexagonal architecture’: de filosofie en een voorbeeld implementatie. De verklaring voor de naam van bedenker Alistair Cockburn [1] is weinig spectaculair. De alternatieve naam ‘ports and adapters’ dekt de lading beter, zoals zal blijken.

 

{ De filosofie van hexagonal architecture }

De kern van waar je als team aan wil werken in je software is het functioneel beoordelen van data en het nemen van besluiten op basis daarvan. Deze kern wordt ook wel de ‘business of domain logic’ genoemd. Een developer moet daarbij zo min mogelijk rekening houden met wat daarvan afleidt, zoals technische randzaken en logica van andere domeinen, die al dan niet de verantwoordelijkheid zijn van andere teams.

 

Veel van de principes uit hexagonal architecture zijn te herleiden tot het SOLID acronym, bijvoorbeeld:

 

  • Single Responsibility Principle: Probeer de diversiteit van oorzaken, waardoor een developer een module aan moet passen, minimaal te houden.
  • Interface Segregation Principle: Probeer voor iedere interactie met een ander systeem een aparte interface te maken.
  • Dependency Inversion Principle: Als een module geen weet hoort te hebben van de implementatie van andere modules, leg dan de verantwoordelijkheid voor het koppelen van implementaties aan interfaces buiten die module.

 

Een aantal mantra’s die hierbij van toepassing zijn:

 

  • ‘Do one thing and do it well.’
  • ‘Loose coupling, high cohesion.’
  • ‘Things that change together should be together.’

 

{ De filosofie in de praktijk}

Met de principes en mantra’s in gedachten zou je bijvoorbeeld het volgende kunnen zeggen over de code die je business logic implementeert:

 

  1. Je hoeft niet te weten dat data via een REST API binnenkomt, hoe REST werkt, hoe HTTP werkt, hoe authenticatie werkt, en welke library er gebruikt is om de payload te transformeren naar een dataformaat dat je intern gebruikt. Als die zaken nodig zijn, dan horen die thuis in een component dat dergelijke interactie met de buitenwereld als kerntaak heeft: een ‘adapter’.
  2. Je hoeft niet te weten dat de bron van de data een ander woord heeft voor de data waar je interesse in hebt. Je domein heeft z’n eigen ‘ubiquitous language’ [2] en als data daarnaartoe moet worden vertaald, dan mag de adapter die jou afschermt van de buitenwereld dat voor je doen.
  3. Je hoeft niet te weten of data opslag via SQL, Hibernate of JPA nodig is. Een adapter die jou afschermt van die implementatiedetails gaat daarover.

 

Dat wil zeggen: de database en de repository layer zijn net zozeer buitenwereld als iets dat je applicatie via een REST API aanroept. En de technologieën benodigd om ermee te interacteren zijn evenmin van belang voor je business logic als de taal die in die buitenwereld gesproken wordt. Iets wijzigen in die technologie zou bovendien moeten kunnen zonder de code in de business logic te raken.

 

{ Visuele weergave }

Stel dat je een applicatie hebt waar je business logic input krijgt door een file te lezen van het file system en output wegschrijft naar de console. Figuur 1 laat zien hoe dat er in een hexagonal architecture uitziet.

Figuur 1: Een eenvoudige applicatie volgens hexagonal architecture.

 

Verderop in dit artikel zal een applicatie worden toegelicht, die deze opzet middels Java en Gradle implementeert.

 

Een geavanceerdere applicatie is uitgewerkt in figuur 2. Hier komt input via HTTP binnen vanaf een website, events komen binnen via een AWS SQS queue, gegevens worden weggeschreven naar een AWS Aurora database en notificaties worden uitgestuurd naar een AWS SNS topic.

 

Figuur 2: Een geavanceerdere applicatie volgens hexagonal architecture.

 

In deze diagrammen komt een duidelijk onderscheid tussen kern en buitenwereld naar voren. Er worden met name twee termen gebruikt om dit duidelijk te maken: Ports en Adapters.

 

Het onderscheid tussen driving en driven adapters is hierbij een optionele aanvulling die verderop kort aan bod komt.

 

{ Hexagonal architecture uitgedrukt in code }

De aanleiding voor dit artikel is een verlangen om in Java code voldoende isolatie af te dwingen tussen domain logic en adapters. Dit kan in UML weergegeven worden zoals in figuur 3. Met Java package en access modifiers kan dit niet bereikt worden. ArchUnit [3] kan het eventueel bewaken, maar niet bij voorbaat afdwingen. Gradle draagt zorg voor dependency management op zo’n manier dat je wel de juiste afscherming bereikt.

Figuur 3: Een ruwe weergave van de belangrijkste classes en modules in UML.

 

In lichtgrijs is de MagicDomainLogicService afgebeeld. Dit is om aan te geven dat dit de kern van de applicatie is: hier zit de domeinspecifieke logica, en die is voor geen van de classes en modules erbuiten te bereiken, anders dan via de ports.

 

Hieronder volgt een uitleg van de code die deze architectuur implementeert, waarbij de getoonde modules middels een Gradle multi-project zijn gerealiseerd.

 

De code is te vinden op GitHub [4].

 

{ Business logic: Core }

De pure logica uniek voor het domein zit in deze module. Deze logica wordt getriggerd door een ‘driving adapter’ zonder te weten van hoe die adapter werkt. En de logica kan iets via een driven adapter verwezenlijken in de buitenwereld, zonder te weten hoe die adapter dat doet.

 

De file /core/build.gradle.kts laat zien dat het alleen afhankelijk is van de ports. Dit ligt in de lijn der verwachting met figuur 3.

 

De service in deze package, MagicDomainLogicService, kan bereikt worden via een MagicDomainLogicPort en kan een opdracht uitsturen via een PersistencePort. Dit zijn Java interfaces die in de volgende module zijn gedefinieerd.

 

{ Ports }

In /ports/build.gradle.kts is te zien dat de ports module nergens van afhankelijk is.

 

Deze module bevat een MagicDomainLogicPort waarlangs een driving adapter data kan aanleveren aan de business logic via een DomainData object, die ook in deze module leeft.

 

{ Driving adapter: Input Handler }

Er is een driving adapter die input uitleest, omvormt naar een DomainData object en via de desbetreffende port deze data aangeeft aan de business logic. Deze input handler kent dus de Java interface van de port en het data object, zoals te zien is in /adapter-input-handler/build.gradle.kts

 

Welke business logic leeft achter deze port, weet deze driving adapter niet! Die dependency injection wordt niet geregeld in de adapter module, maar in de application module, zoals verderop beschreven.

 

{ Driven adapter: Persistence }

Er is een driven adapter die data opslaat. Deze doet dat niet zomaar; het reageert op instructie van de business logic en implementeert daarom de PersistencePort via een eigen SystemOutPersistence class.

 

/adapter-persistence/build.gradle.kts toont aan dat ook deze adapter alleen een dependency heeft op de domain logic uit de core module.

 

{ Bootstrappen van de applicatie }

De application module heeft de verantwoordelijkheid om de core, ports en adapters – het hexagon – bij elkaar te brengen. Dit door de dependencies te injecteren van de ports waarmee de adapters en business logic met elkaar zullen communiceren.

 

De main method leeft hier en doet de expliciete dependency injection.

 

{ De modules aan elkaar geknoopt }

De applicatie bevat een core, ports, twee adapters en een module om deze te bootstrappen. MrHaki beschrijft in zijn blog [5] een manier om dit in Gradle via buildSrc op te zetten. De modules van de applicatie staan opgesomd in /settings.gradle.kts

 

{ Isolatie }

Om te toetsen of jet met de code opzet de gewenste isolatie bereikt volgens de eerdergenoemde principes van hexagonal architecture, zou je als developer in bijvoorbeeld IntelliJ niet bij classes en methods moeten kunnen die volgens het architectuurdiagram in Figuur 3 ontoegankelijk zijn.

 

Om te demonstreren hoe de scheiding van adapters en de business logic werkt, zit er een SuperHandyUtil class in de persistence adapter. Deze class heeft een method om een DomainData object, afkomstig uit de ports module, te valideren en de velden uit dit object te lezen en geconcateneerd terug te geven.

 

Een developer die aan deze applicatie werkt, zal later wellicht besluiten dat deze functionaliteit goed van pas komt in een andere adapter, of in de business logic. Als de opzet geslaagd is en de gezochte isolatie een feit, dan is het niet mogelijk om deze class aan te roepen terwijl je in de code van een andere adapter of van de core domain logic werkt. En dat is goed. Want de manier waarop de handige validatie en concatenatie van de SuperHandyUtil gebeurt, moet binnen de scope van de persistence adapter kunnen wijzigen, zonder gevolgen voor een andere adapter of de core business logic.

 

En inderdaad: In IntelliJ is buiten het persistence adapter Gradle subproject de SuperHandyUtil class niet beschikbaar.

 

Quod erat demonstrandum, ofwel: het bewijs is geleverd.

 

Referenties

  1. Alistair Cockburn’s artikel over hexagonal architecture: https://web.archive.org/web/20180822100852/http://alistair.cockburn.us/Hexagonal+architecture
  2. Ubiquitous Language en andere DDD begrippen uit het boek van Erik Evans: https://en.wikipedia.org/wiki/Domain-driven_design
  3. ArchUnit, een framework om via automated tests de architectuur van je applicatie te bewaken: https://www.archunit.org/
  4. De code waar dit artikel aan refereert: https://github.com/jasperbogers/hexagonal-architecture-gradle-java
  5. Hubert Klein Ikkink (“MrHaki”) over shared configuration in Gradle: https://blog.jdriven.com/2021/02/gradle-goodness-shared-configuration-with-conventions-plugin/

 

De kern van werken als team: data functioneel beoordelen en daarop besluiten nemen.

Jasper Bogers is architect, developer en agile consultant bij JDriven en Infuze Consulting. Hij houdt zich graag bezig met de sociale aspecten van software development.