Microservice-Architektur: Lessons Learned aus zwei Jahren Praxis
Anfang 2017 haben wir bei encircle360 den Schritt gewagt und unsere Projektlandschaft konsequent auf Microservices umgestellt. Spring Boot als Basis, Docker als Deployment-Einheit, Kubernetes als Orchestrierung. Zwei Jahre später ist es Zeit für eine ehrliche Bestandsaufnahme: Was hat funktioniert, wo lagen wir falsch, und was würden wir heute anders machen?
Dieser Artikel ist kein Tutorial und keine Architektur-Empfehlung. Es ist ein Erfahrungsbericht -- mit allen Ecken und Kanten.
Lesson 1: Service-Grenzen sind alles
Die wichtigste Erkenntnis zuerst: Die grösste Herausforderung bei Microservices ist nicht die Technik. Es ist die Frage, wo man die Grenzen zwischen Services zieht. Falsch gezogene Grenzen führen zu einem verteilten Monolithen -- man hat die Nachteile beider Welten, ohne die Vorteile einer der beiden zu geniessen.
Wir haben das am eigenen Leib erfahren. In einem frühen Projekt haben wir Services nach technischen Schichten aufgeteilt: ein Service für Datenbankzugriff, einer für Geschäftslogik, einer für die API. Das Ergebnis war, dass jede fachliche Änderung drei Services gleichzeitig betraf und koordinierte Deployments erforderte. Das war schlimmer als der Monolith, den wir ersetzen wollten.
Die Lösung lag in Domain-Driven Design, konkret im Konzept der Bounded Contexts. Ein Service sollte eine fachliche Domäne abbilden -- Bestellungen, Kundenverwaltung, Rechnungsstellung -- nicht eine technische Schicht. Erst als wir diese Denkweise verinnerlicht hatten, spielten Microservices ihre Stärken aus: unabhängige Deployments, klare Teamzuständigkeiten, isolierte Datenhaltung.
Wer Microservices plant, sollte zuerst seine Domäne verstehen. Nicht die Technik.
Lesson 2: Interne Abstraktion mit Augenmass
Ein Muster, das wir anfangs in jedem Service durchgezogen haben: Hexagonale Architektur mit Ports und Adaptern. Jede Datenbankanbindung hinter einem Interface. Jeder externe Aufruf hinter einer Abstraktion. Die Idee war, dass wir jederzeit die Datenbank oder das Framework austauschen könnten.
In der Praxis hat das nie stattgefunden. Kein einziges Mal haben wir in einem laufenden Service Spring Boot gegen ein anderes Framework getauscht. Kein einziges Mal haben wir PostgreSQL gegen MongoDB ersetzt. Was wir stattdessen hatten, waren Dutzende von Interfaces mit genau einer Implementierung, die das Navigieren im Code erschwerten und bei jeder Änderung doppelten Aufwand verursachten.
Unser Umdenken: Spring Boot ist kein Implementierungsdetail, das man verstecken muss. Es ist ein ausgereiftes Framework, dessen Annotationen und Konventionen man nutzen sollte. @Service, @Repository, @Transactional -- das sind keine Notlösungen, sondern produktive Werkzeuge. Wer in einem Spring-Boot-Service arbeitet, darf das auch sehen.
Das bedeutet nicht, dass Abstraktion generell schlecht ist. Aber sie muss einen konkreten Zweck erfüllen. Ein Interface für einen Service, der potenziell mehrere Implementierungen hat -- ja. Ein Interface als Wrapper um ein Spring-Data-Repository, das nie ausgetauscht wird -- nein.
Lesson 3: Package by Feature, nicht by Layer
Eng verwandt mit dem Abstraktionsthema ist die Paketstruktur. Unsere frühen Services sahen so aus:
com.encircle360.orderservice
├── controller/
├── service/
├── repository/
├── model/
├── dto/
└── mapper/
Das funktioniert bei kleinen Services. Sobald ein Service aber mehrere fachliche Aspekte abdeckt -- etwa Bestellungen und Retouren -- wird es unübersichtlich. Welcher Controller gehört zu welchem Service? Welches DTO zu welchem Use Case?
Wir sind dazu übergegangen, nach Features zu strukturieren:
com.encircle360.orderservice
├── order/
│ ├── OrderController.java
│ ├── OrderService.java
│ ├── OrderRepository.java
│ └── Order.java
└── return/
├── ReturnController.java
├── ReturnService.java
├── ReturnRepository.java
└── Return.java
Jede fachliche Einheit ist ein Paket. Alles, was zusammengehört, liegt beieinander. Das macht nicht nur die Navigation einfacher, sondern zeigt auch sofort, wenn ein Service zu viele Verantwortlichkeiten übernimmt -- ein Signal, dass eine Aufteilung sinnvoll sein könnte.
Lesson 4: Integrationstests schlagen Unit-Tests an den Service-Grenzen
Wir haben anfangs viel Energie in Unit-Tests mit gemockten Abhängigkeiten gesteckt. Jeder Service-Layer wurde isoliert getestet, jede Repository-Methode mit einem Mock verifiziert. Die Testabdeckung sah beeindruckend aus, aber die Tests hatten ein fundamentales Problem: Sie testeten die Verdrahtung, nicht das Verhalten.
Wenn ein OrderService mit einem gemockten OrderRepository getestet wird, prüft man im Wesentlichen, ob die richtigen Methodenaufrufe in der richtigen Reihenfolge stattfinden. Ob die SQL-Query korrekt ist, ob die Transaktionsgrenzen stimmen, ob die JSON-Serialisierung funktioniert -- all das bleibt ungetestet.
Unser Ansatz heute: Integrationstests mit echten Abhängigkeiten, wo immer möglich. Testcontainers hat für uns einen enormen Unterschied gemacht. Ein PostgreSQL-Container, der in Sekunden hochfährt, erlaubt es, Datenbankzugriffe gegen eine echte Datenbank zu testen. Spring Boot bringt mit @SpringBootTest und MockMvc die Werkzeuge mit, um einen Service inklusive HTTP-Layer zu testen.
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class OrderControllerIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:11");
@Autowired
private MockMvc mockMvc;
@Test
void shouldCreateOrder() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"product\": \"Widget\", \"quantity\": 5}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").exists());
}
}
Das ist langsamer als ein Unit-Test mit Mocks. Aber es testet, was tatsächlich in Produktion passiert. Ein fehlschlagender Integrationstest zeigt ein echtes Problem an. Ein fehlschlagender Mock-Test zeigt oft nur, dass sich eine interne API geändert hat.
Lesson 5: Verteilte Systeme sind wirklich schwer
Das klingt nach einer Binsenweisheit, aber die Tragweite wird erst klar, wenn man selbst in der Situation steckt. Zwei Themen haben uns besonders beschäftigt.
Eventual Consistency. Wenn jeder Service seine eigene Datenbank hat -- wie es bei Microservices empfohlen wird -- gibt es keine dienstübergreifenden Transaktionen. Wenn der Order-Service eine Bestellung anlegt und der Inventory-Service den Bestand reduzieren soll, kann einer der beiden Schritte fehlschlagen. Saga-Pattern, Compensating Transactions, Event-basierte Architektur -- die Lösungen existieren, aber sie sind komplex und fehleranfällig. In einem Monolithen hätte eine einzige Datenbanktransaktion genügt.
Distributed Tracing. Wenn ein Request durch fünf Services läuft und am Ende ein Fehler auftritt, muss man den gesamten Pfad nachvollziehen können. Ohne ein vernünftiges Tracing ist Debugging in einer Microservice-Architektur nahezu unmöglich. Wir setzen auf Spring Cloud Sleuth für die Propagation von Trace-IDs und den ELK-Stack für die Aggregation. Das funktioniert, erfordert aber Disziplin: Jeder Service muss korrekt instrumentiert sein, jede asynchrone Kommunikation muss die Trace-ID weiterreichen.
Diese Probleme sind lösbar. Aber sie sind nicht trivial, und sie existieren im Monolithen schlicht nicht. Das sollte man bei der Architekturentscheidung ehrlich einkalkulieren.
Lesson 6: Modular Monolith als Startpunkt
Wenn wir heute ein neues Projekt starten, empfehlen wir nicht mehr automatisch Microservices. Stattdessen beginnen wir mit einem modularen Monolithen: eine einzige Deployment-Einheit, aber intern sauber nach fachlichen Modulen getrennt. Klare Paketgrenzen, definierte Schnittstellen zwischen Modulen, eigene Datenbank-Schemata pro Modul.
Dieser Ansatz hat mehrere Vorteile. Die Entwicklung ist schneller, weil die Infrastrukturkomplexität wegfällt. Refactoring ist einfacher, weil alles im selben Prozess läuft. Und wenn ein Modul tatsächlich so stark wächst, dass es ein eigener Service werden sollte -- weil es unabhängig skaliert oder von einem anderen Team betreut werden muss -- dann ist die Extraktion vergleichsweise einfach, weil die Grenzen bereits sauber definiert sind.
Das ist kein Rückschritt. Es ist die Erkenntnis, dass Microservices ein Werkzeug sind, kein Ziel. Man führt sie ein, wenn der Schmerz im Monolithen grösser ist als die Komplexität der verteilten Architektur. Nicht vorher.
Pragmatismus statt Dogma
Wenn ich die letzten zwei Jahre auf einen Satz reduzieren müsste, wäre es dieser: Pragmatische Architektur schlägt dogmatische Architektur. Jedes Mal.
Wir haben gelernt, dass saubere Service-Grenzen wichtiger sind als die Anzahl der Services. Dass Spring Boot ein Feature ist, kein Makel. Dass Integrationstests mehr Sicherheit bieten als hundert gemockte Unit-Tests. Und dass ein gut strukturierter Monolith einem schlecht geschnittenen Microservice-System in fast jeder Hinsicht überlegen ist.
Microservices sind eine mächtige Architektur -- am richtigen Ort, mit dem richtigen Team und den richtigen Gründen. Aber sie sind kein Allheilmittel. Und die ehrliche Auseinandersetzung mit den eigenen Fehlern ist der beste Weg, um besser zu werden.
Geschrieben von
Patrick HütterGründer & Software-Architekt
Softwarearchitekt, Ingenieur und Unternehmer. Patrick baut seit über einem Jahrzehnt Produkte und Plattformen — von Enterprise-Backends über Cloud-Native-Infrastruktur bis hin zu KI-gestützten Anwendungen. Als Gründer von encircle360 verbindet er technische Tiefe mit unternehmerischem Denken und treibt Open-Source-Projekte voran, die echten Impact haben.
Das könnte dich auch interessieren
Vom Manifest zur Produktion: Wie ADL, A2A und das Inference Gateway die Agenten-Infrastruktur revolutionieren
05.07.2026 · 12 Min. Lesezeit
Agent-Orchestrierung mit Java: Wie Sie LLM-Agenten produktionsreif in die JVM bringen
04.07.2026 · 6 Min. Lesezeit
Spring AI: Wie Java-Entwickler endlich KI-Features ohne Umwege integrieren
25.03.2026 · 5 Min. Lesezeit