Microservices sind der Standard-Ansatz für verteilte Systeme. Aber nicht jedes System profitiert davon. In meinem Fall liefen vier Spring-Boot-Services, die eng miteinander kommunizierten - über REST und Feign Clients. Das funktionierte, aber der Overhead war spürbar: vier JVMs, vier Deployments, vier Health-Checks, und bei jedem internen Aufruf Serialisierung, HTTP und Deserialisierung. Die Latenzen zwischen den Docker Containern belasten spürbar die Performance.

Irgendwann war klar: Die Service-Grenzen passen nicht mehr zur Realität. Die Module gehören zusammen.

Warum die Migration?

Drei konkrete Probleme haben den Ausschlag gegeben:

  1. RAM-Verbrauch: Vier JVMs mit je 800 MB–1 GB. In Summe 3,3 GB für ein System, das ein einzelner Entwickler betreibt.
  2. REST-Overhead: 14 interne Feign Clients für Aufrufe, die eigentlich einfache Methodenaufrufe sein sollten. Jeder mit Serialisierung, Netzwerk-Latenz, Error-Handling und Circuit Breaker.
  3. Deployment-Komplexität: Vier Docker-Container, vier docker-compose-Einträge, vier Logs. Wenn ein Service ein Update brauchte, mussten oft auch die anderen angepasst werden. Die Microservices hatten zu hohe Koabhängigkeit.

Der Ansatz: Big-Bang statt schrittweise

Mein erster Versuch war eine schrittweise Migration - ein Service nach dem anderen. Das klang vernünftig, führte aber schnell zu über 100 Kompilierungsfehlern, weil die Package-Struktur nicht sauber 1:1 übernommen wurde. Nach einem git reset habe ich die Strategie gewechselt.

Der Big-Bang-Ansatz: Alle vier Services auf einmal kopieren, Package-Struktur beibehalten, dann systematisch Konflikte lösen. Das klingt riskanter, war aber in der Praxis deutlich schneller. Die Migration selbst hat rund vier Stunden gedauert.

Was konkret passiert ist

1. Code zusammenführen

Rund 700 Java-Dateien aus vier Services in ein Projekt kopiert. Jeder Service behält sein eigenes Package - com.omninoo.market, com.omninoo.flex, com.omninoo.tws, com.omninoo.portfolio. Keine Umbenennung, keine Umstrukturierung.

Die Module entsprechen unterschiedlichen Datenquellen und Verantwortlichkeiten im System. market verarbeitet Marktdaten, tws integriert die Broker-API, flex importiert Kontoauszüge, und portfolio führt diese Informationen zusammen.

Die Module sind Domain-getrieben, nicht zufällig entstanden. Sie sind fachlich unabhängig, aber infrastrukturell nicht unabhängig genug, um separate Services zu rechtfertigen. Die Migration ist kein Rückschritt, sondern eine Vereinfachung - die fachlichen Grenzen bleiben bestehen. Sie werden jetzt nur nicht mehr über Netzwerkgrenzen erzwungen, sondern über Modulgrenzen im Code. Genau das macht den Unterschied zum klassischen Monolithen: Die Module haben klare Schnittstellen und dürfen nicht beliebig aufeinander zugreifen.

2. Bean-Konflikte lösen

Das war der aufwändigste Teil. Wenn vier Services jeweils einen eigenen JwtAuthenticationFilter, CorsConfig oder GlobalExceptionHandler mitbringen, muss man entscheiden, welche Version bleibt. In meinem Fall:

  • Security-Konfiguration: Eine zentrale Version, die restlichen gelöscht.
  • Cache-Config: Zwei Konfigurationen zusammengeführt.
  • CORS-Config: Features aus beiden Versionen kombiniert.
  • Duplicate Controllers: Entfernt, wo die Funktionalität identisch war.

3. Feign Clients durch direkte Aufrufe ersetzen

Das war der befriedigendste Teil. 14 interne Feign Clients - weg. Stattdessen direkte Service-Injection:

// Vorher: HTTP-Aufruf über Feign
@FeignClient(name = "market-service")
public interface StockPriceFeignClient {
    @GetMapping("/api/prices/{ticker}")
    StockPriceResponse getPrice(@PathVariable String ticker);
}

// Nachher: Access-Interface als Modulgrenze
public interface StockPriceAccess {
    StockPriceResponse getPrice(String ticker);
}

// Implementierung im Market-Modul
@Service
@RequiredArgsConstructor
public class StockPriceAccessImpl implements StockPriceAccess {
    private final StockPriceService stockPriceService;

    public StockPriceResponse getPrice(String ticker) {
        return stockPriceService.getPrice(ticker);
    }
}

Der Trick: Die Feign Clients wurden nicht einfach durch direkte Service-Injection ersetzt, sondern durch *Access-Interfaces. Jedes Modul definiert ein Interface - z.B. StockPriceAccess, TradeHistoryAccess - das genau die Methoden exponiert, die andere Module brauchen. Die Implementierung bleibt intern im Modul. Der früherer FeignClient kann zum AccessClient auf das Fremdmodul umdefiniert werden.

@Component
@Slf4j
@RequiredArgsConstructor
public class FlexHistoryModuleClient {

    private final PositionFlexAccess positionFlexAccess;

    // früher: @GetMapping("/positions/stocks/{accountNumber}")
    public List<StockPositionDto> getStockPositions(String accountNumber) {
        return positionFlexAccess.getAllStockPositions(accountNumber);
    }
}

So bleiben die Code-Änderungen minimal, die Modulgrenzen sauber getrennt, obwohl alles in einer JVM läuft. Kein Modul greift direkt auf interne Services eines anderen Moduls zu. Wenn man später doch wieder aufteilen müsste, sind die Schnittstellen bereits definiert.

Kein HTTP, keine Serialisierung, kein Circuit Breaker für interne Aufrufe. Für die Stellen, wo lose Kopplung sinnvoll bleibt, nutze ich Spring Application Events.

4. Datenbank: Multi-Schema beibehalten

Ein wichtiger Punkt: Die vier Services hatten jeweils ein eigenes Datenbank-Schema. Das habe ich beibehalten. Vier Schemas in einer Datenbank, vier Flyway-Konfigurationen. Das sorgt für saubere Trennung auf Datenebene, auch wenn der Code jetzt in einer JVM läuft.

5. Modulgrenzen absichern mit Spring Modulith und ArchUnit

Der Code lag jetzt in einer JVM - aber wie stellt man sicher, dass die Module nicht langsam wieder zusammenwachsen? Genau hier kommt Spring Modulith ins Spiel.

Jedes Modul wird mit @ApplicationModule annotiert und definiert explizit, welche Packages nach außen sichtbar sind:

@ApplicationModule(
    allowedDependencies = {"market", "shared"}
)
package com.omninoo.portfolio;

Spring Modulith verifiziert diese Grenzen im Test. Ein einzelner Test reicht, um die gesamte Modulstruktur zu prüfen:

@Test
void verifyModuleStructure() {
    ApplicationModules.of(Application.class).verify();
}

Dieser Test schlägt fehl, sobald ein Modul auf interne Klassen eines anderen Moduls zugreift - zum Beispiel direkt auf einen @Service statt über das definierte *Access-Interface. Das fängt Architektur-Verstöße ab, bevor sie in Produktion landen.

Zusätzlich nutze ich ArchUnit für feinere Regeln: Kein Modul darf auf ..internal..-Packages eines anderen Moduls zugreifen, und Controller dürfen nur über Access-Interfaces auf fremde Module zugreifen. Das läuft als Teil der CI-Pipeline - jeder Pull Request wird automatisch gegen die Architekturregeln geprüft.

@ArchTest
static final ArchRule moduleBoundaries = noClasses()
    .that().resideInAPackage("..portfolio..")
    .should().dependOnClassesThat()
    .resideInAPackage("..market.internal..");

Was sich verbessert hat

  • RAM: Von 3,3 GB auf rund 1,5 GB. Eine JVM statt vier.
  • Startup: Ein Prozess statt vier. Schneller, einfacher zu überwachen.
  • Deployment: Ein Docker-Container, ein Log-Stream, ein Health-Check.
  • Latenz: Interne Aufrufe sind jetzt Methodenaufrufe - Mikrosekunden statt Millisekunden.
  • Dependencies: OpenFeign, Resilience4j und Spring Cloud für interne Aufrufe komplett entfernt.

Wo es gehakt hat

  • Bean-Konflikte: Vier Services bringen vier Versionen derselben Infrastruktur-Beans mit. Das manuell zusammenzuführen braucht Sorgfalt.
  • Der erste Ansatz hat nicht funktioniert. Schrittweise Migration klingt sicherer, war aber fehleranfälliger. Big-Bang war die bessere Wahl - alle Probleme sind sofort sichtbar, statt sich über Wochen zu verteilen.
  • Flyway-Konfiguration: Vier Schema-spezifische Flyway-Instanzen in einer Applikation zu konfigurieren, ist etwas mehr Aufwand als eine einzelne.

Modulith first

Wenn ein Modul irgendwann wirklich unabhängig skalieren muss, ist die Extraktion zurück zum Microservice relativ einfach - weil die Vorarbeit bereits geleistet ist:

  • Access-Interfaces definieren die Schnittstellen zwischen den Modulen
  • Datenbank-Schemas sind getrennt (ein Schema pro Modul)
  • Modulgrenzen sind durch Spring Modulith und ArchUnit abgesichert

Ein konkretes Beispiel: Das tws-Modul kommuniziert mit einem externen Broker-Gateway und könnte bei steigender Last irgendwann unabhängig skalieren müssen. Bisher ist das nicht nötig - aber der Punkt ist nicht verbaut. Dank Access-Interface und eigenem Datenbank-Schema ließe sich tws jederzeit als eigenständigen Service extrahieren, ohne den Rest des Systems anzufassen.

Das ist genau die Idee hinter “Modulith first”: Mit einem Modulithen starten, die fachliche Struktur sauber halten, und erst dann aufteilen, wenn es einen echten Grund dafür gibt - nicht vorsorglich.

Fazit

Die gesamte Migration hat rund 18 Stunden gedauert - deutlich weniger als die ursprünglich geschätzten 80 Stunden. Der größte Einzelaufwand war das Lösen von Bean-Konflikten und das Ersetzen der Feign Clients.

Das System läuft seit mehreren Monaten stabil in Produktion. Der Ressourcenverbrauch ist um über 40 % gesunken, das Deployment ist einfacher, und der Code ist ehrlicher: Was zusammengehört, lebt jetzt auch zusammen.

Voraussetzungen: Java 21, Spring Boot 3.2+ und Spring Modulith 1.x. Wer ein System mit eng gekoppelten Microservices betreibt, sollte sich die Frage stellen, ob die Service-Grenzen noch zur Realität passen.