Course topics

By WebNest Studio

Spring Boot Tutorial

Event-Driven Microservices with Spring Cloud Stream

Writing Kafka or RabbitMQ code directly ties your services to one broker's API. Spring Cloud Stream lets you write messaging logic as plain Java functions — Supplier, Function and Consumer beans — and binds them to broker destinations through configuration. The same code runs on Kafka, RabbitMQ, Pulsar or cloud brokers by swapping the binder dependency.

This lesson builds an order-processing flow across services with Spring Cloud Stream: producing events with StreamBridge, consuming and transforming them with functional beans, consumer groups and partitioning, error handling with dead-letter queues, and testing with the test binder.

Functional Bindings

Declare a @Bean of type Consumer<OrderPlaced> named reserveStock, and Spring Cloud Stream creates an input binding reserveStock-in-0. A Function<A, B> gets both -in-0 and -out-0 bindings — it consumes, transforms and publishes. Map bindings to topics/exchanges with spring.cloud.stream.bindings.<binding>.destination. If several functions exist, list them in spring.cloud.function.definition.

Publishing from Business Code

To send an event from a REST controller or service method (not on a schedule), inject StreamBridge and call streamBridge.send("orders-out-0", event). Messages are serialised as JSON by default, with a contentType header.

Consumer Groups and Partitions

Set group on an input binding so that multiple instances of the same service share the work (each message is processed by one instance), while different services each receive every message. Partitioning by a key (such as order id) keeps related events in order and on the same instance.

Error Handling

Failed messages are retried (maxAttempts, back-off settings on the consumer binding). After retries, enable a dead-letter queue (enableDlq on Kafka or RabbitMQ binder properties) so a poison message is parked for inspection instead of blocking the stream. As always with messaging, consumers must be idempotent.

Examples

Dependencies and bindings configuration

Java
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-stream</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-stream-binder-kafka</artifactId>   <!-- or -binder-rabbit -->
</dependency>

# inventory-service application.yml
spring:
  cloud:
    function:
      definition: reserveStock
    stream:
      kafka:
        binder:
          brokers: localhost:9092
      bindings:
        reserveStock-in-0:
          destination: orders.placed
          group: inventory-service
          consumer:
            max-attempts: 3
        reserveStock-out-0:
          destination: stock.reserved
Output
Created binding reserveStock-in-0 -> topic orders.placed (group inventory-service)
Created binding reserveStock-out-0 -> topic stock.reserved

Producing with StreamBridge and processing with a Function

Java
// order-service
public record OrderPlaced(String orderId, List<Line> lines) {}
public record Line(String sku, int qty) {}

@RestController
public class OrderController {

    private final StreamBridge streamBridge;

    public OrderController(StreamBridge streamBridge) {
        this.streamBridge = streamBridge;
    }

    @PostMapping("/api/orders")
    public ResponseEntity<Void> place(@RequestBody OrderPlaced order) {
        // (save the order first — ideally via an outbox)
        streamBridge.send("orders.placed", order);
        return ResponseEntity.accepted().build();
    }
}

// inventory-service: consumes OrderPlaced, emits StockReserved
public record StockReserved(String orderId, boolean success) {}

@Configuration
public class InventoryFunctions {

    @Bean
    Function<OrderPlaced, StockReserved> reserveStock(StockService stock) {
        return order -> {
            boolean ok = stock.tryReserve(order.orderId(), order.lines());   // idempotent by orderId
            return new StockReserved(order.orderId(), ok);
        };
    }
}

// notification-service
@Bean
Consumer<StockReserved> notifyCustomer(Notifier notifier) {
    return event -> notifier.stockResult(event.orderId(), event.success());
}
Output
POST /api/orders {"orderId":"WN-10231","lines":[{"sku":"HD-NAVY-M","qty":1}]} -> 202 Accepted
inventory-service: reserved stock for WN-10231
notification-service: "Good news! Your order WN-10231 is confirmed."

Testing with the test binder

Java
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-stream-test-binder</artifactId>
    <scope>test</scope>
</dependency>

@SpringBootTest
@Import(TestChannelBinderConfiguration.class)
class ReserveStockTest {

    @Autowired InputDestination input;
    @Autowired OutputDestination output;
    @Autowired JsonMapper json;

    @Test
    void reservesStockAndPublishesResult() throws Exception {
        input.send(MessageBuilder.withPayload(
            new OrderPlaced("WN-1", List.of(new Line("HD-NAVY-M", 1)))).build(), "orders.placed");

        Message<byte[]> result = output.receive(1000, "stock.reserved");
        StockReserved event = json.readValue(result.getPayload(), StockReserved.class);
        assertThat(event.success()).isTrue();
    }
}
Output
ReserveStockTest > reservesStockAndPublishesResult() PASSED (no broker needed)

Common Mistakes

  • Forgetting spring.cloud.function.definition when several function beans exist, so none are bound.
  • Omitting the consumer group, so every instance of a service processes every message.
  • Publishing events before the database transaction commits, or without an outbox, causing inconsistencies.
  • Non-idempotent consumers that double-process redelivered messages.
  • Not configuring a DLQ, letting a poison message block a partition or queue.

Key Points to Remember

  • Spring Cloud Stream binds Supplier/Function/Consumer beans to broker destinations by configuration.
  • Binders (Kafka, RabbitMQ, ...) make messaging code broker-independent.
  • StreamBridge sends events from business code; bindings named <function>-in-0/-out-0.
  • Consumer groups share work across instances; partitions preserve per-key ordering.
  • Configure retries and DLQs, keep consumers idempotent, and test with the test binder.

Practice the examples

Change an input, predict the result, then compare it with the output. Explain why the result changes.

Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.