Zum Hauptinhalt springen
Software Engineering Architektur

Vom Spring Starter zur hexagonalen Architektur mit Kotlin

Daniel Schock
Abstrakte hexagonale Strukturen in tiefem Blau

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.

Hexagonale Architektur mit Ports und Adaptern
Die hexagonale Architektur — Domain im Zentrum, umgeben von Ports und Adaptern

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)
}
Multi-Modul Gradle-Projektstruktur
Die Modul-Struktur des Gradle-Projekts

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.

Klassendiagramm des Domain Layers mit Product Entity und Port-Definitionen
Der Domain Layer — Entities, Use Cases und Ports

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()
}
Inbound-Adapter-Pattern: REST-Controller mit DTO-Mapping
Der Endpoint-Adapter — REST-Controller als Inbound-Adapter

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()
}
Outbound-Adapter: JPA Entities und Repository-Implementierung
Der Persistence-Adapter — JPA als Outbound-Adapter

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.

Application-Klasse als Orchestrator zwischen Ports
Die Application-Schicht — Orchestrator zwischen Driving und Driven Ports

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:

github.com/atra-consulting/hexagon

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