Next steps: WebFlux, messaging and native images

Choosing reactive or servlet, using WebClient correctly, integrating Kafka, building a GraalVM native image, and where to read next.

Servlet or reactive

DimensionSpring MVCSpring WebFlux
ThreadingOne thread per requestEvent loop, few threads
Blocking JDBC/JPAThe natural fitBlocks the event loop - avoid
Best atCPU work, existing JPA codeMany slow downstream calls, streaming
DebuggingFamiliar stack tracesReactive stack traces, needs practice
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
Flux<PriceTick> stream() {
  return Flux.interval(Duration.ofSeconds(1))
      .flatMap(i -> pricing.latest("ACME"))
      .onErrorResume(e -> Flux.empty());
}

var client = WebClient.builder()
    .baseUrl("https://api.example.com")
    .defaultHeader(HttpHeaders.USER_AGENT, "orders/1.0")
    .build();

Mono<Quote> quote = client.get().uri("/quote/{s}", symbol)
    .retrieve()
    .onStatus(HttpStatusCode::is4xxClientError, r -> Mono.error(new QuoteMissingException()))
    .bodyToMono(Quote.class)
    .timeout(Duration.ofSeconds(3))
    .retryWhen(Retry.backoff(2, Duration.ofMillis(200)));
💡
Nothing improves by being reactive by accident. Add it when you have a genuine concurrency-of-waiting problem - thousands of slow downstream calls, streaming responses, or SSE - not because it sounds faster.

Messaging with Kafka

@Component
class OrderEvents {
  private final KafkaTemplate<String, OrderPlaced> template;
  OrderEvents(KafkaTemplate<String, OrderPlaced> template) { this.template = template; }

  void publish(OrderPlaced event) {
    template.send("orders.placed", event.orderId(), event);
  }
}

@Component
class BillingListener {
  @KafkaListener(topics = "orders.placed", groupId = "billing")
  void on(OrderPlaced event, Acknowledgment ack) {
    if (processed.contains(event.eventId())) { ack.acknowledge(); return; }  // idempotent
    try {
      billing.charge(event);
      ack.acknowledge();
    } catch (TransientException e) {
      throw e;   // let the container retry, then route to the DLT
    }
  }
}
  • Consumers are at-least-once. Every handler must be idempotent, keyed on the event id.
  • The message key determines the partition, and therefore the ordering guarantee - key by aggregate id.
  • Set spring.kafka.listener.ack-mode deliberately, and configure a dead-letter topic so poison messages do not block a partition.

Native images

./gradlew nativeCompile          # via GraalVM
# or a container build
./gradlew bootBuildImage --imageName=orders-native -Pnative

# run it
./build/native/nativeCompile/orders
# typical result: ~100 MB RSS, 50-100 ms to first response
  • Ahead-of-time compilation closes the world at build time, so reflection, dynamic proxies and some classpath scanning need hints.
  • spring-boot-starter-parent plus the AOT engine covers most of Spring's own reflection, but your code - custom reflection, JPA metamodels, JSON polymorphic types - often needs explicit hints.
  • Startup and memory improve dramatically; peak throughput does not. Native is for scale-to-zero and CLI-style workloads, not a free speed-up.
  • Reading list: the Spring Boot reference on AOT and native images, the Spring for Apache Kafka reference, and the WebFlux section on backpressure.

FAQ

Can I mix MVC and WebFlux?
Yes, but only deliberately: a WebFlux app can use blocking repositories on a bounded elastic scheduler. Mixing them in one context usually means you have chosen the wrong model - pick the one that matches your data access.
Is a native image right for a typical CRUD service?
Usually no. The development friction of reflection hints is real, and the benefit is startup time and footprint - which matters for serverless and CLI, not for a long-running container behind a load balancer.

Packaging and deploying a Spring Boot service Observability with Actuator, logging and metrics

Last refreshed 2026-09-18.