← Back to Presentations

Spring Integration Cheatsheet

Annotation-Based EIP with Spring Integration 6.x / Spring Boot 3.x

📦 Dependencies

Maven

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-integration</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.integration</groupId>
  <artifactId>spring-integration-test</artifactId>
  <scope>test</scope>
</dependency>

Gradle

implementation 'org.springframework.boot:spring-boot-starter-integration'
testImplementation 'org.springframework.integration:spring-integration-test'

1. Core Concepts & Architecture

Spring Integration implements Enterprise Integration Patterns (EIP) within the Spring ecosystem. It connects components via messages flowing through channels, processed by endpoints.

Architecture Diagram

Producer ──→ [Channel] ──→ [Endpoint] ──→ [Channel] ──→ Consumer
                              │
                    ┌─────────┴──────────┐
                    │  @ServiceActivator  │
                    │  @Transformer       │
                    │  @Filter            │
                    │  @Router            │
                    │  @Splitter          │
                    │  @Aggregator        │
                    └────────────────────┘

Message = Header + Payload (immutable)

package com.example.integration;

import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;

// Messages are immutable — use MessageBuilder to create
Message<String> message = MessageBuilder
    .withPayload("Order #123")
    .setHeader("orderId", 123)
    .setHeader("priority", "HIGH")
    .setHeader("region", "EU")
    .build();

// Access
String payload = message.getPayload();            // "Order #123"
Object orderId = message.getHeaders().get("orderId"); // 123

Enable Spring Integration

package com.example.integration;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.integration.annotation.IntegrationComponentScan;
import org.springframework.integration.config.EnableIntegration;

@SpringBootApplication
@EnableIntegration
@IntegrationComponentScan
public class IntegrationApplication {
    public static void main(String[] args) {
        SpringApplication.run(IntegrationApplication.class, args);
    }
}

When to Use Spring Integration vs Plain Spring Boot

ScenarioUse
Simple REST APIPlain Spring Boot
Message routing between componentsSpring Integration
File polling → transform → DB insertSpring Integration
CRUD operationsPlain Spring Boot
Scatter-gather, split/aggregateSpring Integration
Complex orchestration with retriesSpring Integration
Single external API callPlain Spring Boot
Multiple protocols (HTTP + MQTT + JMS)Spring Integration

2. Message Channels — The Pipes

package com.example.integration.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.integration.channel.DirectChannel;
import org.springframework.integration.channel.ExecutorChannel;
import org.springframework.integration.channel.PublishSubscribeChannel;
import org.springframework.integration.channel.QueueChannel;
import org.springframework.integration.channel.PriorityChannel;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import reactor.core.publisher.Flux;
import org.springframework.integration.channel.FluxMessageChannel;

@Configuration
public class ChannelConfig {

    // Synchronous, point-to-point, single subscriber
    @Bean
    public DirectChannel orderChannel() {
        return new DirectChannel();
    }

    // Async, polled, buffered — needs @Poller on consumer
    @Bean
    public QueueChannel processingQueue() {
        return new QueueChannel(100); // capacity 100
    }

    // Broadcast to ALL subscribers
    @Bean
    public PublishSubscribeChannel eventBus() {
        return new PublishSubscribeChannel();
    }

    // Priority-based ordering (messages implement Comparable or use comparator)
    @Bean
    public PriorityChannel priorityChannel() {
        return new PriorityChannel(50); // capacity 50
    }

    // Async with thread pool
    @Bean
    public ExecutorChannel asyncChannel() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(100);
        executor.initialize();
        return new ExecutorChannel(executor);
    }

    // Reactive (Project Reactor)
    @Bean
    public FluxMessageChannel reactiveChannel() {
        return new FluxMessageChannel();
    }
}

Global Channel Interceptor — Logging All Messages

package com.example.integration.interceptor;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.integration.config.GlobalChannelInterceptor;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.stereotype.Component;

@Component
@GlobalChannelInterceptor(patterns = "*")
public class LoggingChannelInterceptor implements ChannelInterceptor {

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

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        log.info("→ [{}] payload={}, headers={}",
            channel, message.getPayload().getClass().getSimpleName(),
            message.getHeaders());
        return message;
    }

    @Override
    public void postSend(Message<?> message, MessageChannel channel, boolean sent) {
        if (!sent) {
            log.warn("✗ Message NOT sent to [{}]: {}", channel, message.getPayload());
        }
    }

    @Override
    public void afterSendCompletion(Message<?> message, MessageChannel channel,
                                     boolean sent, Exception ex) {
        if (ex != null) {
            log.error("✗ Exception on [{}]: {}", channel, ex.getMessage(), ex);
        }
    }
}

3. @ServiceActivator — The Workhorse

Basic Usage

package com.example.integration.service;

import org.springframework.integration.annotation.ServiceActivator;
import org.springframework.messaging.Message;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.messaging.handler.annotation.Headers;
import org.springframework.stereotype.Component;
import java.util.Map;

@Component
public class OrderService {

    // Basic: receives payload, returns result → sent to outputChannel
    @ServiceActivator(inputChannel = "orderChannel", outputChannel = "processedChannel")
    public String processOrder(String orderData) {
        return "PROCESSED: " + orderData;
    }

    // Void return — fire-and-forget (one-way)
    @ServiceActivator(inputChannel = "notificationChannel")
    public void sendNotification(String message) {
        System.out.println("Notification: " + message);
    }

    // Access full Message (headers + payload)
    @ServiceActivator(inputChannel = "enrichChannel")
    public Message<?> enrichOrder(Message<String> message) {
        String enriched = message.getPayload() + " [enriched]";
        return org.springframework.messaging.support.MessageBuilder
            .withPayload(enriched)
            .copyHeaders(message.getHeaders())
            .setHeader("enriched", true)
            .build();
    }

    // Individual header access
    @ServiceActivator(inputChannel = "routedChannel")
    public void handleWithHeader(String payload,
                                  @Header("orderId") int orderId,
                                  @Header(value = "priority", required = false) String priority) {
        System.out.printf("Order %d (priority=%s): %s%n", orderId, priority, payload);
    }

    // All headers at once
    @ServiceActivator(inputChannel = "auditChannel")
    public void audit(String payload, @Headers Map<String, Object> headers) {
        headers.forEach((k, v) -> System.out.printf("  %s = %s%n", k, v));
    }
}

Poller for QueueChannel + Retry Advice

package com.example.integration.service;

import org.springframework.integration.annotation.Poller;
import org.springframework.integration.annotation.ServiceActivator;
import org.springframework.integration.handler.advice.RequestHandlerRetryAdvice;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
import org.springframework.stereotype.Component;

@Component
public class QueueConsumer {

    // Poll from QueueChannel every 1000ms, max 10 messages per poll
    @ServiceActivator(
        inputChannel = "processingQueue",
        poller = @Poller(fixedDelay = "1000", maxMessagesPerPoll = "10")
    )
    public void processFromQueue(String payload) {
        System.out.println("Processing: " + payload);
    }
}

@Configuration
class RetryConfig {

    @Bean
    public RequestHandlerRetryAdvice retryAdvice() {
        RequestHandlerRetryAdvice advice = new RequestHandlerRetryAdvice();
        RetryTemplate retryTemplate = new RetryTemplate();

        ExponentialBackOffPolicy backOff = new ExponentialBackOffPolicy();
        backOff.setInitialInterval(1000);
        backOff.setMultiplier(2.0);
        backOff.setMaxInterval(10000);
        retryTemplate.setBackOffPolicy(backOff);

        advice.setRetryTemplate(retryTemplate);
        return advice;
    }
}

Practical Example: Order Processing Service

package com.example.integration.service;

import org.springframework.integration.annotation.ServiceActivator;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;

@Component
public class OrderProcessingService {

    @ServiceActivator(inputChannel = "validatedOrders", outputChannel = "enrichedOrders")
    public Message<Order> enrichOrder(Order order) {
        // Add tax, shipping, discount
        order.setTax(order.getTotal() * 0.077); // Swiss VAT
        order.setShipping(order.getTotal() > 100 ? 0 : 9.90);
        return MessageBuilder.withPayload(order)
            .setHeader("enrichedAt", System.currentTimeMillis())
            .build();
    }

    @ServiceActivator(inputChannel = "enrichedOrders", outputChannel = "persistedOrders")
    public Order persistOrder(Order order) {
        // orderRepository.save(order);
        return order;
    }

    @ServiceActivator(inputChannel = "persistedOrders")
    public void notifyCustomer(Order order) {
        // emailService.send(order.getEmail(), "Order confirmed: " + order.getId());
    }
}

4. @Transformer — Shape the Data

Basic Transformations

package com.example.integration.transformer;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.integration.annotation.Transformer;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;

@Component
public class OrderTransformer {

    private final ObjectMapper objectMapper = new ObjectMapper();

    // Basic: transform payload type
    @Transformer(inputChannel = "rawOrders", outputChannel = "jsonOrders")
    public String orderToJson(Order order) throws Exception {
        return objectMapper.writeValueAsString(order);
    }

    // JSON string → Object
    @Transformer(inputChannel = "jsonInput", outputChannel = "objectOutput")
    public Order jsonToOrder(String json) throws Exception {
        return objectMapper.readValue(json, Order.class);
    }

    // Header enrichment via MessageBuilder
    @Transformer(inputChannel = "inbound", outputChannel = "enriched")
    public Message<Order> enrichHeaders(Message<Order> message) {
        Order order = message.getPayload();
        return MessageBuilder.fromMessage(message)
            .setHeader("customerTier", order.getTotal() > 1000 ? "GOLD" : "STANDARD")
            .setHeader("processedAt", System.currentTimeMillis())
            .setHeader("region", resolveRegion(order.getCountry()))
            .build();
    }

    private String resolveRegion(String country) {
        return switch (country) {
            case "CH", "DE", "FR", "IT" -> "EU";
            case "US", "CA" -> "US";
            default -> "APAC";
        };
    }
}

Practical Example: CSV → POJO → JSON Pipeline

package com.example.integration.transformer;

import org.springframework.integration.annotation.Transformer;
import org.springframework.stereotype.Component;

@Component
public class CsvTransformer {

    // CSV line → POJO
    @Transformer(inputChannel = "csvLines", outputChannel = "pojoChannel")
    public Product csvToProduct(String csvLine) {
        String[] parts = csvLine.split(",");
        Product p = new Product();
        p.setSku(parts[0].trim());
        p.setName(parts[1].trim());
        p.setPrice(Double.parseDouble(parts[2].trim()));
        p.setQuantity(Integer.parseInt(parts[3].trim()));
        return p;
    }

    // Sanitize — strip HTML, normalize whitespace
    @Transformer(inputChannel = "pojoChannel", outputChannel = "sanitizedChannel")
    public Product sanitize(Product product) {
        product.setName(product.getName().replaceAll("<[^>]*>", "").trim());
        return product;
    }
}

// Java DSL alternative
@Configuration
class TransformerFlowConfig {
    @Bean
    public IntegrationFlow csvPipeline() {
        return IntegrationFlow.from("csvInput")
            .transform(String.class, csv -> {
                String[] p = csv.split(",");
                return new Product(p[0], p[1], Double.parseDouble(p[2]), Integer.parseInt(p[3]));
            })
            .transform(Product.class, product -> {
                product.setName(product.getName().replaceAll("<[^>]*>", ""));
                return product;
            })
            .channel("outputChannel")
            .get();
    }
}

5. @Filter — Gate the Flow

Basic Filtering

package com.example.integration.filter;

import org.springframework.integration.annotation.Filter;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.stereotype.Component;

@Component
public class OrderFilter {

    // Return true = pass, false = discard
    @Filter(inputChannel = "incomingOrders", outputChannel = "validOrders",
            discardChannel = "rejectedOrders")
    public boolean validateOrder(Order order) {
        return order.getTotal() > 0
            && order.getEmail() != null && order.getEmail().contains("@")
            && order.getItems() != null && !order.getItems().isEmpty();
    }

    // throwExceptionOnRejection instead of discardChannel
    @Filter(inputChannel = "strictChannel",
            throwExceptionOnRejection = true)
    public boolean strictValidation(Order order) {
        return order.getTotal() > 0;
    }

    // Filter using header values
    @Filter(inputChannel = "allOrders", outputChannel = "highPriorityOnly")
    public boolean highPriorityFilter(Order order,
                                       @Header("priority") String priority) {
        return "HIGH".equals(priority);
    }
}

Practical Example: Deduplication Filter

package com.example.integration.filter;

import org.springframework.integration.annotation.Filter;
import org.springframework.stereotype.Component;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

@Component
public class DeduplicationFilter {

    private final Set<String> seenIds = ConcurrentHashMap.newKeySet();

    @Filter(inputChannel = "rawEvents", outputChannel = "uniqueEvents",
            discardChannel = "duplicateEvents")
    public boolean deduplicate(Event event) {
        return seenIds.add(event.getId()); // returns false if already present
    }
}

// Java DSL alternative
@Bean
public IntegrationFlow filterFlow() {
    return IntegrationFlow.from("input")
        .filter(Order.class, o -> o.getTotal() > 0,
            f -> f.discardChannel("rejected").throwExceptionOnRejection(false))
        .channel("validated")
        .get();
}

6. @Router — Direct Traffic

Returning Channel Names

package com.example.integration.router;

import org.springframework.integration.annotation.Router;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.stereotype.Component;
import java.util.List;

@Component
public class OrderRouter {

    // Route by region → returns channel name
    @Router(inputChannel = "validatedOrders")
    public String routeByRegion(Order order) {
        return switch (order.getRegion()) {
            case "EU" -> "euProcessingChannel";
            case "US" -> "usProcessingChannel";
            case "APAC" -> "apacProcessingChannel";
            default -> "defaultProcessingChannel";
        };
    }

    // Route to MULTIPLE channels (multicast)
    @Router(inputChannel = "importantOrders")
    public List<String> routeToMultiple(Order order) {
        if (order.getTotal() > 10000) {
            return List.of("processingChannel", "auditChannel", "notificationChannel");
        }
        return List.of("processingChannel");
    }

    // Route by header value
    @Router(inputChannel = "allOrders")
    public String routeByPriority(@Header("priority") String priority) {
        return switch (priority) {
            case "HIGH" -> "fastLaneChannel";
            case "MEDIUM" -> "normalChannel";
            default -> "batchQueueChannel";
        };
    }
}

Built-in Routers (Java Config)

package com.example.integration.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.integration.dsl.IntegrationFlow;
import org.springframework.integration.router.HeaderValueRouter;
import org.springframework.integration.router.PayloadTypeRouter;

@Configuration
public class RouterConfig {

    // HeaderValueRouter — route based on header value
    @Bean
    public IntegrationFlow headerRouterFlow() {
        return IntegrationFlow.from("inbound")
            .<String, String>route(
                m -> m.getHeaders().get("type", String.class),
                mapping -> mapping
                    .subFlowMapping("ORDER", sf -> sf.channel("orderChannel"))
                    .subFlowMapping("INVOICE", sf -> sf.channel("invoiceChannel"))
                    .defaultOutputChannel("unknownChannel")
            )
            .get();
    }

    // PayloadTypeRouter — route by payload class
    @Bean
    public IntegrationFlow payloadTypeRouterFlow() {
        return IntegrationFlow.from("mixedInput")
            .<Object, Class<?>>route(Object::getClass,
                mapping -> mapping
                    .subFlowMapping(Order.class, sf -> sf.channel("orderChannel"))
                    .subFlowMapping(Invoice.class, sf -> sf.channel("invoiceChannel"))
            )
            .get();
    }

    // RecipientListRouter — send to all matching recipients
    @Bean
    public IntegrationFlow recipientListFlow() {
        return IntegrationFlow.from("events")
            .routeToRecipients(r -> r
                .recipient("auditChannel")       // always
                .recipient("alertChannel",        // conditional
                    m -> "ERROR".equals(m.getHeaders().get("level")))
                .recipient("metricsChannel"))     // always
            .get();
    }
}

7. @Splitter — Break It Apart

Basic Splitting

package com.example.integration.splitter;

import org.springframework.integration.annotation.Splitter;
import org.springframework.stereotype.Component;
import java.util.Iterator;
import java.util.List;

@Component
public class OrderSplitter {

    // Split bulk order into individual items
    // Spring auto-sets sequenceSize, sequenceNumber, correlationId headers
    @Splitter(inputChannel = "bulkOrders", outputChannel = "individualItems")
    public List<OrderItem> splitOrder(Order order) {
        return order.getItems();
    }

    // Lazy/streaming split — returns Iterator (memory efficient)
    @Splitter(inputChannel = "largeOrders", outputChannel = "streamedItems")
    public Iterator<OrderItem> splitLazy(Order order) {
        return order.getItems().iterator();
    }
}

Practical Example: CSV File → Rows

package com.example.integration.splitter;

import org.springframework.integration.annotation.Splitter;
import org.springframework.stereotype.Component;
import java.io.File;
import java.nio.file.Files;
import java.util.List;
import java.util.stream.Collectors;

@Component
public class CsvFileSplitter {

    @Splitter(inputChannel = "csvFiles", outputChannel = "csvRows")
    public List<String> splitCsvFile(File file) throws Exception {
        List<String> lines = Files.readAllLines(file.toPath());
        // Skip header row, return data rows
        return lines.stream()
            .skip(1)
            .filter(line -> !line.isBlank())
            .collect(Collectors.toList());
    }
}

// Java DSL alternative with built-in FileSplitter
@Bean
public IntegrationFlow fileSplitFlow() {
    return IntegrationFlow.from("fileInput")
        .split(Files.splitter()
            .markers()           // emit START/END markers
            .charset("UTF-8")
            .applySequence(true))
        .channel("lineOutput")
        .get();
}

8. @Aggregator — Put It Together

Annotation-Based Aggregation

package com.example.integration.aggregator;

import org.springframework.integration.annotation.Aggregator;
import org.springframework.integration.annotation.CorrelationStrategy;
import org.springframework.integration.annotation.ReleaseStrategy;
import org.springframework.messaging.Message;
import org.springframework.stereotype.Component;
import java.util.List;

@Component
public class OrderAggregator {

    // Correlate by orderId header — group messages belonging to the same order
    @CorrelationStrategy
    public Object correlateByOrderId(Message<?> message) {
        return message.getHeaders().get("orderId");
    }

    // Release when all items received (sequenceSize == group size)
    @ReleaseStrategy
    public boolean releaseWhenComplete(List<Message<?>> messages) {
        // Default: checks sequenceSize header matches group size
        if (messages.isEmpty()) return false;
        int expected = (int) messages.get(0).getHeaders().get("sequenceSize");
        return messages.size() >= expected;
    }

    // Aggregate: combine all payloads into a result
    @Aggregator(inputChannel = "processedItems", outputChannel = "completedOrders")
    public Order aggregateOrder(List<OrderItem> items) {
        Order order = new Order();
        order.setItems(items);
        order.setTotal(items.stream()
            .mapToDouble(i -> i.getPrice() * i.getQuantity())
            .sum());
        return order;
    }
}

Java DSL with Timeout & MessageGroupStore

package com.example.integration.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.integration.dsl.IntegrationFlow;
import org.springframework.integration.store.SimpleMessageStore;
import org.springframework.integration.jdbc.store.JdbcMessageStore;
import javax.sql.DataSource;

@Configuration
public class AggregatorConfig {

    @Bean
    public IntegrationFlow aggregatorFlow() {
        return IntegrationFlow.from("splitItems")
            .aggregate(a -> a
                .correlationStrategy(m -> m.getHeaders().get("orderId"))
                .releaseStrategy(g -> g.size() >= g.getOne().getHeaders()
                    .get("sequenceSize", Integer.class))
                .groupTimeout(30_000)          // 30s timeout → partial release
                .sendPartialResultOnExpiry(true)
                .messageStore(new SimpleMessageStore()) // or jdbcMessageStore()
                .outputProcessor(g -> {
                    List<OrderItem> items = g.getMessages().stream()
                        .map(m -> (OrderItem) m.getPayload())
                        .toList();
                    return new Order(items);
                })
            )
            .channel("aggregatedOrders")
            .get();
    }

    // Persistent store for production (survives restarts)
    @Bean
    public JdbcMessageStore jdbcMessageStore(DataSource dataSource) {
        return new JdbcMessageStore(dataSource);
    }
}

Practical Example: Scatter-Gather (3 services)

package com.example.integration.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.integration.dsl.IntegrationFlow;

@Configuration
public class ScatterGatherConfig {

    @Bean
    public IntegrationFlow scatterGatherFlow() {
        return IntegrationFlow.from("priceRequest")
            .scatterGather(
                scatterer -> scatterer
                    .applySequence(true)
                    .recipientFlow(sf -> sf.handle("supplierAService", "getPrice"))
                    .recipientFlow(sf -> sf.handle("supplierBService", "getPrice"))
                    .recipientFlow(sf -> sf.handle("supplierCService", "getPrice")),
                gatherer -> gatherer
                    .releaseStrategy(g -> g.size() == 3)  // wait for all 3
                    .groupTimeout(5000)                    // or timeout after 5s
            )
            // Select best price from gathered responses
            .handle((prices, headers) -> {
                @SuppressWarnings("unchecked")
                List<PriceQuote> quotes = (List<PriceQuote>) prices;
                return quotes.stream()
                    .min(Comparator.comparing(PriceQuote::getPrice))
                    .orElseThrow();
            })
            .channel("bestPrice")
            .get();
    }
}

9. @Gateway — The Clean Entry Point

@MessagingGateway Interface

package com.example.integration.gateway;

import org.springframework.integration.annotation.Gateway;
import org.springframework.integration.annotation.MessagingGateway;
import org.springframework.messaging.handler.annotation.Header;
import java.util.concurrent.Future;

// Hides ALL Spring Integration complexity from business code
@MessagingGateway
public interface OrderGateway {

    // Synchronous: send and wait for reply
    @Gateway(requestChannel = "orderInput", replyChannel = "orderOutput")
    OrderResult submitOrder(Order order);

    // With headers
    @Gateway(requestChannel = "orderInput")
    OrderResult submitOrder(Order order,
                            @Header("priority") String priority,
                            @Header("region") String region);

    // Fire-and-forget (void return)
    @Gateway(requestChannel = "notificationChannel")
    void sendNotification(String message);

    // Async — returns Future
    @Gateway(requestChannel = "asyncProcessing")
    Future<OrderResult> submitOrderAsync(Order order);
}

Using the Gateway in Business Code

package com.example.integration.controller;

import com.example.integration.gateway.OrderGateway;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderGateway orderGateway;

    public OrderController(OrderGateway orderGateway) {
        this.orderGateway = orderGateway;
    }

    @PostMapping
    public OrderResult createOrder(@RequestBody Order order) {
        // Business code has ZERO knowledge of channels, messages, endpoints
        return orderGateway.submitOrder(order, "HIGH", "EU");
    }
}

Reactive Gateway (Mono)

package com.example.integration.gateway;

import org.springframework.integration.annotation.Gateway;
import org.springframework.integration.annotation.MessagingGateway;
import reactor.core.publisher.Mono;

@MessagingGateway
public interface ReactiveFileGateway {

    @Gateway(requestChannel = "fileProcessingInput")
    Mono<ProcessingResult> processFile(byte[] fileContent);
}

10. @InboundChannelAdapter & @Poller — Produce Messages

Basic Polling

package com.example.integration.adapter;

import org.springframework.integration.annotation.InboundChannelAdapter;
import org.springframework.integration.annotation.Poller;
import org.springframework.integration.core.MessageSource;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;
import java.util.concurrent.atomic.AtomicInteger;

@Component
public class OrderPoller {

    private final AtomicInteger counter = new AtomicInteger();

    // Poll every 5 seconds
    @InboundChannelAdapter(
        channel = "polledOrders",
        poller = @Poller(fixedDelay = "5000", maxMessagesPerPoll = "10")
    )
    public String pollForOrders() {
        // In reality: query DB for unprocessed orders
        return "Order-" + counter.incrementAndGet();
    }
}

Practical Example: Database Poller

package com.example.integration.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.integration.dsl.IntegrationFlow;
import org.springframework.integration.dsl.Pollers;
import org.springframework.integration.jdbc.JdbcPollingChannelAdapter;
import javax.sql.DataSource;

@Configuration
public class DatabasePollerConfig {

    @Bean
    public JdbcPollingChannelAdapter jdbcMessageSource(DataSource dataSource) {
        JdbcPollingChannelAdapter adapter = new JdbcPollingChannelAdapter(
            dataSource,
            "SELECT * FROM orders WHERE status = 'NEW' ORDER BY created_at LIMIT 10"
        );
        adapter.setUpdateSql("UPDATE orders SET status = 'PROCESSING' WHERE id IN (:id)");
        return adapter;
    }

    @Bean
    public IntegrationFlow dbPollerFlow(JdbcPollingChannelAdapter jdbcMessageSource) {
        return IntegrationFlow
            .from(jdbcMessageSource,
                c -> c.poller(Pollers.fixedDelay(5000).maxMessagesPerPoll(10)))
            .channel("newOrdersChannel")
            .get();
    }
}

File Poller → Process → Archive

package com.example.integration.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.integration.dsl.IntegrationFlow;
import org.springframework.integration.dsl.Pollers;
import org.springframework.integration.file.FileReadingMessageSource;
import org.springframework.integration.file.FileWritingMessageHandler;
import org.springframework.integration.file.filters.SimplePatternFileListFilter;
import java.io.File;

@Configuration
public class FilePollerConfig {

    @Bean
    public FileReadingMessageSource fileSource() {
        FileReadingMessageSource source = new FileReadingMessageSource();
        source.setDirectory(new File("/data/incoming"));
        source.setFilter(new SimplePatternFileListFilter("*.csv"));
        source.setAutoCreateDirectory(true);
        return source;
    }

    @Bean
    public IntegrationFlow filePollerFlow(FileReadingMessageSource fileSource) {
        return IntegrationFlow
            .from(fileSource,
                c -> c.poller(Pollers.fixedDelay(10_000))) // every 10s
            .handle("csvProcessingService", "process")
            // Archive processed file
            .handle(new FileWritingMessageHandler(new File("/data/archive")))
            .get();
    }
}

Cron-Based Polling

@InboundChannelAdapter(
    channel = "reportChannel",
    poller = @Poller(cron = "0 0 8 * * MON-FRI") // 8 AM weekdays
)
public Report generateDailyReport() {
    return reportService.generate();
}

11. Error Handling — Don't Lose Messages

Global Error Handler

package com.example.integration.error;

import org.springframework.integration.annotation.ServiceActivator;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessagingException;
import org.springframework.stereotype.Component;

@Component
public class GlobalErrorHandler {

    // All unhandled errors arrive here
    @ServiceActivator(inputChannel = "errorChannel")
    public void handleError(Message<MessagingException> errorMessage) {
        MessagingException exception = errorMessage.getPayload();
        Message<?> failedMessage = exception.getFailedMessage();

        // Log
        System.err.printf("ERROR: %s | Payload: %s | Headers: %s%n",
            exception.getCause().getMessage(),
            failedMessage != null ? failedMessage.getPayload() : "N/A",
            failedMessage != null ? failedMessage.getHeaders() : "N/A");

        // Store for later retry
        // failedMessageRepository.save(failedMessage);

        // Notify
        // slackNotifier.send("Integration error: " + exception.getMessage());
    }
}

Retry with Exponential Backoff → Dead Letter Queue

package com.example.integration.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.integration.channel.DirectChannel;
import org.springframework.integration.dsl.IntegrationFlow;
import org.springframework.integration.handler.advice.RequestHandlerRetryAdvice;
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
import org.springframework.retry.policy.SimpleRetryPolicy;
import org.springframework.retry.support.RetryTemplate;

@Configuration
public class RetryAndDlqConfig {

    @Bean
    public DirectChannel deadLetterChannel() {
        return new DirectChannel();
    }

    @Bean
    public RequestHandlerRetryAdvice retryAdvice() {
        RequestHandlerRetryAdvice advice = new RequestHandlerRetryAdvice();

        RetryTemplate retryTemplate = new RetryTemplate();
        retryTemplate.setRetryPolicy(new SimpleRetryPolicy(3)); // max 3 attempts

        ExponentialBackOffPolicy backOff = new ExponentialBackOffPolicy();
        backOff.setInitialInterval(1000);   // 1s
        backOff.setMultiplier(2.0);          // 1s → 2s → 4s
        backOff.setMaxInterval(10000);       // cap at 10s
        retryTemplate.setBackOffPolicy(backOff);

        advice.setRetryTemplate(retryTemplate);
        advice.setRecoveryCallback(ctx -> {
            // After all retries exhausted → send to DLQ
            Message<?> failed = (Message<?>) ctx.getAttribute("message");
            deadLetterChannel().send(failed);
            return null;
        });
        return advice;
    }

    @Bean
    public IntegrationFlow retryFlow() {
        return IntegrationFlow.from("riskyInput")
            .handle("externalService", "call",
                e -> e.advice(retryAdvice()))
            .channel("successOutput")
            .get();
    }
}

Per-Endpoint Error Channel

// Each endpoint can have its own error channel
@ServiceActivator(inputChannel = "paymentChannel")
@Bean
public IntegrationFlow paymentFlow() {
    return IntegrationFlow.from("paymentInput")
        .handle("paymentService", "charge",
            e -> e.advice(retryAdvice()))
        .get();
}

// Dedicated error handler for payment failures
@ServiceActivator(inputChannel = "paymentErrorChannel")
public void handlePaymentError(Message<MessagingException> error) {
    // Refund logic, notify finance team, etc.
}

12. Integration with External Systems

Add specific dependencies per adapter: spring-integration-jdbc, spring-integration-amqp, spring-integration-kafka, spring-integration-http, spring-integration-file, spring-integration-mqtt, spring-integration-sftp, spring-integration-websocket

JDBC — Poll & Insert

@Bean
public IntegrationFlow jdbcInboundFlow(DataSource ds) {
    return IntegrationFlow
        .from(new JdbcPollingChannelAdapter(ds,
            "SELECT * FROM events WHERE processed = false"),
            e -> e.poller(Pollers.fixedDelay(5000)))
        .handle("eventProcessor", "process")
        .get();
}

@Bean
public IntegrationFlow jdbcOutboundFlow(DataSource ds) {
    return IntegrationFlow.from("processedEvents")
        .handle(new JdbcMessageHandler(ds,
            "INSERT INTO processed_events (id, data) VALUES (:headers[id], :payload)"))
        .get();
}

RabbitMQ (AMQP)

@Bean
public IntegrationFlow amqpInboundFlow(ConnectionFactory cf) {
    return IntegrationFlow
        .from(Amqp.inboundAdapter(cf, "order-queue")
            .configureContainer(c -> c.prefetchCount(10)))
        .handle("orderService", "process")
        .get();
}

@Bean
public IntegrationFlow amqpOutboundFlow(AmqpTemplate amqpTemplate) {
    return IntegrationFlow.from("outboundChannel")
        .handle(Amqp.outboundAdapter(amqpTemplate)
            .routingKey("processed-orders"))
        .get();
}

Kafka

@Bean
public IntegrationFlow kafkaInboundFlow(ConsumerFactory<String, String> cf) {
    return IntegrationFlow
        .from(Kafka.messageDrivenChannelAdapter(cf, "orders-topic")
            .configureListenerContainer(c -> c.groupId("integration-group")))
        .handle("orderHandler", "handle")
        .get();
}

@Bean
public IntegrationFlow kafkaOutboundFlow(KafkaTemplate<String, String> kt) {
    return IntegrationFlow.from("toKafka")
        .handle(Kafka.outboundChannelAdapter(kt).topic("processed-orders"))
        .get();
}

HTTP — Inbound & Outbound

// Inbound: expose HTTP endpoint
@Bean
public IntegrationFlow httpInboundFlow() {
    return IntegrationFlow
        .from(Http.inboundGateway("/api/integration/orders")
            .requestMapping(m -> m.methods(HttpMethod.POST))
            .requestPayloadType(Order.class))
        .handle("orderService", "process")
        .get();
}

// Outbound: call external REST API
@Bean
public IntegrationFlow httpOutboundFlow() {
    return IntegrationFlow.from("callExternalApi")
        .handle(Http.outboundGateway("https://api.example.com/validate")
            .httpMethod(HttpMethod.POST)
            .expectedResponseType(ValidationResult.class))
        .channel("validationResults")
        .get();
}

MQTT (see also: Spring Boot MQTT Cheatsheet)

@Bean
public IntegrationFlow mqttInboundFlow(MqttPahoClientFactory factory) {
    return IntegrationFlow
        .from(new MqttPahoMessageDrivenChannelAdapter("client-in", factory,
            "sensors/+/temperature", "sensors/+/humidity"))
        .handle("sensorDataService", "process")
        .get();
}

@Bean
public IntegrationFlow mqttOutboundFlow(MqttPahoClientFactory factory) {
    return IntegrationFlow.from("mqttOutbound")
        .handle(new MqttPahoMessageHandler("client-out", factory))
        .get();
}

SFTP

@Bean
public IntegrationFlow sftpInboundFlow(SessionFactory<SftpClient.DirEntry> sf) {
    return IntegrationFlow
        .from(Sftp.inboundAdapter(sf)
                .remoteDirectory("/remote/incoming")
                .localDirectory(new File("/local/incoming"))
                .patternFilter("*.csv"),
            e -> e.poller(Pollers.fixedDelay(30_000)))
        .handle("fileProcessor", "process")
        .get();
}

13. Testing Spring Integration

Test Setup

package com.example.integration;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.integration.test.context.SpringIntegrationTest;
import org.springframework.integration.test.mock.MockIntegration;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.PollableChannel;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.test.annotation.DirtiesContext;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@SpringIntegrationTest
@DirtiesContext
class OrderFlowIntegrationTest {

    @Autowired
    @Qualifier("orderInput")
    private MessageChannel inputChannel;

    @Autowired
    @Qualifier("orderOutput")
    private PollableChannel outputChannel;

    @Test
    void orderFlowProcessesCorrectly() {
        // Arrange
        Order order = new Order("ORD-001", "[email protected]", 150.00);

        // Act
        inputChannel.send(MessageBuilder.withPayload(order).build());

        // Assert
        var result = outputChannel.receive(5000);
        assertThat(result).isNotNull();
        assertThat(result.getPayload()).isInstanceOf(Order.class);

        Order processed = (Order) result.getPayload();
        assertThat(processed.getStatus()).isEqualTo("PROCESSED");
        assertThat(result.getHeaders().get("enriched")).isEqualTo(true);
    }
}

Mock External Services

package com.example.integration;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.integration.test.context.SpringIntegrationTest;
import org.springframework.integration.test.mock.MockIntegration;
import org.springframework.integration.test.mock.MockMessageHandler;

@SpringBootTest
@SpringIntegrationTest
class OrderFlowMockedTest {

    @Configuration
    static class TestConfig {

        // Mock the external payment service
        @Bean
        public MockMessageHandler paymentServiceMock() {
            return MockIntegration.mockMessageHandler()
                .handleNextAndReply(m -> {
                    Order order = (Order) m.getPayload();
                    return new PaymentResult(order.getId(), "PAID", "TXN-12345");
                });
        }
    }

    @Test
    void orderFlowWithMockedPayment() {
        // The real flow runs, but payment calls hit the mock
        inputChannel.send(MessageBuilder.withPayload(testOrder).build());

        var result = outputChannel.receive(5000);
        assertThat(result).isNotNull();

        // Verify the mock was called
        // paymentServiceMock.verify(...);
    }
}

Verify Headers & Payload

@Test
void verifyHeadersAndPayload() {
    inputChannel.send(MessageBuilder
        .withPayload(new Order("ORD-002", "[email protected]", 500.0))
        .setHeader("priority", "HIGH")
        .build());

    var result = outputChannel.receive(5000);
    assertThat(result).isNotNull();

    // Verify headers
    assertThat(result.getHeaders())
        .containsEntry("priority", "HIGH")
        .containsEntry("enriched", true)
        .containsKey("processedAt");

    // Verify payload
    Order order = (Order) result.getPayload();
    assertThat(order.getTax()).isCloseTo(38.5, within(0.01)); // 500 * 0.077
    assertThat(order.getShipping()).isEqualTo(0); // free above 100
}

14. Real-World Patterns — Complete Flows

Pattern 1: Order Processing Pipeline

Gateway → Filter(validate) → Transformer(enrich) → Router(domestic/intl)
  ├── domesticChannel → ServiceActivator(process) → ServiceActivator(notify)
  └── internationalChannel → ServiceActivator(customs) → ServiceActivator(process) → ServiceActivator(notify)
package com.example.integration.order;

import org.springframework.integration.annotation.*;
import org.springframework.messaging.Message;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;
import java.util.List;

// ── Gateway ──────────────────────────────────────────────────
@MessagingGateway
public interface OrderGateway {
    @Gateway(requestChannel = "orderInput", replyChannel = "orderComplete")
    OrderResult submit(Order order);
}

// ── Filter: Validate ─────────────────────────────────────────
@Component
class OrderValidator {
    @Filter(inputChannel = "orderInput", outputChannel = "validOrders",
            discardChannel = "invalidOrders")
    public boolean validate(Order order) {
        return order.getTotal() > 0
            && order.getEmail() != null
            && order.getItems() != null && !order.getItems().isEmpty();
    }
}

// ── Transformer: Enrich ──────────────────────────────────────
@Component
class OrderEnricher {
    @Transformer(inputChannel = "validOrders", outputChannel = "enrichedOrders")
    public Message<Order> enrich(Order order) {
        order.setTax(order.getTotal() * 0.077);
        order.setTimestamp(System.currentTimeMillis());
        String region = order.getCountry().equals("CH") ? "DOMESTIC" : "INTERNATIONAL";
        return MessageBuilder.withPayload(order)
            .setHeader("region", region)
            .build();
    }
}

// ── Router: Domestic vs International ────────────────────────
@Component
class RegionRouter {
    @Router(inputChannel = "enrichedOrders")
    public String route(@Header("region") String region) {
        return region.equals("DOMESTIC") ? "domesticChannel" : "internationalChannel";
    }
}

// ── ServiceActivator: Process ────────────────────────────────
@Component
class DomesticProcessor {
    @ServiceActivator(inputChannel = "domesticChannel", outputChannel = "notifyChannel")
    public Order process(Order order) {
        order.setStatus("PROCESSED");
        order.setTrackingNumber("CH-" + System.currentTimeMillis());
        return order;
    }
}

@Component
class InternationalProcessor {
    @ServiceActivator(inputChannel = "internationalChannel", outputChannel = "notifyChannel")
    public Order processIntl(Order order) {
        order.setCustomsCleared(true);
        order.setStatus("PROCESSED");
        order.setTrackingNumber("INTL-" + System.currentTimeMillis());
        return order;
    }
}

// ── ServiceActivator: Notify ─────────────────────────────────
@Component
class OrderNotifier {
    @ServiceActivator(inputChannel = "notifyChannel", outputChannel = "orderComplete")
    public OrderResult notify(Order order) {
        // emailService.send(order.getEmail(), "Order " + order.getId() + " confirmed");
        return new OrderResult(order.getId(), order.getStatus(), order.getTrackingNumber());
    }
}

Pattern 2: File ETL Pipeline

FilePoller → Splitter(lines) → Transform(CSV→POJO) → Filter(valid)
  → Aggregate(batch of 100) → JDBC BulkInsert → Archive
package com.example.integration.etl;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.integration.annotation.*;
import org.springframework.integration.dsl.IntegrationFlow;
import org.springframework.integration.dsl.Pollers;
import org.springframework.integration.file.FileReadingMessageSource;
import org.springframework.integration.file.filters.SimplePatternFileListFilter;
import org.springframework.stereotype.Component;
import java.io.File;
import java.nio.file.Files;
import java.util.List;
import java.util.stream.Collectors;

@Configuration
class EtlFlowConfig {

    @Bean
    public FileReadingMessageSource csvSource() {
        FileReadingMessageSource source = new FileReadingMessageSource();
        source.setDirectory(new File("/data/incoming"));
        source.setFilter(new SimplePatternFileListFilter("*.csv"));
        return source;
    }

    @Bean
    public IntegrationFlow etlFlow(FileReadingMessageSource csvSource) {
        return IntegrationFlow
            .from(csvSource, c -> c.poller(Pollers.fixedDelay(10_000)))
            .channel("csvFiles")
            .get();
    }
}

@Component
class CsvLineSplitter {
    @Splitter(inputChannel = "csvFiles", outputChannel = "csvLines")
    public List<String> split(File file) throws Exception {
        return Files.readAllLines(file.toPath()).stream()
            .skip(1).filter(l -> !l.isBlank()).collect(Collectors.toList());
    }
}

@Component
class CsvToProductTransformer {
    @Transformer(inputChannel = "csvLines", outputChannel = "products")
    public Product transform(String line) {
        String[] p = line.split(",");
        return new Product(p[0].trim(), p[1].trim(),
            Double.parseDouble(p[2].trim()), Integer.parseInt(p[3].trim()));
    }
}

@Component
class ProductValidator {
    @Filter(inputChannel = "products", outputChannel = "validProducts",
            discardChannel = "invalidProducts")
    public boolean validate(Product p) {
        return p.getPrice() > 0 && p.getQuantity() >= 0 && !p.getSku().isBlank();
    }
}

@Configuration
class BatchAggregatorConfig {
    @Bean
    public IntegrationFlow batchAndInsert() {
        return IntegrationFlow.from("validProducts")
            .aggregate(a -> a
                .correlationStrategy(m -> "BATCH")  // single batch group
                .releaseStrategy(g -> g.size() >= 100)
                .groupTimeout(5000)
                .sendPartialResultOnExpiry(true))
            .handle("jdbcBulkInsertService", "insertBatch")
            .get();
    }
}

Pattern 3: Scatter-Gather (Best Price)

Request → Scatter[SupplierA, SupplierB, SupplierC]
  → Gather(wait all or 5s) → Select best price → Reply
package com.example.integration.scatter;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.integration.annotation.ServiceActivator;
import org.springframework.integration.dsl.IntegrationFlow;
import org.springframework.stereotype.Component;
import java.util.Comparator;
import java.util.List;

@Configuration
class ScatterGatherFlow {

    @Bean
    public IntegrationFlow bestPriceFlow() {
        return IntegrationFlow.from("priceRequest")
            .scatterGather(
                s -> s.applySequence(true)
                    .recipientFlow(f -> f.handle("supplierA", "quote"))
                    .recipientFlow(f -> f.handle("supplierB", "quote"))
                    .recipientFlow(f -> f.handle("supplierC", "quote")),
                g -> g.releaseStrategy(group -> group.size() == 3)
                    .groupTimeout(5000)
                    .sendPartialResultOnExpiry(true)
            )
            .handle((payload, headers) -> {
                @SuppressWarnings("unchecked")
                List<PriceQuote> quotes = (List<PriceQuote>) payload;
                return quotes.stream()
                    .min(Comparator.comparing(PriceQuote::getAmount))
                    .orElse(null);
            })
            .get();
    }
}

@Component
class SupplierA {
    @ServiceActivator
    public PriceQuote quote(PriceRequest req) {
        return new PriceQuote("SupplierA", req.getProductId(), 29.99);
    }
}

@Component
class SupplierB {
    @ServiceActivator
    public PriceQuote quote(PriceRequest req) {
        return new PriceQuote("SupplierB", req.getProductId(), 24.50);
    }
}

@Component
class SupplierC {
    @ServiceActivator
    public PriceQuote quote(PriceRequest req) {
        return new PriceQuote("SupplierC", req.getProductId(), 31.00);
    }
}

Pattern 4: Publish-Subscribe Event Bus

Event → PubSubChannel → [Audit Logger]
                       → [Email Notifier]
                       → [Metrics Collector]
                       → [Cache Invalidator]
package com.example.integration.pubsub;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.integration.annotation.ServiceActivator;
import org.springframework.integration.channel.PublishSubscribeChannel;
import org.springframework.stereotype.Component;

@Configuration
class EventBusConfig {

    @Bean
    public PublishSubscribeChannel eventBus() {
        return new PublishSubscribeChannel();
    }
}

@Component
class AuditLogger {
    @ServiceActivator(inputChannel = "eventBus")
    public void audit(DomainEvent event) {
        // auditRepository.save(new AuditEntry(event));
        System.out.printf("[AUDIT] %s: %s at %s%n",
            event.getType(), event.getEntityId(), event.getTimestamp());
    }
}

@Component
class EmailNotifier {
    @ServiceActivator(inputChannel = "eventBus")
    public void notify(DomainEvent event) {
        if ("ORDER_PLACED".equals(event.getType())) {
            // emailService.send(event.getUserEmail(), "Order confirmed!");
        }
    }
}

@Component
class MetricsCollector {
    @ServiceActivator(inputChannel = "eventBus")
    public void collect(DomainEvent event) {
        // meterRegistry.counter("events", "type", event.getType()).increment();
    }
}

@Component
class CacheInvalidator {
    @ServiceActivator(inputChannel = "eventBus")
    public void invalidate(DomainEvent event) {
        if ("PRODUCT_UPDATED".equals(event.getType())) {
            // cacheManager.getCache("products").evict(event.getEntityId());
        }
    }
}