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.
- Es wird zu start.spring.io navigiert.
- 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)
- 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.

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

graph TD
direction TB
Application[":application"]
Domain[":domain"]
Endpoint[":adapters:endpoint"]
Persistence[":adapters:persistence"]
Application --> Domain
Application --> Endpoint
Application --> Persistence
Endpoint --> Domain
Persistence --> DomainNun 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.ktsDamit 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

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.
// 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,
SaveProductUseCaseKapitel 5: Der Eingangs-Adapter

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

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

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.
// 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
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
Vom Spring Starter zur hexagonalen Architektur mit Kotlin
Einleitung In der modernen Softwareentwicklung wird häufig mit monolithischen Strukturen begonnen, um schnell sichtbare Ergebnisse…
OOP 2026: Zwischen KI und (Software-)Handwerk
Bericht von der OOP 2026 Unter dem Motto »Embrace Change« fand die OOP 2026 vom…
Warum Priorisierung heute mehr braucht als eine Matrix
Die Eisenhower-Matrix ist ein Klassiker der Management- und Consulting-Werkzeuge. Aufgaben werden in vier Quadranten nach…
c’t webdev conference 2025
Zwei Tage voller Frontend-Tiefgang in Köln Die Webentwicklung verändert sich rasant – aktuell vor allem…
ISAQB SAG 2025
Es gibt einige Konferenzen, die wir gewöhnlich besuchen. Eine davon ist das ISAQB Software Architecture…
Event-Rückblick: Java-Startzeiten optimieren
Vortrag im Office mit Karsten Silz Wie holen wir das Beste aus unseren Java-Anwendungen heraus?…
Wieso ich Retros nicht mehr mag
Und was ich daraus gelernt habe... Retrospektiven gelten als Herzstück agiler Teams – doch was,…
Conventional Commits und Semantic Versioning
Implementierung eines automatisierten Release-Workflows mit Conventional Commits und Semantic Versioning Manuelle Software-Releases stellen eine häufige…

Veränderung, die wirklich trägt: integrale Perspektive
Warum agile Transformationen oft scheitern… …und wie wir sie mit der integralen Perspektive zum Leben…
Ein (halber) Tag im Leben eines Vibecoders
Vier Stunden Vibe-Coding mit Lovable – ein Erfahrungsbericht zwischen Wow-Effekt, Warnsignalen

