Last Updated: May 31, 2026 at 10:00

Kafka in Micronaut: A Practical Guide for Spring Developers

Build message-driven microservices with Micronaut's compile-time Kafka integration — and see exactly how it compares to Spring Boot

Modern microservices rarely communicate through REST alone. Event-driven architectures built on Kafka have become the backbone of scalable systems, and Micronaut provides first-class support for it. In this guide you will learn how Micronaut's compile-time messaging model works, how to publish and consume messages, and how these patterns compare to the Spring Boot approaches you may already know.

Image

The mental model

Micronaut's messaging support follows the same annotation-driven philosophy as the rest of the framework. Producers and consumers are declared as annotated interfaces and classes, and Micronaut generates the implementation at compile time. You describe what you want to send and receive, and the framework handles the client lifecycle, threading, serialisation, and offset management.

If you are coming from Spring, the structural difference is:

  1. Spring Kafka uses KafkaTemplate for sending and @KafkaListener on methods for receiving.
  2. Micronaut uses @KafkaClient on an interface you declare (Micronaut generates the implementation) and @KafkaListener on a class with @Topic on the handler method.

The annotation names overlap, but the underlying model is different. When Micronaut sees @KafkaClient on your interface, it generates a concrete implementation class at compile time — bytecode written to disk as part of your build. By the time your application starts, that implementation already exists as a .class file. Spring Kafka, by contrast, uses runtime reflection to scan annotations, inspect method signatures, and build proxy objects while the JVM is already running. Micronaut does not eliminate runtime work — network I/O, connection management, and message delivery all still happen at runtime — but it eliminates the reflection-based framework machinery that would otherwise run at startup.

Serialisation in Micronaut: @Serdeable

Before writing a single producer or consumer, there is one concept that every Spring developer needs to understand: @Serdeable.

In Spring Boot, Jackson handles serialisation via runtime reflection. You create a POJO, Jackson inspects it at runtime, and everything works automatically.

Micronaut works differently. One of its core design goals is to eliminate runtime reflection entirely. Instead of Jackson reflection, Micronaut generates serialisers and deserialisers for your types at compile time. To tell Micronaut to generate these for a class, you annotate it with @Serdeable.

import io.micronaut.serde.annotation.Serdeable;

@Serdeable
public class OrderEvent {
private Long orderId;
private String status;
private String customerId;

public OrderEvent() {}

public OrderEvent(Long orderId, String status, String customerId) {
this.orderId = orderId;
this.status = status;
this.customerId = customerId;
}

// getters and setters
}

If you prefer records (Java 16+), @Serdeable works there too:

@Serdeable
public record OrderEvent(Long orderId, String status, String customerId) {}

Any class you intend to send or receive as a Kafka message must be annotated with @Serdeable. If you forget it, you will get a compile-time error — which is exactly the point.

1. Setup

Dependencies (build.gradle)

dependencies {
// Kafka
implementation("io.micronaut.kafka:micronaut-kafka")

// Test
testImplementation("io.micronaut.test:micronaut-test-junit5")
testImplementation("org.testcontainers:testcontainers")
testImplementation("org.testcontainers:kafka")
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.awaitility:awaitility:4.2.0")
}

Kafka configuration (application.yml)

kafka:
bootstrap:
servers: localhost:9092
consumers:
default:
auto:
offset:
reset: earliest

With @Serdeable on your DTOs, Micronaut's Kafka integration infers the correct serialisers automatically. You do not need to configure them explicitly for basic usage.

2. Kafka keys and partitions

Before looking at producers, it is worth understanding Kafka's partitioning model, because it directly affects how you declare your producer methods.

A Kafka topic is divided into partitions. Producers assign each message to a partition, and Kafka guarantees ordering only within a single partition. Consumer group instances each own one or more partitions — so ordering is preserved within a partition but not across the whole topic.

The message key determines which partition a message lands in. Messages with the same key always go to the same partition, which means messages with the same key are always processed in order. A common pattern is to use a business entity ID — such as orderId or customerId — as the key so that all events for one entity are ordered correctly.

// Same key → same partition → guaranteed ordering for that entity
producer.send("ORDER-42", event); // always lands in the same partition as other ORDER-42 events
producer.send("ORDER-99", event); // may land in a different partition

This becomes relevant immediately when you look at @KafkaKey in the producer declaration below.

3. Publishing messages with @KafkaClient

A Kafka producer in Micronaut is a plain interface annotated with @KafkaClient. You declare the methods; Micronaut generates the implementation at compile time.

import io.micronaut.configuration.kafka.annotation.KafkaClient;
import io.micronaut.configuration.kafka.annotation.KafkaKey;
import io.micronaut.configuration.kafka.annotation.Topic;

@KafkaClient
public interface OrderEventProducer {

@Topic("order.events")
void send(@KafkaKey String orderId, OrderEvent event);
}

@KafkaClient marks the interface as a Kafka producer bean. @Topic specifies the topic. @KafkaKey marks which parameter to use as the message key — which, as explained above, controls partition assignment.

The generated implementation handles serialisation, connection management, and error handling. Inject and use it like any other bean:

@Singleton
public class OrderService {

private final OrderRepository orderRepository;
private final OrderEventProducer producer;

public OrderService(OrderRepository orderRepository,
OrderEventProducer producer) {
this.orderRepository = orderRepository;
this.producer = producer;
}

public Order placeOrder(CreateOrderRequest request) {
Order order = orderRepository.save(
new Order(request.getCustomerId(), OrderStatus.CONFIRMED));

producer.send(
String.valueOf(order.getId()),
new OrderEvent(order.getId(), "ORDER_PLACED", order.getCustomerId()));

return order;
}
}

A note on @Transactional and Kafka

Notice the method above does not use @Transactional. This is intentional.

If you wrap a database save and a Kafka publish in a single @Transactional method, they are not part of the same atomic transaction. The database operation and the Kafka publish are completely independent — the transaction controls only the database. It is entirely possible for the database commit to succeed and the Kafka publish to fail, or vice versa.

For introductory use cases this is often acceptable. For production systems that require guaranteed consistency between a database write and a Kafka event, the Transactional Outbox Pattern is the standard approach: you write the event to a database outbox table within the same transaction as your business data, and a separate process reliably publishes it to Kafka. This avoids the dual-write problem entirely.

Producing to dynamic topics

When the topic is not known until runtime:

@KafkaClient
public interface EventProducer {

void send(@Topic String topic,
@KafkaKey String key,
Object payload);
}

Producing with headers

@KafkaClient
public interface OrderEventProducer {

@Topic("order.events")
void send(@KafkaKey String orderId,
OrderEvent event,
@Header("X-Correlation-Id") String correlationId);
}

Waiting for broker acknowledgement

By default, send() returns immediately after handing the message to the Kafka client buffer — at that point, the message has not necessarily reached the broker yet. The client will deliver it asynchronously in the background.

If you need stronger guarantees before continuing, Kafka provides acknowledgement modes controlled by the acks setting:

acks = 0 — the producer does not wait for any acknowledgement from the broker. The message is considered sent the moment it leaves the client buffer. Fastest, but no delivery guarantee.

acks = 1 — the leader broker writes the message to its local log and acknowledges. Fast, but if the leader fails before the message is replicated, it can be lost.

acks = all (or -1) — the leader waits until all in-sync replicas have written the message before acknowledging. Slowest, but guarantees the message survives a broker failure. This is the setting to use when delivery must be confirmed.

In Micronaut, you set this on @KafkaClient and change the return type to RecordMetadata to block until the acknowledgement arrives:

@KafkaClient(acks = AckMode.ALL)
public interface ReliableOrderProducer {

@Topic("order.events")
RecordMetadata sendAndConfirm(@KafkaKey String orderId, OrderEvent event);
}

RecordMetadata is what the broker returns once it has acknowledged the write. It contains the topic name, the partition the message was assigned to, and the offset at which it was written — the message's permanent address in the Kafka log. You can log these for tracing or use the offset for correlation in downstream systems.

RecordMetadata meta = producer.sendAndConfirm(orderId, event);
log.info("Message written to topic={} partition={} offset={}",
meta.topic(), meta.partition(), meta.offset());

Reactive producer

The reactive producer returns a Mono<RecordMetadata> instead of blocking the calling thread while waiting for broker acknowledgement. This fits naturally into a reactive pipeline — you can chain the send with downstream operations and let the Reactor scheduler handle the threading. Use this when your service is already built on Project Reactor and you want to avoid introducing a blocking call into an otherwise non-blocking flow.

import reactor.core.publisher.Mono;

@KafkaClient
public interface ReactiveOrderProducer {

@Topic("order.events")
Mono<RecordMetadata> send(@KafkaKey String orderId, OrderEvent event);
}

4. Consuming messages with @KafkaListener

import io.micronaut.configuration.kafka.annotation.KafkaListener;
import io.micronaut.configuration.kafka.annotation.OffsetReset;
import io.micronaut.configuration.kafka.annotation.Topic;

@KafkaListener(groupId = "notification-service", offsetReset = OffsetReset.EARLIEST)
public class OrderEventConsumer {

private final NotificationService notificationService;

public OrderEventConsumer(NotificationService notificationService) {
this.notificationService = notificationService;
}

@Topic("order.events")
public void consume(OrderEvent event) {
notificationService.sendOrderConfirmation(
event.getCustomerId(), event.getOrderId());
}
}

groupId is the Kafka consumer group — all instances with the same group ID share the partition load. offsetReset controls what happens when no committed offset exists: EARLIEST starts from the beginning of the topic, LATEST starts from new messages only.

The Spring difference: in Spring Kafka, @KafkaListener goes on the method. In Micronaut, @KafkaListener marks the class and @Topic marks the handler method. You can have multiple @Topic-annotated methods in the same @KafkaListener class, each consuming from a different topic.

Accessing message metadata

Micronaut automatically binds Kafka metadata to method parameters by type and annotation — you simply declare what you need and the framework injects the values for each message. This is useful for structured logging, tracing correlation IDs across services, or routing logic that depends on which partition or offset a message came from. The parameter names do not matter; Micronaut resolves them by type (long for offset, int for partition, String for topic) and by annotation (@KafkaKey, @Header).

@Topic("order.events")
public void consume(
@KafkaKey String key,
OrderEvent event,
@Header("X-Correlation-Id") String correlationId,
long offset,
int partition,
String topic) {

log.info("Received {} from topic={} partition={} offset={} correlationId={}",
event.getOrderId(), topic, partition, offset, correlationId);

notificationService.sendOrderConfirmation(event.getCustomerId(), event.getOrderId());
}

Batch consumers

Rather than invoking the handler once per message, Micronaut delivers all messages polled in a single fetch cycle as a list. This reduces per-message overhead and is particularly effective when the processing cost is dominated by I/O — such as bulk database writes or external API calls — where batching amortises that cost across many records. The trade-off is that a failure in the batch requires you to decide whether to retry the entire batch or implement your own per-record error handling.

@KafkaListener(groupId = "analytics-service", batch = true)
public class AnalyticsConsumer {

@Topic("order.events")
public void consumeBatch(List<OrderEvent> events) {
analyticsService.processBatch(events);
}
}

Manual offset acknowledgement

By default, Micronaut commits the offset automatically once your handler method returns without throwing an exception — meaning Kafka considers the message processed. Disabling this with OffsetStrategy.DISABLED gives you full control: you commit only after confirming your business logic succeeded, which prevents message loss on failure. The downside is that if your service crashes after processing but before committing, the message will be redelivered — so your consumer should be designed to handle duplicates.

import io.micronaut.configuration.kafka.annotation.OffsetStrategy;
import org.apache.kafka.clients.consumer.Consumer;

@KafkaListener(
groupId = "payment-service",
offsetStrategy = OffsetStrategy.DISABLED)
public class PaymentEventConsumer {

@Topic("order.events")
public void consume(OrderEvent event, Consumer<?, ?> kafkaConsumer,
TopicPartition topicPartition, long offset) {
try {
paymentService.process(event);

kafkaConsumer.commitSync(
Map.of(topicPartition, new OffsetAndMetadata(offset + 1)));

} catch (RetryableException e) {
// Do not commit — message will be redelivered
log.warn("Retryable failure for order {}", event.getOrderId());
}
}
}

Error handling and dead letter topics

When a consumer throws an uncaught exception, Micronaut's default behaviour is ErrorStrategy.SEEK — it seeks back to the failed message's offset and retries it. This is fine for transient failures, but for messages that repeatedly fail (poison pills), retrying forever will stall the partition.

The standard solution is to route unprocessable messages to a dead letter topic (DLT) — a regular Kafka topic where failed messages sit until someone investigates or replays them. In Micronaut, you do this manually by implementing KafkaListenerExceptionHandler and injecting a producer that forwards the message:

import io.micronaut.configuration.kafka.exceptions.KafkaListenerException;
import io.micronaut.configuration.kafka.exceptions.KafkaListenerExceptionHandler;

@KafkaListener(groupId = "order-processor")
public class ResilientOrderConsumer implements KafkaListenerExceptionHandler {

private final OrderEventProducer dltProducer;

public ResilientOrderConsumer(OrderEventProducer dltProducer) {
this.dltProducer = dltProducer;
}

@Topic("order.events")
public void consume(OrderEvent event) {
orderProcessingService.process(event);
}

@Override
public void handle(KafkaListenerException exception) {
Object failedMessage = exception.getConsumerRecord()
.map(ConsumerRecord::value)
.orElse(null);

log.error("Failed to process message: {}", failedMessage, exception);

if (failedMessage instanceof OrderEvent event) {
dltProducer.send("order.events.dlt",
event.getOrderId().toString(), event);
}
}
}

Error handling in Micronaut Kafka goes significantly deeper than what is covered here — including retry strategies, exponential backoff, and more granular exception handling. These will be covered in a later article; in the meantime refer to the Micronaut Kafka documentation for the full picture.

Note: The KafkaListenerExceptionHandler API has evolved across Micronaut Kafka releases. As of Micronaut Kafka 5.0+, the preferred approach is using @KafkaListenerErrorHandler as an annotation rather than implementing the interface directly. Verify the correct approach against the Micronaut Kafka documentation for the version you are targeting before using this in production.

5. A complete Kafka example

An order placement flow where a service publishes an event and two downstream consumer groups react independently:

// --- Event ---

@Serdeable
public class OrderEvent {
private Long orderId;
private String status;
private String customerId;

public OrderEvent() {}

public OrderEvent(Long orderId, String status, String customerId) {
this.orderId = orderId;
this.status = status;
this.customerId = customerId;
}

// getters and setters
}

// --- Producer ---

@KafkaClient
public interface OrderEventProducer {

@Topic("order.events")
void send(@KafkaKey String orderId, OrderEvent event);
}

// --- Service that publishes ---

@Singleton
public class OrderService {

private final OrderRepository orderRepository;
private final OrderEventProducer producer;

public OrderService(OrderRepository orderRepository,
OrderEventProducer producer) {
this.orderRepository = orderRepository;
this.producer = producer;
}

public Order placeOrder(CreateOrderRequest request) {
Order order = orderRepository.save(new Order(request.getCustomerId()));
producer.send(
String.valueOf(order.getId()),
new OrderEvent(order.getId(), "ORDER_PLACED", request.getCustomerId()));
return order;
}
}

// --- Consumer 1: notifications ---

@KafkaListener(groupId = "notification-service")
public class OrderNotificationConsumer {

private final NotificationService notificationService;

public OrderNotificationConsumer(NotificationService notificationService) {
this.notificationService = notificationService;
}

@Topic("order.events")
public void consume(OrderEvent event) {
if ("ORDER_PLACED".equals(event.getStatus())) {
notificationService.sendConfirmation(
event.getCustomerId(), event.getOrderId());
}
}
}

// --- Consumer 2: inventory ---

@KafkaListener(groupId = "inventory-service")
public class InventoryReservationConsumer {

private final InventoryService inventoryService;

public InventoryReservationConsumer(InventoryService inventoryService) {
this.inventoryService = inventoryService;
}

@Topic("order.events")
public void consume(OrderEvent event) {
if ("ORDER_PLACED".equals(event.getStatus())) {
inventoryService.reserve(event.getOrderId());
}
}
}

Because notification-service and inventory-service have different group IDs, both consumers receive every message independently. Kafka fan-out at the consumer group level is free — you just subscribe with a different groupId.

6. Testing Kafka applications in Micronaut

Why you cannot assert immediately after send()

Kafka message delivery is asynchronous. The consumer runs in a separate thread, polling the broker on its own schedule. Your test thread and the consumer thread are completely independent. This means:

  1. You cannot assert immediately after calling producer.send()
  2. Thread.sleep(3000) is fragile — too short sometimes, unnecessarily slow when the message arrives in 50ms

The standard solution is Awaitility, which lets you express "wait until this condition is true, up to a timeout":

// ❌ Fragile
producer.send("ORDER-1", event);
Thread.sleep(3000);
assertThat(consumer.getReceivedEvents()).hasSize(1);

// ✅ Correct
producer.send("ORDER-1", event);
await().atMost(10, SECONDS)
.pollInterval(100, MILLISECONDS)
.until(() -> consumer.getReceivedEvents().size() >= 1);
assertThat(consumer.getReceivedEvents()).hasSize(1);

The test passes the moment the condition is satisfied — typically within a few hundred milliseconds — rather than waiting out a fixed sleep.

The collector consumer pattern

The cleanest pattern for Kafka integration tests is a collector consumer — a test-only bean that consumes from the topic and stores messages in a thread-safe list:

import io.micronaut.configuration.kafka.annotation.KafkaListener;
import io.micronaut.configuration.kafka.annotation.OffsetReset;
import io.micronaut.configuration.kafka.annotation.Topic;
import io.micronaut.context.annotation.Requires;

import java.util.concurrent.CopyOnWriteArrayList;

@KafkaListener(
groupId = "test-collector",
offsetReset = OffsetReset.EARLIEST)
@Requires(env = "test")
public class OrderEventCollector {

private final CopyOnWriteArrayList events = new CopyOnWriteArrayList<>();

@Topic("order.events")
public void collect(OrderEvent event) {
events.add(event);
}

public List getEvents() {
return Collections.unmodifiableList(events);
}

public void clear() {
events.clear();
}
}

CopyOnWriteArrayList is thread-safe — the Kafka consumer thread writes while your test thread reads. @Requires(env = "test") ensures this bean is never instantiated in production.

Test configuration

src/test/resources/application-test.yml:

kafka:
bootstrap:
servers: ${KAFKA_BOOTSTRAP_SERVERS:`localhost:9092`}
consumers:
default:
auto:
offset:
reset: earliest

The bootstrap servers are overridden per-test with the Testcontainers container address.

Integration test: producer → consumer

@MicronautTest(environments = "test")
@Testcontainers
class OrderKafkaFlowTest {

@Container
static final KafkaContainer kafka =
new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.4.0"));

@BeforeAll
static void overrideKafkaBootstrap() {
System.setProperty("kafka.bootstrap.servers", kafka.getBootstrapServers());
}

@Inject
OrderEventProducer producer;

@Inject
OrderEventCollector collector;

@BeforeEach
void setUp() {
collector.clear();
}

@Test
void producerSendsEventAndCollectorReceivesIt() {
OrderEvent event = new OrderEvent(1L, "ORDER_PLACED", "CUST-1");

producer.send("1", event);

await().atMost(10, SECONDS)
.pollInterval(100, MILLISECONDS)
.until(() -> collector.getEvents().size() >= 1);

OrderEvent received = collector.getEvents().get(0);
assertThat(received.getOrderId()).isEqualTo(1L);
assertThat(received.getStatus()).isEqualTo("ORDER_PLACED");
assertThat(received.getCustomerId()).isEqualTo("CUST-1");
}

@Test
void multipleEventsAreAllDelivered() {
List.of(
new OrderEvent(1L, "ORDER_PLACED", "CUST-1"),
new OrderEvent(2L, "ORDER_PLACED", "CUST-2"),
new OrderEvent(3L, "ORDER_PLACED", "CUST-3")
).forEach(e -> producer.send(String.valueOf(e.getOrderId()), e));

await().atMost(15, SECONDS)
.until(() -> collector.getEvents().size() >= 3);

assertThat(collector.getEvents())
.extracting(OrderEvent::getCustomerId)
.containsExactlyInAnyOrder("CUST-1", "CUST-2", "CUST-3");
}
}

Integration test: verifying a service publishes as a side effect

@MicronautTest(environments = "test")
@Testcontainers
class OrderServicePublishesEventTest {

@Container
static final KafkaContainer kafka =
new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.4.0"));

@Inject
OrderService orderService;

@Inject
OrderEventCollector collector;

@MockBean(OrderRepository.class)
OrderRepository mockRepo() {
return Mockito.mock(OrderRepository.class);
}

@Inject
OrderRepository orderRepository;

@BeforeEach
void setUp() {
collector.clear();
when(orderRepository.save(any())).thenReturn(new Order(42L, "CUST-1"));
}

@Test
void placeOrderPublishesOrderPlacedEvent() {
orderService.placeOrder(new CreateOrderRequest("CUST-1"));

await().atMost(10, SECONDS)
.until(() -> collector.getEvents()
.stream()
.anyMatch(e -> "ORDER_PLACED".equals(e.getStatus())));

OrderEvent published = collector.getEvents().stream()
.filter(e -> "ORDER_PLACED".equals(e.getStatus()))
.findFirst()
.orElseThrow();

assertThat(published.getOrderId()).isEqualTo(42L);
assertThat(published.getCustomerId()).isEqualTo("CUST-1");
}
}

Unit test: consumer logic in isolation

The consumer's consume() method is a regular Java method. For unit tests of the consumer's business logic, you do not need Kafka at all:

class OrderNotificationConsumerTest {

private NotificationService notificationService;
private OrderNotificationConsumer consumer;

@BeforeEach
void setUp() {
notificationService = Mockito.mock(NotificationService.class);
consumer = new OrderNotificationConsumer(notificationService);
}

@Test
void sendsConfirmationForOrderPlacedEvents() {
OrderEvent event = new OrderEvent(1L, "ORDER_PLACED", "CUST-1");

consumer.consume(event);

verify(notificationService).sendConfirmation("CUST-1", 1L);
}

@Test
void ignoresNonOrderPlacedEvents() {
OrderEvent event = new OrderEvent(1L, "ORDER_CANCELLED", "CUST-1");

consumer.consume(event);

verifyNoInteractions(notificationService);
}
}

This is the cheapest test to write and the fastest to run. Favour unit tests for consumer logic and reserve the Testcontainers integration tests for verifying that the messaging infrastructure is wired up correctly end-to-end.

7. Spring to Micronaut quick reference

Kafka producer: Spring uses KafkaTemplate; Micronaut uses a @KafkaClient interface whose implementation is generated at compile time.

Kafka consumer: Spring uses @KafkaListener on a method; Micronaut uses @KafkaListener on the class and @Topic on the handler method.

Message key: Spring uses the ProducerRecord constructor; Micronaut uses the @KafkaKey parameter annotation.

Batch consumer: Spring accepts List<ConsumerRecord> as a parameter; Micronaut uses batch = true in @KafkaListener.

Test Kafka broker: Spring supports both @EmbeddedKafka and Testcontainers; Micronaut uses Testcontainers KafkaContainer.

Serialisation: Spring relies on Jackson reflection at runtime; Micronaut generates serialisers at compile time via @Serdeable.

N

About N Sharma

Lead Architect at StackAndSystem

N Sharma is a technologist with over 28 years of experience in software engineering, system architecture, and technology consulting. He holds a Bachelor’s degree in Engineering, a DBF, and an MBA. His work focuses on research-driven technology education—explaining software architecture, system design, and development practices through structured tutorials designed to help engineers build reliable, scalable systems.

Disclaimer

This article is for educational purposes only. Assistance from AI-powered generative tools was taken to format and improve language flow. While we strive for accuracy, this content may contain errors or omissions and should be independently verified.

Kafka in Micronaut: A Practical Guide for Spring Boot Developers