← Zurück

Kapitelkompass

Frameworks nach Betriebsmodell auswählen

Spring Boot, Jakarta EE, Quarkus und Micronaut unterscheiden sich nicht nur in APIs, sondern in Build, Startzeit, Ökosystem und Betrieb.

AnforderungAusgangspunktVergleichEinordnenPrototypAbsichernEntscheidungErgebnis
Der Themenweg zeigt die fachliche Leserichtung dieses Kapitels.

Das nimmst du mit

  • Entscheidungskriterien gewichten
  • Runtime-Modell verstehen
  • Native Trade-offs kennen
  • Migration realistisch planen

Praxisfall

Ein Team benötigt schnelle Skalierung, bekannte Libraries und langfristig verfügbare Betriebskenntnisse.

Entscheidung

Nach Team, Plattform, Libraries und SLOs entscheiden; Benchmarks allein reichen nicht.

!

Typisches Risiko

Ein Frameworkwechsel ohne fachlichen oder betrieblichen Nutzen erzeugt nur Migrationskosten.

17. Framework Deep Dive: Spring Boot, Jakarta EE, Quarkus und Micronaut

Dieser Kapitel ist der Abschlussrun der geplanten 24 Deep-Dive-Vertiefungen. Er behandelt Frameworks nicht als Feature-Liste, sondern als Architekturentscheidung: Wie bleibt der fachliche Kern stabil, wenn HTTP, Dependency Injection, Transactions, Configuration, Observability, Packaging und Runtime je nach Framework anders aussehen?

01. Warum Framework-Deep-Dive mehr ist als Annotationen

In Enterprise-Projekten wird Framework-Wissen oft falsch verstanden. Viele Teams lernen zuerst @RestController, @Transactional, @Inject, @Path oder @Controller und glauben dann, die Architektur sei erledigt. Das ist gefaehrlich. Ein Framework erzeugt Objektgraphen, startet Runtime-Komponenten, bindet HTTP, verwaltet Konfiguration und integriert Infrastruktur. Es entscheidet aber nicht automatisch, wo deine fachlichen Grenzen liegen.

Ein guter Framework-Deep-Dive fragt deshalb nicht: “Welche Annotation brauche ich?” Sondern: “Welche Verantwortung liegt im Framework-Adapter, welche im Application Service, welche im Domain Model und welche im Infrastruktur-Port?” Diese Trennung bestimmt, ob ein Projekt nach drei Jahren noch wartbar ist.

Typische schlechte Loesung:

JAVA
@RestController
class OrderController {
    @PostMapping("/orders")
    public ResponseEntity<?> place(@RequestBody OrderRequest request) {
        // Validierung, Preisberechnung, Payment-Aufruf, Repository-Zugriff,
        // Eventversand und HTTP-Fehlerbehandlung in einer Klasse.
    }
}

Das Problem ist nicht die Annotation. Das Problem ist, dass HTTP, Fachlogik, Transaktion, externe Systeme und Fehlerabbildung zusammenkleben. In einem kleinen Demo-Projekt sieht das bequem aus. In einem Enterprise-Projekt fuehrt es zu schlechten Tests, schwerer Migration und unsauberen Fehlergrenzen.

Bessere Regel: Framework-Code ist Adapter-Code. Fachlogik lebt im Use Case und Domain Model.

02. Gemeinsame Fachfunktion fuer vier Frameworks

In diesem Kapitel verwenden Spring Boot, Jakarta EE, Quarkus und Micronaut denselben fachlichen Use Case: Bestellung anlegen. Alle Adapter rufen denselben PlaceOrderUseCase auf. Dadurch sieht man, was wirklich Framework-spezifisch ist und was fachlich stabil bleiben muss.

Der Request-Flow ist immer gleich:

  1. HTTP-Request trifft Framework-Adapter.
  2. Adapter validiert transportnahe Form und mappt DTOs.
  3. Adapter ruft frameworkfreien Use Case.
  4. Use Case orchestriert Domain, Ports und Transaktionsrahmen.
  5. Adapter mappt fachliches Result auf HTTP-Status und Problem-Body.

Wichtig: Der fachliche Kern weiss nicht, ob er von Spring, Jakarta, Quarkus, Micronaut, CLI, Batch oder Test aufgerufen wurde.

03. Dependency Injection und Lifecycle tief verstehen

Dependency Injection ist kein Ersatz fuer gutes Design. DI beantwortet: “Wie entstehen Objekte und wie werden sie verdrahtet?” Es beantwortet nicht: “Welche Module duerfen voneinander abhaengen?”, “Wo endet die Transaktion?” oder “Welche Fehler sind fachlich?”.

Bei Spring Boot passiert viel zur Laufzeit und durch Auto-Konfiguration. Jakarta EE arbeitet staerker ueber Standards und Container-Services. Quarkus verschiebt vieles in Build-Time-Augmentation. Micronaut setzt stark auf Compile-Time-Metadaten. Diese Unterschiede beeinflussen Startzeit, Diagnose, Reflection, Native-Image-Faehigkeit und Debugging.

Deep-Dive-Regel: Je mehr ein Framework automatisch macht, desto wichtiger werden Architekturtests, Startdiagnose, Konfigurationsreport und klare Modulgrenzen.

04. Spring Boot Deep Dive

Spring Boot ist in vielen Enterprise-Teams beliebt, weil es schnell produktiv macht. Auto-Konfiguration, Starter, Actuator, Testing-Support und ein grosses Oekosystem sind starke Vorteile. Die Kehrseite: Ein Projekt kann unbemerkt viele Abhaengigkeiten, versteckte Bean-Konflikte und unklare Auto-Konfigurationspfade bekommen.

Gute Spring-Boot-Architektur bedeutet nicht, ueberall Spring-Typen zu verwenden. Gute Architektur bedeutet: Spring erzeugt Adapter und Infrastruktur; der Kern bleibt sauber.

JAVA
package com.example.framework.deepdive.frameworks.spring;
import com.example.framework.deepdive.application.PlaceOrderUseCase;
import com.example.framework.deepdive.domain.*;
import com.example.framework.deepdive.fakeannotations.RestController;
import java.util.*;
@RestController("orders")
// Pattern: Adapter - uebersetzt HTTP DTOs in den frameworkfreien Use Case.
public final class SpringBootOrderController {
    private final PlaceOrderUseCase useCase;
    public SpringBootOrderController(PlaceOrderUseCase useCase) {
        this.useCase = useCase;
    }
@com.example.framework.deepdive.fakeannotations.PostMapping("/orders")
public HttpResponse place(PlaceOrderHttpRequest request) {
    var command = new PlaceOrderCommand(request.requestId(), new CustomerId(request.customerId()), request.lines().stream().map(l -> new OrderLine(new Sku(l.sku()), l.quantity(), Money.eur(l.unitPrice()))).toList());
    return switch (useCase.place(command)) {
        case PlaceOrderResult.Accepted ok -> new HttpResponse(201, "accepted:" + ok.orderId().value() + ":" + ok.total());
        case PlaceOrderResult.Rejected bad -> new HttpResponse(statusFor(bad.error()), "problem:" + bad.error().code() + ":" + bad.error().message());
    };
}
private int statusFor(BusinessError error) {
    return switch (error) {
        case ValidationError e -> 400;
        case DuplicateRequest e -> 409;
        case PaymentDeclined e -> 422;
    };
}
}

Produktionshinweise:

05. Jakarta EE Deep Dive

Jakarta EE ist weniger “one jar owns everything” und staerker ein Standard-API- und Container-Modell. Das ist in Organisationen mit langen Lebenszyklen, Application-Server-Strategie und Standardisierungsdruck relevant. Der Vorteil ist Stabilitaet und API-Portabilitaet. Die Herausforderung ist, dass Runtime, Server-Konfiguration, Deployment und Versionen sehr bewusst zusammenpassen muessen.

Jakarta-Adapter sehen anders aus, aber sie duerfen dieselbe fachliche Regel nicht anders auslegen. Ein @Path-Resource ist genau wie ein Spring Controller: ein Adapter, kein Domain Service.

Typische Fehler:

06. Quarkus Deep Dive

Quarkus ist fuer moderne Container- und Cloud-Runtimes interessant, weil viele Entscheidungen in die Build-Zeit verlagert werden. Das kann Startzeit und Speicherprofil verbessern, fuehrt aber auch dazu, dass Extensions, Build-Konfiguration und Native-Image-Eignung wichtiger werden.

Ein Quarkus-Projekt braucht deshalb besonders klare Grenzen zwischen Build-Time-Metadaten, Runtime-Konfiguration und Fachmodell. Wer die Grenzen nicht zieht, bekommt zwar eine schnelle Runtime, aber weiterhin schlechte Architektur.

Deep-Dive-Fragen fuer Quarkus:

07. Micronaut Deep Dive

Micronaut reduziert viele Runtime-Reflection-Mechanismen durch Compile-Time-Verarbeitung. Das ist fuer kleine Services, schnelle Starts und klare DI-Fehler attraktiv. Gleichzeitig muss das Team verstehen, welche Annotation Processing Schritte stattfinden und wie Konfiguration, Clients, Validation und Security in diesem Modell funktionieren.

Micronaut eignet sich gut, wenn ein Team explizite Compile-Time-Metadaten, geringe Laufzeitmagie und leichte Services moechte. Es ist aber kein Selbstlaeufer: Auch hier bleiben Modulgrenzen, Teststrategie und saubere Ports wichtiger als Framework-Features.

08. Transaktionen und Frameworks

Frameworks machen Transaktionen bequem, aber genau darin liegt die Gefahr. Eine Annotation sieht klein aus, kann aber grosse Laufzeitfolgen haben: Connection wird gebunden, Persistence Context entsteht, Locks koennen gehalten werden, externe Calls werden blockierend, Rollback-Regeln greifen oder greifen nicht.

Schlechte Regel: “Einfach @Transactional ueberall drauf.”
Bessere Regel: “Jeder Use Case hat eine bewusst dokumentierte Transaktionsgrenze.”

Im Code-Lab ist TransactionTemplate absichtlich ein Port. In echten Frameworks wuerde dieser Port durch Spring TransactionTemplate, Jakarta Transaktionen oder Quarkus/Micronaut Integration umgesetzt. Der Use Case kennt diese Details nicht.

JAVA
package com.example.framework.deepdive.application;
import com.example.framework.deepdive.domain.*;
import com.example.framework.deepdive.ports.*;
import java.time.Clock;
import java.util.Objects;
// Pattern: Application Service - orchestriert Ports, transaktionale Grenze und Aggregate.
public final class PlaceOrderUseCase {
    private final OrderRepository orders;
    private final PaymentPort payments;
    private final OutboxPort outbox;
    private final TransactionTemplate tx;
    private final OrderNumberGenerator numbers;
    private final Clock clock;
    public PlaceOrderUseCase(OrderRepository orders, PaymentPort payments, OutboxPort outbox, TransactionTemplate tx, OrderNumberGenerator numbers, Clock clock) {
        this.orders = Objects.requireNonNull(orders);
        this.payments = Objects.requireNonNull(payments);
        this.outbox = Objects.requireNonNull(outbox);
        this.tx = Objects.requireNonNull(tx);
        this.numbers = Objects.requireNonNull(numbers);
        this.clock = Objects.requireNonNull(clock);
    }
public PlaceOrderResult place(PlaceOrderCommand command) {
    return tx.inTransaction(() -> {
        if (orders.findByRequestId(command.requestId()).isPresent()) return new PlaceOrderResult.Rejected(new DuplicateRequest("request already processed"));
        if (command.lines().isEmpty()) return new PlaceOrderResult.Rejected(new ValidationError("order must contain at least one line"));
        var order = Order.draft(numbers.next(), command.customerId(), command.lines());
        var payment = payments.authorize(command.customerId(), order.total());
        if (payment instanceof PaymentDecision.Declined declined) {
            order.reject(declined.reason(), clock); orders.save(command.requestId(), order); outbox.appendAll(order.pullEvents()); return new PlaceOrderResult.Rejected(new PaymentDeclined(declined.reason()));
        }
    order.accept(clock); orders.save(command.requestId(), order); outbox.appendAll(order.pullEvents()); return new PlaceOrderResult.Accepted(order.id(), order.total());
}
);
}
}

09. Runtime-Trade-offs und Entscheidungsmatrix

Framework-Auswahl ist keine reine Geschmacksfrage. Sie ist eine betriebliche Entscheidung. Man muss Team-Know-how, Oekosystem, Startup-Zeit, Memory Footprint, Standards, Cloud-Fit, Debugging, Monitoring, Testbarkeit, Migrationskosten und Lizenz-/Supportstrategie betrachten.

Entscheidungsmatrix:

Kriterium Spring Boot Jakarta EE Quarkus Micronaut
Produktivitaet sehr hoch mittel bis hoch hoch hoch
Standardisierung mittel sehr hoch mittel mittel
Container-Fokus hoch runtime-abhaengig sehr hoch hoch
Startzeit-Fokus mittel runtime-abhaengig hoch hoch
Oekosystem sehr gross server-/vendor-abhaengig wachsend fokussiert
Lernrisiko Auto-Magie Servermodell Build-Time-Modell Compile-Time-Modell
Beste Rolle universelle Enterprise-Services Standard-/Serverlandschaften cloud-native Services leichte, schnelle Services

10. Testing-Strategie fuer Framework-Projekte

Framework-Tests sind oft langsam, wenn man alles mit voller Runtime startet. Das ist nicht noetig. Ein gutes Testmodell trennt:

  1. Domain Tests ohne Framework.
  2. Application-Service-Tests mit Fakes.
  3. Adapter-Tests fuer HTTP/DTO/Statuscodes.
  4. Contract Tests fuer Ports.
  5. wenige, aber echte Integrationstests.
  6. Smoke Tests fuer Packaging und Runtime-Start.

Im Code-Lab wird derselbe Use Case ueber vier Adapter getestet. Das beweist: Frameworks duerfen unterschiedlich aussehen, aber fachliche Ergebnisse muessen gleich bleiben.

JAVA
package com.example.framework.deepdive;
import com.example.framework.deepdive.adapters.*;
import com.example.framework.deepdive.application.*;
import com.example.framework.deepdive.domain.*;
import com.example.framework.deepdive.frameworks.spring.*;
import com.example.framework.deepdive.frameworks.jakarta.*;
import com.example.framework.deepdive.frameworks.quarkus.*;
import com.example.framework.deepdive.frameworks.micronaut.*;
import java.time.Clock;
import java.util.List;
public final class FrameworkDeepDiveTestRunner {
    public static void main(String[] args) {
        sameUseCaseContractForEveryFramework();
        duplicateRequestIsConflict();
        declinedPaymentIsBusinessProblem();
        System.out.println("Kapitel");
    }
static PlaceOrderUseCase newUseCase(ApprovingPaymentAdapter payment, InMemoryOrderRepository orders, InMemoryOutbox outbox) {
    return new PlaceOrderUseCase(orders, payment, outbox, new SimpleTransactionTemplate(), new OrderNumberGenerator(), Clock.systemUTC());
}
static void sameUseCaseContractForEveryFramework() {
    var orders = new InMemoryOrderRepository();
    var outbox = new InMemoryOutbox();
    var uc = newUseCase(new ApprovingPaymentAdapter(), orders, outbox);
    var spring = new SpringBootOrderController(uc).place(new com.example.framework.deepdive.frameworks.spring.PlaceOrderHttpRequest("REQ-S", "C-1", List.of(new com.example.framework.deepdive.frameworks.spring.OrderLineDto("SKU", 1, "10.00"))));
    var jakarta = new JakartaOrderResource(uc).place(new com.example.framework.deepdive.frameworks.jakarta.PlaceOrderHttpRequest("REQ-J", "C-1", List.of(new com.example.framework.deepdive.frameworks.jakarta.OrderLineDto("SKU", 1, "10.00"))));
    var quarkus = new QuarkusOrderResource(uc).place(new com.example.framework.deepdive.frameworks.quarkus.PlaceOrderHttpRequest("REQ-Q", "C-1", List.of(new com.example.framework.deepdive.frameworks.quarkus.OrderLineDto("SKU", 1, "10.00"))));
    var micronaut = new MicronautOrderController(uc).place(new com.example.framework.deepdive.frameworks.micronaut.PlaceOrderHttpRequest("REQ-M", "C-1", List.of(new com.example.framework.deepdive.frameworks.micronaut.OrderLineDto("SKU", 1, "10.00"))));
    assertEquals(201, spring.status());
    assertEquals(201, jakarta.status());
    assertEquals(201, quarkus.status());
    assertEquals(201, micronaut.status());
    assertEquals(4, orders.size());
    assertEquals(4, outbox.pendingCount());
}
static void duplicateRequestIsConflict() {
    var orders = new InMemoryOrderRepository();
    var outbox = new InMemoryOutbox();
    var uc = newUseCase(new ApprovingPaymentAdapter(), orders, outbox);
    var c = new SpringBootOrderController(uc);
    var req = new com.example.framework.deepdive.frameworks.spring.PlaceOrderHttpRequest("REQ-DUP", "C-1", List.of(new com.example.framework.deepdive.frameworks.spring.OrderLineDto("SKU", 1, "10.00")));
    assertEquals(201, c.place(req).status());
    assertEquals(409, c.place(req).status());
}
static void declinedPaymentIsBusinessProblem() {
    var orders = new InMemoryOrderRepository();
    var outbox = new InMemoryOutbox();
    var uc = new PlaceOrderUseCase(orders, new DecliningPaymentAdapter(), outbox, new SimpleTransactionTemplate(), new OrderNumberGenerator(), Clock.systemUTC());
    var c = new SpringBootOrderController(uc);
    var r = c.place(new com.example.framework.deepdive.frameworks.spring.PlaceOrderHttpRequest("REQ-DEC", "C-1", List.of(new com.example.framework.deepdive.frameworks.spring.OrderLineDto("SKU", 1, "10.00"))));
    assertEquals(422, r.status());
    assertEquals(1, orders.size());
    assertEquals(1, outbox.pendingCount());
}
static void assertEquals(Object e, Object a) {
    if (!e.equals(a)) throw new AssertionError("expected " + e + " but got " + a);
}
}

11. Framework-Anti-Patterns

Die haeufigsten Anti-Patterns in Enterprise-Java-Projekten:

Gegenmittel:

12. Framework-Wechsel realistisch planen

Ein Framework-Wechsel ist selten ein reines Code-Such-und-Ersetz-Projekt. Die eigentliche Frage lautet: Wie viel Fachlogik ist mit dem Framework verwoben? Wenn Business-Regeln in Controllers, Repositories und Annotationen versteckt sind, wird ein Wechsel teuer. Wenn der fachliche Kern sauber ist, ist der Wechsel vor allem Adapter- und Infrastrukturarbeit.

Migrationsreihenfolge:

  1. Use Cases aus Controllern extrahieren.
  2. Ports fuer Infrastruktur einfuehren.
  3. DTO-Mapping isolieren.
  4. Transaktionsgrenzen dokumentieren.
  5. Contract Tests schreiben.
  6. neuen Framework-Adapter parallel aufbauen.
  7. Traffic schrittweise umschalten.
  8. alte Adapter entfernen.

13. Code-Lab Walkthrough

Das Lab ist bewusst JDK-only, damit es ohne externe Framework-Downloads kompiliert. Es simuliert die Framework-Adapter durch kleine Annotationen und Controller-Klassen. Der Lernwert liegt nicht darin, echte Server zu starten, sondern die Architekturgrenze zu beweisen.

Wichtige Dateien:

Datei Zweck
PlaceOrderUseCase.java frameworkfreie Use-Case-Orchestrierung
Order.java Aggregate Root mit Domain Events
PaymentPort.java Port fuer externes Payment
OutboxPort.java Port fuer transaktionale Event-Vorbereitung
SpringBootOrderController.java Spring-artiger Adapter
JakartaOrderResource.java Jakarta-artiger Adapter
QuarkusOrderResource.java Quarkus-artiger Adapter
MicronautOrderController.java Micronaut-artiger Adapter
FrameworkDeepDiveTestRunner.java Contract Tests ueber alle Adapter

14. Produktionscheckliste

Vor produktiver Framework-Entscheidung klaeren:

⌂ Cockpit