Moderne Softwareentwicklung beginnt oft monolithisch — und das aus gutem Grund. Ein schneller Prototyp, ein start.spring.io-Projekt, alles in einem Modul. Das funktioniert, solange das Team klein und die Domäne überschaubar ist. Doch mit wachsender Komplexität zeigen sich die Grenzen: enge Kopplung, schwer testbare Geschäftslogik und technologische Abhängigkeiten, die jede Änderung zum Risiko machen.
Die hexagonale Architektur (auch bekannt als Ports and Adapters) stellt die Geschäftslogik konsequent ins Zentrum. Technische Details wie REST-APIs oder Datenbanken werden zu austauschbaren Adaptern, die über definierte Schnittstellen (Ports) mit der Domain kommunizieren.
Dieser Artikel zeigt die schrittweise Migration eines klassischen Spring Boot Monolithen hin zu einer modularisierten, hexagonalen Architektur mit Kotlin und Gradle. Jedes Kapitel entspricht einem Commit im zugehörigen GitHub-Repository:
github.com/atra-consulting/hexagon
Kapitel 0: Der Start
Alles beginnt auf start.spring.io. Wir generieren ein Projekt mit folgenden Abhängigkeiten:
- Gradle – Kotlin als Build-System
- Spring Web für REST-Endpoints
- Spring Data JPA für die Persistenz
- H2 Database als eingebettete Datenbank
Das Ergebnis ist eine typische, flache Spring Boot Projektstruktur:
src/
└── main/
└── kotlin/
└── com/example/demo/
├── DemoApplication.kt
├── Product.kt
├── ProductsRepository.kt
└── ProductsController.kt
Keine Trennung der Verantwortlichkeiten
Controller, Geschäftslogik und Persistenz leben im selben Paket. Änderungen an einer Schicht betreffen potenziell alle anderen.
Schwer testbar
Unit-Tests der Geschäftslogik erfordern immer den Spring-Kontext, da Domain und Infrastruktur nicht getrennt sind.
Technologische Kopplung
Die Domain ist direkt an JPA-Annotationen und Spring-Stereotypen gebunden. Ein Wechsel der Datenbank oder des Frameworks zieht die gesamte Codebasis in Mitleidenschaft.
Diese drei Schwächen werden wir in den folgenden Kapiteln systematisch auflösen.
Kapitel 1: Der Monolith
Bevor wir refactoren, bauen wir zunächst eine funktionsfähige Anwendung. Unser Domänenobjekt ist ein einfaches Product:
// Product.kt
package com.example.demo
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
@Entity
data class Product(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
val name: String,
val price: Double,
)
Das Repository nutzt Spring Data JPA:
// ProductsRepository.kt
package com.example.demo
import org.springframework.data.jpa.repository.JpaRepository
interface ProductsRepository : JpaRepository<Product, Long>
Und der Controller exponiert die Daten per REST:
// ProductsController.kt
package com.example.demo
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/products")
class ProductsController(
private val productsRepository: ProductsRepository,
) {
@GetMapping
fun getProducts(): List<Product> = productsRepository.findAll()
@GetMapping("/{id}")
fun getProduct(@PathVariable id: Long): Product =
productsRepository.findById(id).orElseThrow()
@PostMapping
fun createProduct(@RequestBody product: Product): Product =
productsRepository.save(product)
}
Kapitel 2: Die neue Struktur
Der erste große Schritt ist die Umstellung auf ein Multi-Module Gradle-Projekt. Jedes Modul bekommt eine klare Verantwortung:
hexagon/
├── application/ # Orchestrierung, Spring Boot Main
├── domain/ # Reine Geschäftslogik, keine Frameworks
├── adapters/
│ ├── endpoint/ # REST-API (Driving Adapter)
│ └── persistence/ # Datenbank (Driven Adapter)
├── settings.gradle.kts
└── build.gradle.kts
Die settings.gradle.kts definiert die Module:
// settings.gradle.kts
rootProject.name = "hexagon"
include(
"application",
"domain",
"adapters:endpoint",
"adapters:persistence",
)
Die Root-build.gradle.kts enthält gemeinsame Konfiguration für alle Submodule:
// build.gradle.kts
plugins {
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.spring) apply false
alias(libs.plugins.spring.boot) apply false
alias(libs.plugins.spring.dependency.management) apply false
}
subprojects {
group = "com.example"
version = "0.0.1-SNAPSHOT"
repositories {
mavenCentral()
}
}
Das application-Modul ist das einzige, das Spring Boot kennt und alle Adapter zusammenführt:
// application/build.gradle.kts
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.spring)
alias(libs.plugins.spring.boot)
alias(libs.plugins.spring.dependency.management)
}
dependencies {
implementation(project(":domain"))
implementation(project(":adapters:endpoint"))
implementation(project(":adapters:persistence"))
implementation(libs.spring.boot.starter.web)
implementation(libs.spring.boot.starter.data.jpa)
runtimeOnly(libs.h2)
}
Kapitel 3: Die Abhängigkeiten
Mit mehreren Modulen wird ein zentrales Dependency Management wichtig. Gradle bietet dafür den Version Catalog in gradle/libs.versions.toml:
# gradle/libs.versions.toml
[versions]
kotlin = "2.1.10"
spring-boot = "3.4.3"
spring-dependency-management = "1.1.7"
[libraries]
spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" }
spring-boot-starter-data-jpa = { module = "org.springframework.boot:spring-boot-starter-data-jpa" }
h2 = { module = "com.h2database:h2" }
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-spring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin" }
spring-boot = { id = "org.springframework.boot:spring-boot-gradle-plugin", version.ref = "spring-boot" }
spring-dependency-management = { id = "io.spring.dependency-management", version.ref = "spring-dependency-management" }
Kapitel 4: Die Domain
Das domain-Modul ist das Herzstück der hexagonalen Architektur. Hier lebt die reine Geschäftslogik — ohne Abhängigkeiten zu Frameworks, Datenbanken oder HTTP.
Das Domänenobjekt ist ein schlichtes Kotlin Data Class:
// domain/src/main/kotlin/.../domain/Product.kt
package com.example.domain
data class Product(
val id: Long? = null,
val name: String,
val price: Double,
)
Die Use Cases definieren, was die Anwendung kann:
// domain/src/main/kotlin/.../domain/ProductUseCases.kt
package com.example.domain
interface ProductUseCases {
fun getProducts(): List<Product>
fun getProduct(id: Long): Product
fun createProduct(product: Product): Product
}
Die Ports sind die Schnittstellen zur Außenwelt. Wir unterscheiden zwei Typen:
// domain/src/main/kotlin/.../domain/ProductPorts.kt
package com.example.domain
/**
* Driving Port — wird von außen aufgerufen (z.B. REST-Controller).
*/
interface ProductsEndpointPort : ProductUseCases
/**
* Driven Port — wird von der Domain aufgerufen (z.B. Datenbank).
*/
interface ProductsPersistencePort {
fun findAll(): List<Product>
fun findById(id: Long): Product?
fun save(product: Product): Product
}
Driving Port (Eingang)
Der ProductsEndpointPort wird von außen aufgerufen — z.B. vom REST-Controller. Er definiert, was die Anwendung anbietet.
Driven Port (Ausgang)
Der ProductsPersistencePort wird von der Domain aufgerufen, um Daten zu laden oder zu speichern. Die Implementierung kennt die Domain nicht.
Kapitel 5: Der Eingangs-Adapter
Der Endpoint-Adapter (adapters:endpoint) ist die REST-API-Schicht. Er nimmt HTTP-Requests entgegen, wandelt sie in Domain-Objekte um und delegiert an den Driving Port.
Zuerst die Request- und Response-DTOs:
// adapters/endpoint/src/main/kotlin/.../endpoint/ProductRequest.kt
package com.example.adapters.endpoint
data class ProductRequest(
val name: String,
val price: Double,
)
// adapters/endpoint/src/main/kotlin/.../endpoint/ProductResponse.kt
package com.example.adapters.endpoint
data class ProductResponse(
val id: Long,
val name: String,
val price: Double,
)
Die Mapping-Funktionen übersetzen zwischen API- und Domain-Schicht:
// adapters/endpoint/src/main/kotlin/.../endpoint/ProductMapping.kt
package com.example.adapters.endpoint
import com.example.domain.Product
fun ProductRequest.toDomain() = Product(
name = name,
price = price,
)
fun Product.toResponse() = ProductResponse(
id = id!!,
name = name,
price = price,
)
Das Endpoint-Interface definiert die REST-Operationen:
// adapters/endpoint/src/main/kotlin/.../endpoint/ProductsEndpoint.kt
package com.example.adapters.endpoint
import org.springframework.web.bind.annotation.*
@RequestMapping("/products")
interface ProductsEndpoint {
@GetMapping
fun getProducts(): List<ProductResponse>
@GetMapping("/{id}")
fun getProduct(@PathVariable id: Long): ProductResponse
@PostMapping
fun createProduct(@RequestBody product: ProductRequest): ProductResponse
}
Und der Adapter implementiert dieses Interface, indem er den Driving Port verwendet:
// adapters/endpoint/src/main/kotlin/.../endpoint/ProductsEndpointAdapter.kt
package com.example.adapters.endpoint
import com.example.domain.ProductsEndpointPort
import org.springframework.web.bind.annotation.RestController
@RestController
class ProductsEndpointAdapter(
private val productsEndpointPort: ProductsEndpointPort,
) : ProductsEndpoint {
override fun getProducts(): List<ProductResponse> =
productsEndpointPort.getProducts().map { it.toResponse() }
override fun getProduct(id: Long): ProductResponse =
productsEndpointPort.getProduct(id).toResponse()
override fun createProduct(product: ProductRequest): ProductResponse =
productsEndpointPort.createProduct(product.toDomain()).toResponse()
}
Kapitel 6: Der Ausgangs-Adapter
Der Persistence-Adapter (adapters:persistence) implementiert den Driven Port und kapselt alle Datenbankzugriffe. Die Domain weiß nicht, ob die Daten in einer SQL-Datenbank, einer NoSQL-Lösung oder einem externen Service liegen.
Die JPA-Entity lebt ausschließlich im Persistence-Modul:
// adapters/persistence/src/main/kotlin/.../persistence/ProductEntity.kt
package com.example.adapters.persistence
import com.example.domain.Product
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
@Entity
data class ProductEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
val name: String,
val price: Double,
) {
fun toDomain() = Product(
id = id,
name = name,
price = price,
)
companion object {
fun fromDomain(product: Product) = ProductEntity(
id = product.id,
name = product.name,
price = product.price,
)
}
}
Das Spring Data Repository:
// adapters/persistence/src/main/kotlin/.../persistence/ProductsRepository.kt
package com.example.adapters.persistence
import org.springframework.data.jpa.repository.JpaRepository
interface ProductsRepository : JpaRepository<ProductEntity, Long>
Und der Adapter, der den Driven Port implementiert:
// adapters/persistence/src/main/kotlin/.../persistence/ProductsPersistenceAdapter.kt
package com.example.adapters.persistence
import com.example.domain.Product
import com.example.domain.ProductsPersistencePort
import org.springframework.stereotype.Component
@Component
class ProductsPersistenceAdapter(
private val productsRepository: ProductsRepository,
) : ProductsPersistencePort {
override fun findAll(): List<Product> =
productsRepository.findAll().map { it.toDomain() }
override fun findById(id: Long): Product? =
productsRepository.findById(id).orElse(null)?.toDomain()
override fun save(product: Product): Product =
productsRepository.save(ProductEntity.fromDomain(product)).toDomain()
}
Kapitel 7: Die Application
Das application-Modul ist der Orchestrierer. Hier laufen alle Fäden zusammen: Es implementiert den Driving Port und nutzt den Driven Port, um die Geschäftslogik auszuführen.
// application/src/main/kotlin/.../Application.kt
package com.example.application
import com.example.domain.Product
import com.example.domain.ProductsEndpointPort
import com.example.domain.ProductsPersistencePort
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.autoconfigure.domain.EntityScan
import org.springframework.boot.runApplication
import org.springframework.context.annotation.ComponentScan
import org.springframework.data.jpa.repository.config.EnableJpaRepositories
import org.springframework.stereotype.Service
@SpringBootApplication
@ComponentScan(basePackages = ["com.example"])
@EntityScan(basePackages = ["com.example"])
@EnableJpaRepositories(basePackages = ["com.example"])
class Application
fun main(args: Array<String>) {
runApplication<Application>(*args)
}
@Service
class ProductsEndpointPortImpl(
private val productsPersistencePort: ProductsPersistencePort,
) : ProductsEndpointPort {
override fun getProducts(): List<Product> =
productsPersistencePort.findAll()
override fun getProduct(id: Long): Product =
productsPersistencePort.findById(id)
?: throw NoSuchElementException("Product with id $id not found")
override fun createProduct(product: Product): Product =
productsPersistencePort.save(product)
}
Hier passiert die eigentliche Magie: Der ProductsEndpointPortImpl ist ein Spring @Service, der den ProductsEndpointPort implementiert. Er delegiert an den ProductsPersistencePort — ohne zu wissen, ob dahinter H2, PostgreSQL oder ein Microservice steckt.
Fazit
Die Migration von einem Spring Boot Monolithen zur hexagonalen Architektur erfordert initiale Investition in Modulstruktur und Abstraktion. Doch die Vorteile zahlen sich mit wachsender Codebasis schnell aus:
Klare Modulgrenzen
Jedes Modul hat eine definierte Verantwortung. Abhängigkeiten sind explizit und fließen immer in Richtung Domain.
Testbarkeit
Die Domain lässt sich ohne Spring-Kontext testen. Adapter können isoliert mit Mocks geprüft werden.
Austauschbarkeit
Datenbank wechseln? REST durch gRPC ersetzen? Nur der jeweilige Adapter muss angepasst werden — die Domain bleibt unberührt.
Wartbarkeit
Neue Entwickler finden sich schnell zurecht. Die Architektur erzwingt saubere Grenzen und verhindert Shortcuts.
Skalierbarkeit
Module können unabhängig weiterentwickelt und bei Bedarf in eigene Services extrahiert werden.
Der gesamte Quellcode — Kapitel für Kapitel als einzelne Commits nachvollziehbar — ist auf GitHub verfügbar:
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.