Event-Driven with Spring Cloud Stream

Declarative binding to Kafka/RabbitMQ, with a complete producer/consumer example and consumer groups.

The problem with direct service-to-service calls

A service calling another service directly (via RestClient/WebClient) couples the caller to the callee being available right now — if payments-service is down or slow, orders-service either waits or fails right along with it. Event-driven communication decouples this: instead of calling another service directly, a service publishes an event to a message broker, and any interested service consumes it independently, on its own schedule, whether or not the publisher is even still running.

Spring Cloud Stream's binder abstraction

Spring Cloud Stream lets you write messaging logic against a broker-agnostic model — a binder is the piece that adapts this model to a specific broker (Kafka, RabbitMQ), so the same application code works against either, with the broker choice made entirely in configuration and a dependency swap:

HTML
<!-- Kafka binder -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-stream-binder-kafka</artifactId>
</dependency>
HTML
<!-- RabbitMQ binder -- same application code, different dependency -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-stream-binder-rabbit</artifactId>
</dependency>

The functional programming model

Modern Spring Cloud Stream binds plain java.util.function interfaces to channels declaratively — a Supplier produces messages, a Consumer receives them, and a Function does both (consumes one, produces another). In practice, most producers publish on demand — when an order is actually placed — rather than being polled on a schedule, which is what StreamBridge is for:

Java
@Service
public class OrderService {

    private final StreamBridge streamBridge;
    private final OrderRepository orderRepository;

    public OrderService(StreamBridge streamBridge, OrderRepository orderRepository) {
        this.streamBridge = streamBridge;
        this.orderRepository = orderRepository;
    }

    public Order placeOrder(PlaceOrderRequest request) {
        Order order = orderRepository.save(new Order(request.sku(), request.quantity()));

        streamBridge.send("orderPlaced-out-0", new OrderPlacedEvent(order.getId(), order.getSku(), order.getQuantity()));

        return order;
    }
}
Java
public record OrderPlacedEvent(Long orderId, String sku, int quantity) {}

A complete consumer

A separate service — say, inventory-service — reacts to that event with nothing more than a Consumer bean:

Java
@Configuration
public class InventoryEventsConfig {

    private static final Logger log = LoggerFactory.getLogger(InventoryEventsConfig.class);

    @Bean
    public Consumer<OrderPlacedEvent> orderPlaced(InventoryService inventoryService) {
        return event -> {
            log.info("Reserving {} units of {} for order {}", event.quantity(), event.sku(), event.orderId());
            inventoryService.reserveStock(event.sku(), event.quantity());
        };
    }
}

Binding configuration

The functional bean name (orderPlaced) maps to a <bean-name>-in-0/<bean-name>-out-0 binding, which is then wired to an actual topic/exchange in configuration:

YAML
# orders-service (producer)
spring:
  cloud:
    stream:
      bindings:
        orderPlaced-out-0:
          destination: order-placed-events
    function:
      definition: orderPlaced
YAML
# inventory-service (consumer)
spring:
  cloud:
    stream:
      bindings:
        orderPlaced-in-0:
          destination: order-placed-events
          group: inventory-service   # a consumer group -- see below
    function:
      definition: orderPlaced

Neither service's Java code mentions Kafka or RabbitMQ directly at all — destination names a logical topic/exchange, and the binder dependency on the classpath determines which actual broker it maps to.

Consumer groups

group: inventory-service matters the moment there's more than one instance of inventory-service running: every instance in the same group shares the incoming events (each event is delivered to exactly one instance in the group, load-balanced), while a different service (say, notifications-service, also consuming order-placed-events but in its own group) gets every event independently, regardless of how inventory-service's group consumes them.

Plaintext
                          topic: order-placed-events
                                     |
              +------------------------+------------------------+
              v                                                  v
   group: inventory-service                          group: notifications-service
   +----------+  +----------+                        +------------------+
   | instance A |  | instance B |  <- share events      | its own instance(s) |  <- gets EVERY event too,
   +----------+  +----------+     (each event once)    +------------------+     independent of the other group

Common mistakes

  • Omitting group on a consumer with multiple running instances — without it, every instance may receive every event independently (duplicate processing) instead of sharing the load as intended.
  • Coupling a consumer's logic to the producer's internal implementation details instead of a stable event schema — the whole point of an event is that the consumer only depends on the event's shape, not on how or why the producer decided to publish it.
  • Treating StreamBridge.send(...) as if it were guaranteed instantly delivered and processed — like any asynchronous messaging, it's eventually consistent, and code relying on the event having been fully processed by the time the producing method returns is making a false assumption.
  • Not planning for a consumer failing partway through processing an event — without idempotent handling (safe to process the same event twice) or proper acknowledgment/retry configuration, a redelivered message after a crash can cause duplicate side effects (double-reserving stock, in this example).