Zum Hauptinhalt springen
Software Engineering Architektur

Vom Spring Starter zur hexagonalen Architektur mit Kotlin

Daniel Schock
Ein sechseckiger Betonblock im Streiflicht, dessen sechs Außenflächen jede anders bearbeitet und verwittert sind; ein Schnitt an der Stirnseite legt einen durchgehend orangefarbenen Kern frei

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.

Klassendiagramm des Monolithen: ProductsController nutzt ProductsRepository, beide arbeiten mit der Product-Entität

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

// 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:

// 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:

// 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

Modulstruktur: :application hängt von :domain, :adapters:endpoint und :adapters:persistence ab; beide Adapter hängen von :domain ab

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:

.
├── 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.

rootProject.name = "hexagon"

// ...

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

// ...

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.

// ...

@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.

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

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

// 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.

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.

// ...
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.
// ...

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.

// ...

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.

// ...

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.

// ...

subprojects {

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

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

// 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.

// 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.
# 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.

// ...
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.

// 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

Klassendiagramm der Domain: die Use-Case-Interfaces, das Product-Modell sowie die Ports ProductsEndpointPort und ProductsPersistencePort

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.

// 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.

// 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.

// 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

Klassendiagramm des Eingangs-Adapters: ProductsEndpointAdapter implementiert ProductsEndpoint, nutzt den ProductsEndpointPort und mappt zwischen Request-/Response-DTOs und Product

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.

// 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.

// 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,
)
// 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.

// 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.

// 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.

// 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

Klassendiagramm des Ausgangs-Adapters: ProductsPersistenceAdapter implementiert ProductsPersistencePort, nutzt ProductsRepository und mappt zwischen Product und ProductEntity

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.

// 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.

// 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.

// 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.

// 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

Klassendiagramm der Application: Application implementiert den ProductsEndpointPort (Driving) und nutzt den ProductsPersistencePort (Driven), die Adapter docken an die Ports an

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.

// 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.

// 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

Daniel Schock

Senior Consultant

Daniel Schock ist als Senior Consultant bei atra consulting im Bereich Software Engineering tätig. Er begleitet Kunden bei der Modernisierung bestehender Softwarearchitekturen und der Einführung moderner Entwicklungspraktiken.

Artikel teilen