Das nimmst du mit
- Entscheidungskriterien gewichten
- Runtime-Modell verstehen
- Native Trade-offs kennen
- Migration realistisch planen
Kapitelkompass
Spring Boot, Jakarta EE, Quarkus und Micronaut unterscheiden sich nicht nur in APIs, sondern in Build, Startzeit, Ökosystem und Betrieb.
Ein Team benötigt schnelle Skalierung, bekannte Libraries und langfristig verfügbare Betriebskenntnisse.
Nach Team, Plattform, Libraries und SLOs entscheiden; Benchmarks allein reichen nicht.
Ein Frameworkwechsel ohne fachlichen oder betrieblichen Nutzen erzeugt nur Migrationskosten.
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?
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:
@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.
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:
Wichtig: Der fachliche Kern weiss nicht, ob er von Spring, Jakarta, Quarkus, Micronaut, CLI, Batch oder Test aufgerufen wurde.
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.
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.
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:
@Transactional nur an klaren
Application-Service-Grenzen nutzen.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:
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:
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.
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.
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());
}
);
}
}
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 |
Framework-Tests sind oft langsam, wenn man alles mit voller Runtime startet. Das ist nicht noetig. Ein gutes Testmodell trennt:
Im Code-Lab wird derselbe Use Case ueber vier Adapter getestet. Das beweist: Frameworks duerfen unterschiedlich aussehen, aber fachliche Ergebnisse muessen gleich bleiben.
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);
}
}
Die haeufigsten Anti-Patterns in Enterprise-Java-Projekten:
@Transactional an jedem Service, ohne
Use-Case-Grenze.Gegenmittel:
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:
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 |
Vor produktiver Framework-Entscheidung klaeren: