Vom Spring Starter zur hexagonalen Architektur mit Kotlin

Daniel Schock
11 Mar 2026

Einleitung

In der modernen Softwareentwicklung wird häufig mit monolithischen Strukturen begonnen, um schnell sichtbare Ergebnisse zu erzielen. Startpunkt ist oft ein einfaches Spring Boot Projekt, generiert über Initializer wie start.spring.io. Mit zunehmender Komplexität und wachsenden Anforderungen stoßen diese Strukturen jedoch an ihre Grenzen: Die Wartbarkeit sinkt, die Testbarkeit wird erschwert und technische Abhängigkeiten vermischen sich untrennbar mit der fachlichen Logik.

Eine Lösung für diese Herausforderungen bietet die hexagonale Architektur (auch bekannt als „Ports and Adapters“). Durch sie wird die Geschäftslogik ins Zentrum der Anwendung gerückt und von äußeren Einflüssen wie Datenbanken, Benutzeroberflächen oder externen APIs isoliert.

Dieser Artikel beschreibt die schrittweise Migration eines klassischen Spring Boot Monolithen hin zu einer modularisierten, hexagonalen Architektur unter Verwendung von Kotlin und Gradle. Es wird aufgezeigt, wie durch klare Modulgrenzen und definiertes Dependency Management eine langlebige und flexible Software-Architektur entsteht.

Zur besseren Nachvollziehbarkeit lassen sich die einzelnen Abschnitte Commit für Commit im GitHub-Repository nachvollziehen, der Zustand nach jedem Kapitel ist mit einem Git-Tag markiert.

Kapitel 0: Der Start

Als Ausgangszustand dient ein frisch generiertes Projekt von start.spring.io, welches das typische „Vorher“-Bild einer Anwendung darstellt.

  1. Es wird zu start.spring.io navigiert.
  2. Folgende Konfiguration wird ausgewählt:
    • Project: Gradle – Kotlin
    • Language: Kotlin
    • Spring Boot: (eine aktuelle Version, z.B. 3.5.x)
    • Java: 21
    • Dependencies:
      • Spring Web
      • Spring Data JPA
      • H2 Database
      • Spring Boot DevTools (optional)
      • Spring Configuration Processor (optional)
  3. Das Projekt wird generiert, heruntergeladen und in der IDE geöffnet.

Es zeigt sich die typische, flache Spring-Boot-Struktur: Alle Komponenten befinden sich in src/main/kotlin unter einem Hauptpaket.

Diese Struktur eignet sich ideal für einen schnellen Start. Nachteile werden jedoch sichtbar, sobald das Projekt wächst:

  • Keine Trennung von Belangen: Web-Logik (Controller), Business-Logik (Services) und Datenbank-Logik (Repositories, Entities) sind eng miteinander verwoben.
  • Schwere Testbarkeit: Das Testen echter Business-Logik ohne das Hochfahren des Webservers oder das Mocken einer Datenbank gestaltet sich kompliziert.
  • Technologie-Kopplung: Die Business-Logik ist untrennbar mit Spring-Annotationen (@Service, @Entity) und JPA verbunden.

Kapitel 1: Der Monolith

Um eine Refactoring-Basis zu schaffen, wird zunächst eine funktionierende, wenn auch architektonisch einfach strukturierte Anwendung aufgebaut. Als Beispiel dient ein simples „Produkt“, welches wir über REST-API in einer Datenbank ansprechen können. Folgendes Diagramm veranschaulicht die Zielsetzung.

Kotlin
classDiagram
    direction RL
    class Product {
        <<Entity / data class>>
        +Long? id
        +String name
        +Double price
    }
    class ProductsRepository {
        <<interface>>
    }
    class ProductsController {
        <<RestController>>
        -ProductsRepository repository
        +list() List~Product~
        +create(Product product) Product
        +get(Long id) Product?
        +update(Long id, Double discount) Product?
    }
    ProductsRepository ..> Product : manages
    ProductsController --> ProductsRepository : repository
    ProductsController ..> Product : uses

Zuerst wird die Product.kt Klasse erstellt. Sie fungiert gleichzeitig als @Entity und data class – eine Klasse, die sämtliche Verantwortlichkeiten bündelt:

Kotlin
// src/main/kotlin/consulting/atra/hexagon/Product.kts

package consulting.atra.hexagon  
import jakarta.persistence.Entity  
import jakarta.persistence.GeneratedValue  
import jakarta.persistence.Id  

@Entity  
data class Product(  
    @Id  
    @GeneratedValue
    var id: Long? = null,  
    var name: String,  
    var price: Double,
)

Als Nächstes folgt das ProductsRepository.kt, definiert als einfaches Spring Data JpaRepository:

Kotlin
// src/main/kotlin/consulting/atra/hexagon/ProductsRepository.kt

package consulting.atra.hexagon  
import org.springframework.data.jpa.repository.JpaRepository  
interface ProductsRepository: JpaRepository<Product, Long>

Zuletzt wird der ProductsController.kt implementiert. Dieser @RestController injiziert das ProductsRepository direkt und verwendet das Product-Entity sowohl als Request- als auch als Response-Objekt:

Kotlin
// src/main/kotlin/consulting/atra/hexagon/ProductsController.kt
package consulting.atra.hexagon  
import org.springframework.web.bind.annotation.GetMapping  
import org.springframework.web.bind.annotation.PatchMapping  
import org.springframework.web.bind.annotation.PathVariable  
import org.springframework.web.bind.annotation.PostMapping  
import org.springframework.web.bind.annotation.RequestBody  
import org.springframework.web.bind.annotation.RequestMapping  
import org.springframework.web.bind.annotation.RequestParam  
import org.springframework.web.bind.annotation.RestController  
@RestController  
@RequestMapping("/api/v1/products")  
class ProductsController(  
    private val repository: ProductsRepository,  
) {  
    @GetMapping  
    fun list(): List<Product> = repository.findAll()  
    @PostMapping  
    fun create(@RequestBody product: Product): Product =
repository.save(product)  
    @GetMapping("/{id}")  
    fun get(@PathVariable id: Long): Product? =
repository.findById(id).orElse(null)  
    @PatchMapping("/{id}")  
    fun update(@PathVariable id: Long, @RequestParam discount: Double): Product? {  
        var product = repository.findById(id).orElse(null)  
        if (product != null) {  
            product.price *= (1 - discount / 100)  
            product = repository.save(product)  
        }  
        return product  
    }  
}

Wird die Anwendung gestartet und die API aufgerufen, ist die Funktionalität gegeben.

Das Problem hierbei ist jedoch ersichtlich: Der Controller gibt die Datenbank-Entität (Product) direkt an den Client weiter. Jede Änderung an der Datenbankstruktur (z.B. das Hinzufügen einer internen Spalte) stellt sofort einen „Breaking Change“ für die API dar. Die API ist somit ein direktes Abbild der Datenbank.

Kapitel 2: Die neue Struktur

Kotlin
graph TD
    direction TB
    Application[":application"]
    Domain[":domain"]
    Endpoint[":adapters:endpoint"]
    Persistence[":adapters:persistence"]
    Application --> Domain
    Application --> Endpoint
    Application --> Persistence
    Endpoint --> Domain
    Persistence --> Domain

Nun wird das Fundament für die logische Trennung gelegt. Das einfache Projekt wird in ein Multi-Modul-Projekt umgewandelt.

Die Idee ist es, die Geschäftslogik, die eingehende und die ausgehende Kommunikation des Monolithen zu trennen und in einzelne Gradle-Module zu verlagern. Folgende Dateistruktur dient als Zielbild der Umstrukturierung:

Kotlin
.
├── adapters
│   ├── endpoint
│   │   └── build.gradle.kts
│   └── persistence
│       └── build.gradle.kts
├── application
│   ├── src
│   └── build.gradle.kts
├── domain
│   ├── src
│   └── build.gradle.kts
├── gradle
│   ├── wrapper
│   │   ├── gradle-wrapper.jar
│   │   └── gradle-wrapper.properties
│   └── libs.versions.toml
├── build.gradle.kts
├── CHANGELOG.md
├── CONTRIBUTING.md 
├── .editorconfig
├── .gitattributes
├── .gitignore
├── gradle.properties
├── gradlew
├── gradlew.bat
├── LICENSE
├── README.md
└── settings.gradle.kts

Damit die Migrationsgrundlage erhalten bleibt, wird der src-Ordner zunächst in den zuvor erstellten application-Ordner verschoben.

Kapitel 2.1: settings.gradle.kts

Die settings.gradle.kts erfordert nun Aufmerksamkeit, da hier die Module definiert und die Grundlage eines zentralen Plugin- und Dependency-Managements geschaffen wird. Bisher war lediglich der Projektname hinterlegt.

Kotlin
rootProject.name = "hexagon"

// ...

Es wird ein pluginManagement-Block für die zentral definierte Plugin-Auflösung ergänzt:

Kotlin
// ...

pluginManagement {  
    repositories {  
        gradlePluginPortal()  
    }  
}

// ...

Die Auflösung der Dependencies wird durch den dependencyResolutionManagement-Block gesteuert. Das Überschreiben der Repositories durch Module kann verhindert werden, indem der RepositoriesMode auf FAIL_ON_PROJECT_REPOS gesetzt wird. Da Gradle bei der Nutzung von repositories und repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) im dependencyResolutionManagement-Block eine Warnung bezüglich UnstableApiUsage ausgibt, wird diese mit @Suppress(„UnstableApiUsage“) bewusst unterdrückt.

Kotlin
// ...

@Suppress("UnstableApiUsage")  
dependencyResolutionManagement {  
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)  
    repositories {  
        mavenCentral()  
    }  
}

// ...

Mit einem include-Statement wird die settings.gradle.kts für ein modulares Gradle-Projekt vorbereitet. Hier werden die Module hinterlegt, wobei es für Gradle zu diesem Zeitpunkt unerheblich ist, ob die Ordner bereits existieren oder dazugehörige build.gradle.kts-Dateien enthalten sind.

Kotlin
// ...
include(  
    ":application",  
    ":domain",  
    ":adapters:endpoint",  
    ":adapters:persistence",  
)

Die fertige settings.gradle.kts stellt sich damit wie folgt dar:

Kotlin
// settings.gradle.kts

rootProject.name = "hexagon"  

pluginManagement {  
    repositories {  
        gradlePluginPortal()  
    }  
}  

@Suppress("UnstableApiUsage")  
dependencyResolutionManagement {  
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)  
    repositories {  
        mavenCentral()  
    }  
}  

include(  
    ":application",  
    ":domain",  
    ":adapters:endpoint",  
    ":adapters:persistence",  
)

Kapitel 2.2: build.gradle.kts

Das Herzstück der modularen Konfiguration bildet die build.gradle.kts. Hier werden projektweit gemeinsame Abhängigkeiten definiert und Gradle-Plugins konfiguriert.

Um die Versionen der verwendeten Gradle-Plugins zentral zu steuern, werden diese mit Versionsnummer projektweit in der build.gradle.kts definiert, die Verwendung jedoch gleichzeitig mit einem apply false verhindert. Dies ist notwendig, da Gradle die Plugins sonst im rootProject hinterlegen und ausführen würde. Da unterschiedliche Module unterschiedliche Konfigurationen benötigen, muss gezielt kontrolliert werden, welches Modul welches Plugin erhält.

Kotlin
plugins {  
    kotlin("jvm") version "1.9.25" apply false  
    kotlin("plugin.spring") version "1.9.25" apply false  
    id("org.springframework.boot") version "3.5.7" apply false  
    id("io.spring.dependency-management") version "1.1.7" apply false  
    kotlin("plugin.jpa") version "1.9.25" apply false  
}
// ...

Um gemeinsame Konfigurationen auf die Module anzuwenden, bietet Gradle unter anderem die Blöcke allprojects und subprojects an. allprojects konfiguriert alle Module, darunter auch das rootProject, während subprojects nur die untergeordneten Module betrifft. Für allprojects werden lediglich group und version gesetzt.

Kotlin
// ...
allprojects {  
    group = "consulting.atra.hexagon"  
    version = "0.0.0"  
}
// ...

Für weitere grundlegende Modul-Konfigurationen wird der subprojects-Block verwendet. Hier werden unter anderem die Compiler-Settings, die Java-Version und Dependencies festgelegt. Zuerst werden die Plugins geladen, die das Grund-Setup der Module benötigt.

  • java-library schaltet grundsätzliche Java-Compiler-Features frei; so kann die Java-Version erst mit diesem Plugin gesetzt werden. Darüber hinaus können mit dem Plugin kompatible Abhängigkeiten unter den einzelnen Modulen erreicht werden.
  • org.jetbrains.kotlin.jvm stellt den Kotlin-Compiler bereit und ermöglicht dadurch die Verwendung von Kotlin in der JVM.
  • org.springframework.boot bietet viele Funktionen rund um das Spring-Boot-Framework; vom Starten der Anwendung bis hin zum Bauen eines Images steht alles mit diesem Plugin zur Verfügung.
  • org.jetbrains.kotlin.plugin.spring erweitert den Kotlin-Compiler um benötigte spring boot Features.
  • io.spring.dependency-management wird für die Abhängigkeitsverwaltung verwendet. Im nächsten Kapitel wird dieses Plugin durch ein Gradle-eigenes Feature ersetzt.
Kotlin
// ...

subprojects {
    apply {  
        plugin("java-library")  
        plugin("org.jetbrains.kotlin.jvm")  
        plugin("org.springframework.boot")  
        plugin("org.jetbrains.kotlin.plugin.spring"
        plugin("org.jetbrains.kotlin.plugin.jpa"
        plugin("io.spring.dependency-management")  
    }

    // ...
}

Bevor Abhängigkeiten definiert werden, verlangt Spring Boot das Erweitern der DependencyConfiguration compileOnly von annotationProcessor. DependencyConfigurations sind ClassPath-Container, in denen kompilierte Artefakte hinterlegt sind. Dadurch können Abhängigkeiten für einzelne Zwecke selektiv verwendet werden. So ist es beispielsweise möglich, für Tests andere Bibliotheken zu nutzen als für die eigentliche Implementierung, oder Bibliotheken nur während der RunTime zu verwenden, die zum Kompilieren nicht benötigt werden.

Kotlin
// ...

subprojects {
    // ...
    val annotationProcessor by configurations  
    val compileOnly by configurations  
    val implementation by configurations  
    val testImplementation by configurations  
    val testRuntimeOnly by configurations  

    configurations {  
        compileOnly.extendsFrom(annotationProcessor)  
    }  

    dependencies {  
        implementation("org.jetbrains.kotlin:kotlin-reflect")
        testImplementation("org.springframework.boot:spring-boot-starter-test")  
        testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")  
        testRuntimeOnly("org.junit.platform:junit-platform-launcher")  
    }

    // ...
}

Anschließend werden die Extensions konfiguriert. Gradle erlaubt es, die DSL über Extensions zu erweitern. Soll diese in subprojects oder allprojects konfiguriert werden, muss dies über die configure<?>-Anweisung geschehen. Für Spring Boot und Kotlin müssen die JavaLanguageVersion in der JavaPluginExtension, die compilerOptions in der KotlinJvmProjectExtension und JPA-relevante Settings in der AllOpenExtension konfiguriert werden.

Kotlin
// ...

subprojects {

    // ...

    configure<JavaPluginExtension> {  
        toolchain {  
            languageVersion = JavaLanguageVersion.of(21)  
        }
    }  
    
    configure<KotlinJvmProjectExtension> {  
        compilerOptions {  
            freeCompilerArgs.add("-Xjsr305=strict")  
        }  
    }
  
    configure<AllOpenExtension> {  
        annotation("jakarta.persistence.Entity")  
        annotation("jakarta.persistence.MappedSuperclass")  
        annotation("jakarta.persistence.Embeddable")  
    }

    // ...
}

Zu guter Letzt werden die Tasks der subprojects konfiguriert. In diesem Fall wird  die Nutzung der JUnitPlatform für Tasks des Typs Test angewiesen und der BootJar-Task deaktiviert, da ansonsten das Spring-Boot-Plugin in allen konfigurierten Modulen eine Main-Methode sucht, aber nicht findet.

Kotlin
// ...

subprojects {

    // ...
    tasks {  
        withType<Test> {  
            useJUnitPlatform()  
        }  
        withType<BootJar> {  
            enabled = false  
        }
    }  
}

Damit sieht die vollständige build.gradle.kts wie folgt aus:

Kotlin
// build.gradle.kts

import org.jetbrains.kotlin.allopen.gradle.AllOpenExtension  
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension  

plugins {  
    kotlin("jvm") version "1.9.25" apply false  
    kotlin("plugin.spring") version "1.9.25" apply false  
    id("org.springframework.boot") version "3.5.7" apply false  
    id("io.spring.dependency-management") version "1.1.7" apply false  
    kotlin("plugin.jpa") version "1.9.25" apply false  
}  

allprojects {  
    group = "consulting.atra.hexagon"  
    version = "0.0.0"  
}  

subprojects {  
    apply {  
        plugin("java-library")  
        plugin("org.jetbrains.kotlin.jvm")  
        plugin("org.springframework.boot")  
        plugin("org.jetbrains.kotlin.plugin.spring")  
        plugin("io.spring.dependency-management")  
    }  
    val annotationProcessor by configurations  
    val compileOnly by configurations  
    val implementation by configurations  
    val testImplementation by configurations  
    val testRuntimeOnly by configurations  
    configurations {  
        compileOnly.extendsFrom(annotationProcessor)  
    }  
    dependencies {  
        implementation("org.jetbrains.kotlin:kotlin-reflect")  
        testImplementation("org.springframework.boot:spring-boot-starter-test")  
        testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")  
        testRuntimeOnly("org.junit.platform:junit-platform-launcher")  
    }  
    configure<JavaPluginExtension> {  
        toolchain {  
            languageVersion = JavaLanguageVersion.of(21)  
        }  
    }  
    configure<KotlinJvmProjectExtension> {  
        compilerOptions {  
            freeCompilerArgs.add("-Xjsr305=strict")  
        }  
    }  
    configure<AllOpenExtension> {  
        annotation("jakarta.persistence.Entity")  
        annotation("jakarta.persistence.MappedSuperclass")  
        annotation("jakarta.persistence.Embeddable")  
    }  
    tasks {  
        withType<Test> {  
            useJUnitPlatform()  
        }

        withType<BootJar> {  
            enabled = false  
        }  
    }  
}

Kapitel 2.3: application/build.gradle.kts

Da die build.gradle.kts des Hauptordners bereits umfangreich konfiguriert wurde, kommen die einzelnen Module mit einer minimalen Konfiguration aus.

Kotlin
// application/build.gradle.kts

plugins {  
    id("application")  
    id("org.springframework.boot")  
    id("io.spring.dependency-management")  
}  

dependencies {  
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")  
    implementation("org.springframework.boot:spring-boot-starter-web")  
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")  
    developmentOnly("org.springframework.boot:spring-boot-devtools")  
    runtimeOnly("com.h2database:h2")  
    annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")  
}

Kapitel 3: Die Abhängigkeiten

Um das Verwalten von Abhängigkeiten noch leichter zu gestalten und das io.spring.dependency-management-Plugin abzulösen, kann ein Gradle Version Catalog verwendet werden. Dazu wird die Datei gradle/libs.versions.toml erstellt. Die Katalog-Datei gliedert sich in 4 relevante Segmente:

  • [versions]: Definiert die Versionen über einen eindeutigen Schlüssel. Versionen können über den Schlüssel von [libraries] und [plugins] referenziert werden.
  • [libraries]: Bibliotheken mit einer eindeutigen Referenz. Dies wird zum Laden der BOMs verwendet, um nicht alle Bibliotheken selber verwalten zu müssen.
  • [plugins]: Gradle-Plugins, welche über eine eindeutige Referenz in den Modulen verwendet werden können.
  • [bundles]: Kollektion von [libraries], ermöglicht es, gemeinsam auftretende Bibliotheken gebündelt zu definieren.

Kotlin
# gradle/libs.versions.toml
[versions]  
kotlin = "1.9.25"  
spring-boot = "3.5.7"  
[libraries]  
bom-spring-boot = { group = "org.springframework.boot", name = "spring-boot-dependencies", version.ref = "spring-boot" }  
h2 = { group = "com.h2database", name = "h2"}  
junit-platform = { group = "org.junit.platform", name = "junit-platform-launcher" }  
kotlin-jackson = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin" }  
kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect" }  
kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test-junit5" }  
spring-boot = { group = "org.springframework.boot", name = "spring-boot-starter" }  
spring-boot-configuration = { group = "org.springframework.boot", name = "spring-boot-configuration-processor" }  
spring-boot-devtools = { group = "org.springframework.boot", name = "spring-boot-devtools" }  
spring-boot-test = { group = "org.springframework.boot", name = "spring-boot-starter-test" }  
spring-boot-data-jpa = { group = "org.springframework.boot", name = "spring-boot-starter-data-jpa" }  
spring-boot-validation = { group = "org.springframework.boot", name = "spring-boot-starter-validation" }  
spring-boot-web = { group = "org.springframework.boot", name = "spring-boot-starter-web" }  

[plugins]  
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }  
kotlin-jpa = { id = "org.jetbrains.kotlin.plugin.jpa", version.ref = "kotlin" }  
kotlin-spring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin" }  
spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" }  

[bundles]  
spring-boot-web = ["spring-boot-validation", "spring-boot-web", "kotlin-jackson"]  
spring-boot-test = ["spring-boot-test", "kotlin-test"]

Durch die Nutzung des Version Catalog fällt eine Anpassung der build.gradle.kts an. Zum einen wird die Version-Catalog-Referenz für plugins und dependencies genutzt, zum anderen wird das zuvor erwähnte io.spring.dependency-management-Plugin entfernt. Weiterhin müssen die BOMs in alle DependencyConfiguration geladen werden, damit Versionen, die von einer BOM verwaltet werden, auch referenziert werden können. Plugins werden über die alias()-Anweisung definiert, wobei zu beachten ist, dass diese in subprojects und allprojects so nicht zur Verfügung steht. BOMs müssen einzeln, nicht als [bundles], über die platform()-Anweisung in jeder genutzten DependencyConfiguration hinterlegt werden.

Kotlin
// ...
plugins {  
    alias(libs.plugins.kotlin.jvm) apply false  
    alias(libs.plugins.kotlin.spring) apply false  
    alias(libs.plugins.kotlin.jpa) apply false  
    alias(libs.plugins.spring.boot) apply false  
}

// ...
subprojects {
    // ...
    val annotationProcessor by configurations  
    val compileOnly by configurations  
    val developmentOnly by configurations  
    val implementation by configurations  
    val testImplementation by configurations  
    val testRuntimeOnly by configurations  
    // ... 
    dependencies {  
        annotationProcessor(platform(rootProject.libs.bom.spring.boot))  
        compileOnly(platform(rootProject.libs.bom.spring.boot))  
        developmentOnly(platform(rootProject.libs.bom.spring.boot))  
        implementation(platform(rootProject.libs.bom.spring.boot))  
        testImplementation(platform(rootProject.libs.bom.spring.boot))  
        testRuntimeOnly(platform(rootProject.libs.bom.spring.boot))  
        implementation(rootProject.libs.kotlin.reflect)  
        testImplementation(rootProject.libs.bundles.spring.boot.test)  
        testRuntimeOnly(rootProject.libs.junit.platform)  
    }
    // ...
}

Die Leichtigkeit des Gradle Version Catalogs fällt erst richtig bei den einzelnen Modulen auf. Die build.gradle.kts des application-Moduls wird mit den neuen Änderungen noch schlanker.

Kotlin
// application/build.gradle.kts

plugins {  
    id("application")  
    alias(libs.plugins.spring.boot)  
    alias(libs.plugins.kotlin.jpa)  
}  

dependencies {  
    implementation(libs.spring.boot.data.jpa)  
    implementation(libs.bundles.spring.boot.web)  
    developmentOnly(libs.spring.boot.devtools)  
    runtimeOnly(libs.h2)  
    annotationProcessor(libs.spring.boot.configuration)  
}

Kapitel 4: Die Domain

Kotlin
classDiagram
    class Product {
        <<data class>>
        +Long? id
        +String name
        +Double price
        +applyDiscount(Double discount)
    }

    class FindAllProductsUseCase {
        <<interface>>
        +findAllProducts() List~Product~
    }

    class FindProductByIdUseCase {
        <<interface>>
        +findProductById(Long id) Product?
    }

    class CreateProductUseCase {
        <<interface>>
        +createProduct(Product product) Product
    }
 
   class ApplyDiscountToProductUseCase {
        <<interface>>
        +applyDiscountToProduct(Long id, Double discount) Product
    }

    class SaveProductUseCase {
        <<interface>>
        +saveProduct(Product product) Product
    }

    class ProductsEndpointPort {
        <<interface>>
    }

    class ProductsPersistencePort {
        <<interface>>
    }

    ProductsEndpointPort --|> FindAllProductsUseCase
    ProductsEndpointPort --|> FindProductByIdUseCase
    ProductsEndpointPort --|> CreateProductUseCase
    ProductsEndpointPort --|> ApplyDiscountToProductUseCase
    ProductsPersistencePort --|> FindAllProductsUseCase
    ProductsPersistencePort --|> FindProductByIdUseCase
    ProductsPersistencePort --|> SaveProductUseCase
    FindAllProductsUseCase ..> Product : uses
    FindProductByIdUseCase ..> Product : uses
    CreateProductUseCase ..> Product : uses
    ApplyDiscountToProductUseCase ..> Product : uses
    SaveProductUseCase ..> Product : uses

Das domain-Modul bildet den Kern der hexagonalen Architektur. Hier residiert die reine Geschäftslogik, vollständig entkoppelt von technischen Frameworks oder Infrastruktur-Details. Gradle-seitig ist für dieses Modul keine weitere Konfiguration notwendig, da es lediglich auf der Standard-Kotlin-Konfiguration aus dem Root-Projekt aufbaut. Es wird bewusst darauf verzichtet, Abhängigkeiten zu Frameworks wie Spring Boot hinzuzufügen.

Das zentrale Domänenmodell wird durch die Klasse Product repräsentiert. Im Gegensatz zur vorherigen Implementierung ist dies nun eine reine Kotlin-Klasse ohne JPA-Annotationen, angereichert mit fachlicher Logik wie der Preiskalkulation.

Kotlin
// domain/src/main/kotlin/consulting/atra/hexagon/domain/Product.kt

package consulting.atra.hexagon.domain  

data class Product(  
    val id: Long? = null,  
    var name: String,  
    var price: Double,  
) {  
    fun applyDiscount(discount: Double) {  
        price *= (1 - discount / 100)  
    }  
}

Die Interaktion mit der Domäne wird über Use Cases definiert. Diese Interfaces beschreiben, was mit der Domäne getan werden kann, ohne vorzuschreiben, wie dies technisch umgesetzt wird.

Kotlin
/ domain/src/main/kotlin/consulting/atra/hexagon/domain/ProductUseCases.kt

package consulting.atra.hexagon.domain  

interface FindAllProductsUseCase {  
    fun findAllProducts(): List<Product>  
}  

interface FindProductByIdUseCase {  
    fun findProductById(id: Long): Product?  
}  

interface CreateProductUseCase {  
    fun createProduct(product: Product): Product  
}  

interface ApplyDiscountToProductUseCase {  
    fun applyDiscountToProduct(id: Long, discount: Double): Product  
}  

interface SaveProductUseCase {  
    fun saveProduct(product: Product): Product  
}

Um die Kommunikation nach außen zu ermöglichen, werden Ports definiert. Der ProductsEndpointPort fungiert als „Driving Port“ (Eingang), der von der Außenwelt (z.B. REST API) aufgerufen wird. Der ProductsPersistencePort dient als „Driven Port“ (Ausgang), über den die Domäne Daten speichert oder lädt, ohne die konkrete Datenbanktechnologie zu kennen.

Kotlin
// domain/src/main/kotlin/consulting/atra/hexagon/domain/ProductPorts.kt

package consulting.atra.hexagon.domain  

interface ProductsEndpointPort :  
    FindAllProductsUseCase,  
    FindProductByIdUseCase,  
    CreateProductUseCase,  
    ApplyDiscountToProductUseCase  

interface ProductsPersistencePort :  
    FindAllProductsUseCase,  
    FindProductByIdUseCase,  
    SaveProductUseCase

Kapitel 5: Der Eingangs-Adapter

Kotlin
classDiagram
    class ProductsEndpoint {
        <<interface>>
        +list() List~ProductResponse~
        +create(ProductRequest product) ProductResponse
        +get(Long id) ProductResponse?
        +update(Long id, Double discount) ProductResponse?
    }

    class ProductsEndpointAdapter {
        <<RestController>>
        -ProductsEndpointPort port
        +list() List~ProductResponse~
        +create(ProductRequest product) ProductResponse
        +get(Long id) ProductResponse?
        +update(Long id, Double discount) ProductResponse?
    }

    class ProductsEndpointPort {
        <<interface / from domain>>
    }

    class ProductRequest {
        <<data class>>
        +String name
        +Double price
    }

    class ProductResponse {
        <<data class>>
        +Long id
        +String name
        +Double price
    }

    class Product {
        <<data class / from domain>>
    }

    ProductsEndpointAdapter ..|> ProductsEndpoint : implements
    ProductsEndpointAdapter --> ProductsEndpointPort : uses port
    ProductsEndpointAdapter ..> ProductRequest : receives
    ProductsEndpointAdapter ..> ProductResponse : returns
    ProductsEndpointAdapter ..> Product : maps to/from

Der Eingangs-Adapter (Driving Adapter) ist verantwortlich für die Kommunikation von außen in die Anwendung hinein. In diesem Fall handelt es sich um einen REST-Controller. Das Modul adapters:endpoint erhält Zugriff auf das domain-Modul sowie auf die benötigten Web-Bibliotheken.

Kotlin
// adapters/endpoint/build.gradle.kts

dependencies {  
    implementation(project(":domain"))  
    implementation(libs.bundles.spring.boot.web)  
}

Um die API-Schicht sauber von der Domäne zu trennen, werden separate Data Transfer Objects (DTOs) für Requests und Responses definiert. Dies verhindert, dass Änderungen an der internen Domänenstruktur ungewollt die externe API beeinflussen.

Kotlin
// adapters/endpoint/src/main/kotlin/consulting/atra/hexagon/adapters/endpoint/ProductRequest.kt

package consulting.atra.hexagon.adapters.endpoint  

data class ProductRequest(  
    var name: String
    var price: Double,  
)

Kotlin
// adapters/endpoint/src/main/kotlin/consulting/atra/hexagon/adapters/endpoint/ProductResponse.kt

package consulting.atra.hexagon.adapters.endpoint  

data class ProductResponse(  
    var id: Long,  
    var name: String,  
    var price: Double,  
)

Die Konvertierung zwischen DTOs und Domänen-Objekten erfolgt durch explizite Mapping-Funktionen.

Kotlin
// adapters/endpoint/src/main/kotlin/consulting/atra/hexagon/adapters/endpoint/ProductMapping.kt

package consulting.atra.hexagon.adapters.endpoint  

import consulting.atra.hexagon.domain.Product  

fun Product.toResponse() =  
    ProductResponse(  
        id = id!!,  
        name = name,  
        price = price,  
    )  

fun ProductRequest.toDomain() =  
    Product(  
        name = name,  
        price = price,  
    )  

fun List<Product>.toResponses() =  
    map { it.toResponse() }

Die Schnittstelle des REST-Controllers wird zunächst als Interface definiert, um die technische Definition der Endpunkte von der Implementierung zu trennen.

Kotlin
// adapters/endpoint/src/main/kotlin/consulting/atra/hexagon/adapters/endpoint/ProductsEndpoint.kt

package consulting.atra.hexagon.adapters.endpoint  

import org.springframework.web.bind.annotation.GetMapping  
import org.springframework.web.bind.annotation.PatchMapping  
import org.springframework.web.bind.annotation.PathVariable  
import org.springframework.web.bind.annotation.PostMapping  
import org.springframework.web.bind.annotation.RequestBody  
import org.springframework.web.bind.annotation.RequestMapping  
import org.springframework.web.bind.annotation.RequestParam  

@RequestMapping("/api/v1/products")  
interface ProductsEndpoint {  

    @GetMapping  
    fun list(): List<ProductResponse>  

    @PostMapping  
    fun create(@RequestBody product: ProductRequest): ProductResponse  

    @GetMapping("/{id}")  
    fun get(@PathVariable id: Long): ProductResponse?  

    @PatchMapping("/{id}")  
    fun update(@PathVariable id: Long, @RequestParam discount: Double): ProductResponse?  

}

Die eigentliche Implementierung des Adapters, ProductsEndpointAdapter, nutzt den ProductsEndpointPort der Domäne, um die Geschäftslogik auszuführen. Eingehende Requests werden in Domänenobjekte umgewandelt, an den Port übergeben und das Ergebnis wieder als Response-DTO zurückgegeben.

Kotlin
// adapters/endpoint/src/main/kotlin/consulting/atra/hexagon/adapters/endpoint/ProductsEndpointAdapter.kt

package consulting.atra.hexagon.adapters.endpoint  

import consulting.atra.hexagon.domain.ProductEndpointPort  
import org.springframework.web.bind.annotation.RestController  

@RestController  
class ProductsEndpointAdapter (  
    private val port: ProductsEndpointPort,  
) : ProductsEndpoint {  

    override fun list(): List<ProductResponse> =  
        port.findAllProducts().toResponses()  

    override fun create(product: ProductRequest): ProductResponse =  
        port.createProduct(product.toDomain()).toResponse()  

    override fun get(id: Long): ProductResponse? =  
        port.findProductById(id)?.toResponse()  

    override fun update(  
        id: Long,  
        discount: Double  
    ): ProductResponse? =  
        port.applyDiscountToProduct(id, discount)?.toResponse()  
}

Kapitel 6: Der Ausgangs-Adapter

Kotlin
classDiagram
    class ProductEntity {
        <<Entity / data class>>
        +Long? id
        +String name
        +Double price
    }

    class ProductsRepository {
        <<interface>>
    }

    class ProductsPersistenceAdapter {
        <<Service>>
        -ProductsRepository repository
        +findAllProducts() List~Product~
        +findProductById(Long id) Product?
        +saveProduct(Product product) Product
    }

    class ProductsPersistencePort {
        <<interface / from domain>>
    }

    class Product {
        <<data class / from domain>>
    }

    ProductsPersistenceAdapter ..|> ProductsPersistencePort : implements
    ProductsPersistenceAdapter --> ProductsRepository : repository
    ProductsRepository ..> ProductEntity : manages
    ProductsPersistenceAdapter ..> ProductEntity : maps to/from
    ProductsPersistenceAdapter ..> Product : maps to/from

Auf der anderen Seite des Hexagons befindet sich der Ausgangs-Adapter (Driven Adapter), der für die Persistenz zuständig ist. Das Modul adapters:persistence implementiert die technischen Details der Datenspeicherung mittels JPA und H2.

Kotlin
// adapters/persistence/build.gradle.kts

plugins {  
    alias(libs.plugins.kotlin.jpa)  
}  

dependencies {  
    implementation(project(":domain"))  
    implementation(libs.spring.boot.data.jpa)  
    runtimeOnly(libs.h2)  
}

Auch hier wird eine strikte Trennung vorgenommen: Die ProductEntity ist eine JPA-spezifische Klasse, die mit Datenbank-Annotationen versehen ist. Sie ist von der Domänenklasse Product getrennt, um das Domänenmodell nicht mit Persistenzdetails zu verunreinigen.

Kotlin
// adapters/persistence/src/main/kotlin/consulting/atra/hexagon/adapters/persistence/ProductEntity.kt

package consulting.atra.hexagon.adapters.persistence  

import jakarta.persistence.Entity  
import jakarta.persistence.GeneratedValue  
import jakarta.persistence.Id  

@Entity  
data class ProductEntity(  
    @Id  
    @GeneratedValue
    var id: Long? = null,  

    var name: String,  

    var price: Double,  
)

Das ProductsRepository bleibt ein klassisches Spring Data JPA Repository.

Kotlin
// adapters/persistence/src/main/kotlin/consulting/atra/hexagon/adapters/persistence/ProductsRepository.kt

package consulting.atra.hexagon.adapters.persistence  

import org.springframework.data.jpa.repository.JpaRepository  
interface ProductsRepository : JpaRepository<ProductEntity, Long>

Der ProductsPersistenceAdapter implementiert schließlich den ProductsPersistencePort der Domäne. Er übersetzt die Aufrufe der Domäne in Datenbankoperationen und konvertiert zwischen Product (Domäne) und ProductEntity (Datenbank). Damit bleibt die Domäne vollständig unabhängig von der verwendeten Datenbanktechnologie.

Kotlin
// adapters/persistence/src/main/kotlin/consulting/atra/hexagon/adapters/persistence/ProductsPersistenceAdapter.kt

package consulting.atra.hexagon.adapters.persistence  

import consulting.atra.hexagon.domain.Product  
import consulting.atra.hexagon.domain.ProductsPersistencePort  
import org.springframework.data.repository.findByIdOrNull  
import org.springframework.stereotype.Service  

@Service  
class ProductsPersistenceAdapter(  
    private val repository: ProductsRepository,  
) : ProductsPersistencePort {  
    override fun findAllProducts(): List<Product> =  
        repository.findAll().toDomains()  

    override fun findProductById(id: Long): Product? =  
        repository.findByIdOrNull(id)?.let { return it.toDomain() }  

    override fun saveProduct(product: Product): Product =  
        repository.save(product.toEntity()).toDomain()  
}

Kapitel 7: Die Application

Kotlin
classDiagram
    class Application {
        <<SpringBootApplication>>
        -ProductsPersistencePort persistencePort
        +findAllProducts() List~Product~
        +findProductById(Long id) Product?
        +createProduct(Product product) Product
        +applyDiscountToProduct(Long id, Double discount) Product?
        +main(String[] args)$
    }

    class ProductsEndpointPort {
        <<interface / from domain>>
    }

    class ProductsPersistencePort {
        <<interface / from domain>>
    }

    class Product {
        <<data class / from domain>>
        +applyDiscount(Double discount)
    }

    class ProductsEndpointAdapter {
        <<RestController / from adapters:endpoint>>
        -ProductsEndpointPort port
    }

    class ProductsPersistenceAdapter {
        <<Service / from adapters:persistence>>
    }

    Application ..|> ProductsEndpointPort : implements (Driving Port)
    Application --> ProductsPersistencePort : uses (Driven Port)
    Application ..> Product : orchestrates domain logic
    ProductsEndpointAdapter --> ProductsEndpointPort : injects (Application)
    ProductsPersistenceAdapter ..|> ProductsPersistencePort : implements


Das application-Modul fungiert als Klebstoff, der alle Komponenten zusammenhält. Hier werden die Abhängigkeiten zu allen anderen Modulen definiert, damit Spring Boot den Application Context vollständig aufbauen kann.

Kotlin
// application/build.gradle.kts

plugins {  
    id("application")  
    alias(libs.plugins.spring.boot)  
}  

dependencies {  
    implementation(project(":domain"))  
    implementation(libs.spring.boot)  
    implementation(project(":adapters:endpoint"))  
    implementation(project(":adapters:persistence"))  
    developmentOnly(libs.spring.boot.devtools)  
    annotationProcessor(libs.spring.boot.configuration)  
}


Die zentrale Klasse Application dient hierbei nicht nur als Startpunkt, sondern übernimmt in diesem Beispiel auch die Orchestrierung der Use Cases. Sie implementiert den ProductsEndpointPort (den Eingang zur Domäne) und nutzt den ProductsPersistencePort (den Ausgang zur Datenbank). Durch Dependency Injection wird die konkrete Implementierung des Persistenz-Adapters (ProductsPersistenceAdapter) zur Laufzeit bereitgestellt.

Diese Struktur ermöglicht es, dass die Domäne selbst frei von Framework-Code bleibt, während die Application-Klasse die Verbindung zwischen den abstrakten Ports der Domäne und den konkreten Adaptern herstellt.

Kotlin
// application/src/main/kotlin/consulting/atra/hexagon/Application.kt

package consulting.atra.hexagon  

import consulting.atra.hexagon.domain.Product  
import consulting.atra.hexagon.domain.ProductsEndpointPort  
import consulting.atra.hexagon.domain.ProductsPersistencePort  
import org.springframework.boot.autoconfigure.SpringBootApplication  
import org.springframework.boot.runApplication  

@SpringBootApplication  
class Application(  
    private val persistencePort: ProductsPersistencePort  
) : ProductsEndpointPort {  
    override fun findAllProducts(): List<Product> =  
        persistencePort.findAllProducts()  

    override fun findProductById(id: Long): Product? =  
        persistencePort.findProductById(id)  

    override fun createProduct(product: Product): Product =  
        persistencePort.saveProduct(product)  

    override fun applyDiscountToProduct(  
        id: Long,  
        discount: Double  
    ): Product? {  
        var product = persistencePort.findProductById(id)  
        if (product != null) {  
            product.applyDiscount(discount)  
            product = persistencePort.saveProduct(product)  
        }  
        return product  
    }  
}  

fun main(args: Array<String>) {  
    runApplication<Application>(*args)  
}

Fazit

Die Transformation vom monolithischen Spring Starter Projekt zur hexagonalen Architektur ist vollzogen. Durch die Aufteilung in Module (domain, adapters, application) wurde eine klare Trennung der Verantwortlichkeiten erreicht.

Die Domäne ist nun das unabhängige Herzstück der Anwendung, frei von Framework-Abhängigkeiten und rein in Kotlin geschrieben. Externe Einflüsse wie REST-Schnittstellen oder Datenbanken wurden in separate Adapter ausgelagert, die über definierte Ports mit der Domäne kommunizieren.

Zwar erhöht sich durch die Modularisierung und die Einführung von Mapping-Schichten die anfängliche Komplexität und die Menge an Boilerplate-Code, jedoch wird dies durch eine signifikant verbesserte Testbarkeit, Wartbarkeit und Flexibilität aufgewogen. Technologien können einfacher ausgetauscht und die Geschäftslogik isoliert getestet werden, was langfristig zu einer robusteren Softwarearchitektur führt.

Daniel Schock

Senior Software Engineer

Daniel ist ein erfahrener Fullstack-Entwickler und DevOps-Spezialist mit einem klaren Schwerpunkt auf der Backend-Entwicklung mit Java und Kotlin. Seine Expertise wird durch praktische Erfahrung in der Frontend-Entwicklung mit Vue.js abgerundet. Er verfügt über umfassende Kenntnisse in der Konzeption von CI/CD-Pipelines sowie im Einsatz von Cloud-Technologien wie Kubernetes und AWS, die er erfolgreich in den Branchen Versicherung, Logistik und E-Commerce einsetzte. Daniel arbeitet bevorzugt in agilen Umgebungen und zeichnet sich durch seine analytische Denkweise und hohe Problemlösungskompetenz aus.

Weitere Artikel