Monolith oder Microservices: Wie treffe ich die richtige Wahl

1. Übersicht

Die Frage “Monolith oder Microservices?” taucht auf fast jeder System-Design-Diskussion auf. Dieser Beitrag bietet keine Universal-Antwort, sondern führt dich durch konkrete Kriterien: Team-Größe, Release-Geschwindigkeit, Betriebskomplexität und Kommunikationskosten zwischen Services. Am Ende hast du eine Checkliste, um zu beurteilen, wo dein System auf der “Achse” Monolith – modularer Monolith – Microservices positioniert sein sollte.

2. Monolith: einfach, aber häufig unterschätzt

Ein Monolith bedeutet: die gesamte Geschäftslogik (Order, Customer, Payment, Inventory…) läuft in einem Process, einer Codebase, einem Deploy-Durchlauf.

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) {
        Order order = orderService.create(request);
        return ResponseEntity.ok(OrderResponse.from(order));
    }
}

@Service
public class OrderService {

    private final InventoryService inventoryService;
    private final PaymentService paymentService;

    public OrderService(InventoryService inventoryService, PaymentService paymentService) {
        this.inventoryService = inventoryService;
        this.paymentService = paymentService;
    }

    public Order create(OrderRequest request) {
        inventoryService.reserve(request.productId(), request.quantity());
        paymentService.charge(request.customerId(), request.amount());
        return new Order(request); // angenommen, der Konstruktor speichert in der DB
    }
}

OrderService ruft InventoryService und PaymentService direkt auf — Methodenaufrufe in der gleichen JVM. Kein Netzwerk, keine Serialisierung, eine einzelne @Transactional-Annotation kann alles abdecken. Das ist der größte Vorteil des Monolithen: Consistency ist einfach zu garantieren, Debugging ist unkompliziert, ein Deploy und die Sache ist erledigt.

Die Nachteile zeigen sich, wenn das Team wächst: alle bearbeiten die gleiche Codebase, Build-Zeit nimmt zu, ein kleines Feature-Deploy zieht einen vollständigen Rebuild nach sich.

Hinweis: Monolith bedeutet nicht “Spaghetti-Code”. Ein gut geschriebener Monolith mit klaren Modulen (Packages nach Domain, keine zirkulären Abhängigkeiten) läuft auch für Systeme mit Millionen Nutzern problemlos.

3. Microservices: Entkopplung für unabhängige Skalierung, dafür mehr Betriebskomplexität

Microservices teilen Order, Inventory und Payment in separate Services auf, die über das Netzwerk kommunizieren (REST, gRPC, Message Queue).

@Service
public class OrderService {

    private final RestClient restClient;

    public OrderService(RestClient restClient) {
        this.restClient = restClient;
    }

    public Order create(OrderRequest request) {
        // Inventory Service per HTTP aufrufen
        restClient.post()
                .uri("http://inventory-service/reservations")
                .body(new ReservationRequest(request.productId(), request.quantity()))
                .retrieve()
                .toBodilessEntity();

        // Payment Service per HTTP aufrufen
        restClient.post()
                .uri("http://payment-service/charges")
                .body(new ChargeRequest(request.customerId(), request.amount()))
                .retrieve()
                .toBodilessEntity();

        return new Order(request);
    }
}

Der Unterschied zur Monolith-Version in Abschnitt 2 liegt in den restClient.post(...)-Aufrufen: Methodenaufrufe im gleichen Process werden zu HTTP-Calls übers Netzwerk. Konsequenzen:

  • Jeder Call kann fehlschlagen durch Netzwerk-Timeout, Service Down, DNS-Fehler — Fehler, die es im Monolithen nicht gibt.
  • Es gibt keine ACID-Transaktionen über Service-Grenzen hinweg. Du brauchst das Saga Pattern oder Eventual Consistency.
  • Du benötigst Service Discovery, Load Balancer, Distributed Tracing zum Debuggen von Fehlern.

3.1. Versteckte Kosten: verteilte Transaktionen

@Service
public class OrderSagaOrchestrator {

    public void createOrder(OrderRequest request) {
        try {
            inventoryClient.reserve(request.productId(), request.quantity());
            paymentClient.charge(request.customerId(), request.amount());
        } catch (PaymentFailedException e) {
            // Du musst selbst die Kompensationslogik schreiben, um einen Rollback zu machen
            inventoryClient.release(request.productId(), request.quantity());
            throw e;
        }
    }
}

Im Monolithen ist der Rollback automatisch durch @Transactional. In Microservices musst du selbst Kompensationslogik schreiben (release nach fehlgeschlagenem reserve). Dieser Code existiert im Monolithen gar nicht, ist aber in Microservices Pflicht, um Dateninkonsistenzen zu vermeiden.

4. Entscheidungskriterien: nicht nur nach dem Vorbild großer Unternehmen

4.1. Team-Größe und Conways Gesetz

Conways Gesetz: Die Struktur eines Systems spiegelt die Organisationsstruktur wider. Wenn du 5 Entwickler hast und sie auf 5 Services aufteilst, sitzt jeder an einem Service isoliert — Code Reviews finden nicht statt, und du verlierst den Vorteil von “mehreren Augen auf einen Bug”.

Praktische Richtwerte:

1–15 Entwickler, 1 Team         -> Monolith (modular)
15–50 Entwickler, 2–5 Teams     -> Modularer Monolith oder 2-3 große Services
50+ Entwickler, viele Teams     -> Microservices nach Bounded Context

4.2. Ist die Release-Geschwindigkeit wirklich ein Engpass?

Wenn dein Team den Monolithen alle 2 Wochen releast und es keine ernsthaften Merge-Konflikte gibt, Services zu trennen um “unabhängig zu deployen” bringt keinen Wert — es verursacht nur Betriebskosten (separate CI/CD, Monitoring, API-Versionierung).

Microservices lohnen sich wenn: Team A muss 10x am Tag für Payment deployen, während Team B Inventory nur 1x pro Woche deployed. Eine Trennung erlaubt A, nicht auf B zu warten.

4.3. Fähigkeit der Infrastruktur, mit Ausfällen umzugehen

# docker-compose.yml - zeigt minimale Komplexität für Microservices
services:
  order-service:
    image: order-service:1.0
    depends_on:
      - inventory-service
      - payment-service
      - service-registry
  inventory-service:
    image: inventory-service:1.0
  payment-service:
    image: payment-service:1.0
  service-registry:
    image: eureka-server:1.0
  api-gateway:
    image: spring-cloud-gateway:1.0

Mit nur 3 Business-Services brauchst du bereits service-registry und api-gateway. Multipliziert mit 20 echten Services wächst die Zahl der beweglichen Teile exponentiell — erfordert ein starkes DevOps-Team für Kubernetes, verteiltes Logging (ELK), Tracing (Jaeger/Zipkin).

Hinweis: Wenn dein Unternehmen niemanden mit Container Orchestration und Observability-Stack hat, werden Microservices jeden Incident zur “Suche nach der Nadel im Heuhaufen” über viele Service-Logs machen.

5. Modularer Monolith: die übersehene Mittelposition

Der modulare Monolith ist ein Monolith, dessen Code in unabhängige Module organisiert ist, die über klar definierte Interfaces kommunizieren, ohne dass sich ihre Datenbanken überschneiden.

// Package: com.nonladev.shop.order
public interface InventoryPort {
    void reserve(String productId, int quantity);
}

// Package: com.nonladev.shop.inventory
@Service
public class InventoryService implements InventoryPort {
    @Override
    public void reserve(String productId, int quantity) {
        // Business-Logik, greift auf die eigene DB des Inventory-Moduls zu
    }
}

OrderService hängt nur vom Interface InventoryPort ab, nicht von der Implementierung des Inventory-Moduls. Falls du später Inventory als separaten Service brauchst, ersetzt du nur die Implementierung von InventoryPort durch einen HTTP-Client — der Code in OrderService ändert sich nicht.

Das ist die Strategie “mit Monolith starten, später bei Bedarf aufteilen”, die viele große Systeme (Shopify, GitHub in frühen Phasen) erfolgreich angewendet haben.

6. Test: Messung der Latenz-Kosten beim Übergang von In-Process zu Netzwerk-Aufrufen

Das folgende Test-Beispiel zeigt den Latentz-Unterschied — ein Faktor, den du vor der Entscheidung zur Service-Trennung real messen solltest.

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <scope>test</scope>
</dependency>
class OrderServiceLatencyTest {

    @Test
    void inProcessCallShouldBeFasterThanRemoteCall() {
        InventoryPort inProcess = (productId, qty) -> { /* keine Operation */ };
        long startInProcess = System.nanoTime();
        inProcess.reserve("P-1", 1);
        long inProcessDuration = System.nanoTime() - startInProcess;

        InventoryPort remoteSimulated = (productId, qty) -> {
            try {
                Thread.sleep(20); // simuliert Netzwerk Round-Trip ~20ms
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        };
        long startRemote = System.nanoTime();
        remoteSimulated.reserve("P-1", 1);
        long remoteDuration = System.nanoTime() - startRemote;

        assertThat(remoteDuration).isGreaterThan(inProcessDuration);
    }
}

Dieser Test misst nicht die echte Infrastruktur-Performance, aber zeigt das Wesen: jedes Mal wenn Order zu Inventory über das Netzwerk anruft, addierst du mehrere bis mehrere Dutzend Millisekunden. Wenn ein Request über 5 Services hinweg aufgerufen wird, addiert sich die Latenz und könnte das SLA überschreiten — ein versteckter Kostenfaktor bei der Architektur-Planung auf dem Papier.

7. Wann man Microservices NICHT wählen sollte

  • Team kleiner als 10 Personen: die Betriebskosten (CI/CD, Monitoring, On-Call) für mehrere Services überwiegen den Nutzen der Trennung.
  • Domain noch nicht stabil: wenn die Geschäftslogik sich häufig ändert (Startup in der Produktentwicklung), sind Service-Grenzen ständig falsch — man baut, trennt, mergt wieder — doppelter Aufwand.
  • Keine Observability vorhanden: ohne Distributed Tracing, zentralisiertes Logging ist es quasi unmöglich, Fehler über Service-Grenzen zu debuggen.
  • Starker Konsistenz-Bedarf (z.B. Core Banking): bleib lieber in einer Transaction Boundary (Monolith) als ein komplexes Saga Pattern zu bauen.

Umgekehrt lohnt sich Microservices: mehrere unabhängige Teams, jeder Teil muss einzeln skalierbar sein (Search-Service braucht mehr CPU als Order-Service), oder unterschiedliche Technologien pro Domain (Node.js für Real-Time-Chat, Java für Billing).

8. Fazit

Es gibt keine “absolut richtige” Architektur — nur eine, die zu deiner Team-Größe, Release-Geschwindigkeit und aktuellen Betriebsfähigkeiten passt. Wenn du unsicher bist, starte mit modularem Monolith: halte die Interfaces zwischen Domains klar, vermeide zirkuläre Datenbank-Abhängigkeiten, sodass du später, wenn nötig, nur die Implementierung tauschst, nicht das ganze System neu schreibst. Microservices sind ein Werkzeug zur Lösung von Organisations- und Skalierungsproblemen, nicht das Ziel an sich.

microservicesmonolithsystem-designspring-bootArchitektur