skip to content

Compare DirectChannel and QueueChannel: threading, buffering, polling, and transaction behaviour.

level: middleimportance: must knowfreq 55%

answer

  1. Direct = caller thread, no buffer, shared tx
  2. Queue = buffer + returns immediately, needs poller
  3. Poller thread != sender thread => separate transaction
  4. Bounded QueueChannel = back-pressure
  5. Forgot the poller => messages pile up

basics

~20 s

DirectChannel runs the consumer synchronously on the sender's thread with no buffer, so send and handling share one thread and transaction. QueueChannel buffers messages and needs a poller; the consumer runs on a separate poller thread later.

solid answer

~40 s

DirectChannel is a `SubscribableChannel`: `send()` invokes the subscribed handler synchronously in the *caller's* thread with no buffering. Because it is one thread, a transaction (or security context) started by the sender wraps the handler too, and any handler exception propagates straight back to the sender. QueueChannel is a `PollableChannel`: `send()` just puts the message in an in-memory queue (bounded or unbounded) and returns immediately, so producer and consumer are decoupled in time and thread. A separate polling endpoint (`@Poller` / `PollerMetadata`) pulls messages on a scheduler thread, so the handler runs later on a *different* thread — the sender's transaction does NOT span it, and back-pressure appears as a blocking/failing send when a bounded queue is full. Use DirectChannel for synchronous, transactional handoff; QueueChannel to buffer bursts and offload work.

code

java · 29 lines
java
@Configuration
public class ChannelThreadingConfig {

    // Synchronous: handler runs on the sender's thread, in the sender's transaction
    @Bean
    public MessageChannel sync() {
        return new DirectChannel();
    }

    // Buffered: send() returns immediately; a poller drains it on another thread
    @Bean
    public PollableChannel buffered() {
        return new QueueChannel(500); // bounded => back-pressure when full
    }

    // A consumer reading from a PollableChannel MUST declare a poller
    @Bean(name = PollerMetadata.DEFAULT_POLLER)
    public PollerMetadata defaultPoller() {
        PollerMetadata p = new PollerMetadata();
        p.setTrigger(new PeriodicTrigger(Duration.ofMillis(200)));
        p.setMaxMessagesPerPoll(10);
        return p;
    }

    @ServiceActivator(inputChannel = "buffered") // runs on the poller thread
    public void handle(String payload) {
        // separate thread + separate (poller) transaction from the sender
    }
}

go deeper

for a junior

Know that DirectChannel is synchronous/same-thread and QueueChannel buffers.

for a middle

Must articulate poller requirement, thread handoff, and the transaction-boundary difference clearly.

for a senior

Add back-pressure via bounded capacity, error-channel routing on the poller thread, and message-store durability options.

for a principal

Reason about when to trade the shared-transaction guarantee for throughput/decoupling, and about failure/replay semantics of in-memory vs persistent queues.

**DirectChannel** (`org.springframework.integration.channel.DirectChannel`) is the default channel type and the simplest. It implements `SubscribableChannel` and uses a `UnicastingDispatcher`. When you call `send(message)`: 1. The dispatcher immediately invokes the subscribed `MessageHandler`. 2. This happens **in the sender's own thread** — no thread handoff, no queue, zero capacity. 3. `send()` returns only after the handler finishes. Consequences: The entire flow behaves like a nested method call. If the sender is inside a `@Transactional` method, the handler participates in the *same* transaction — commit/rollback covers both. Thread-bound context (transaction, Spring Security `SecurityContext`, `ThreadLocal`s, MDC) is naturally visible to the handler. An exception in the handler propagates back to the caller of `send()`. If multiple handlers subscribe, DirectChannel load-balances round-robin (`RoundRobinLoadBalancingStrategy`) with failover enabled by default — one handler per message, not broadcast. **QueueChannel** (`org.springframework.integration.channel.QueueChannel`) implements `PollableChannel`. `send()` places the message into an internal `BlockingQueue` (unbounded `LinkedBlockingQueue` by default, or bounded if you pass a capacity) and returns immediately without invoking any handler. Nothing consumes the message until a **polling consumer** runs. A polling endpoint is configured with a `Poller` (fixed-delay/fixed-rate/cron, `maxMessagesPerPoll`, optional `TaskExecutor`, optional transaction advice). The poller thread calls `receive()`, gets a message, and runs the handler **on the poller/scheduler thread** — a different thread from the sender. Consequences: Producer and consumer are decoupled in **time** (buffering absorbs bursts) and **thread** (async processing). The sender's transaction does NOT extend to the handler; if you want the poll+handle to be transactional you attach transactional advice to the *poller*, which is a separate transaction. Exceptions in the handler surface on the poller thread and go to the flow's error channel, not back to the original sender. A **bounded** QueueChannel gives back-pressure: `send()` with a timeout blocks when full and returns `false` (or throws on failure) — this is how you prevent unbounded memory growth. Ordering is FIFO. **Persistence:** by default the queue is in-memory, so a crash loses buffered messages; you can back a QueueChannel with a `MessageGroupStore`/`MessageStore` (e.g. JDBC) for durability. **Poller requirement gotcha:** A common mistake is wiring a QueueChannel and forgetting the consumer's poller — messages pile up and nothing processes them. Any endpoint reading from a PollableChannel MUST have a poller (globally via a default `PollerMetadata` bean or per-endpoint). **Decision guide:** - Need the same transaction/security context end-to-end, synchronous semantics, exceptions back to caller → **DirectChannel**. - Need to buffer bursts, throttle, or hand work to a background thread, or you're fine with a separate transaction and error-channel handling → **QueueChannel**. - Note: DirectChannel gives async *look* only if you deliberately make the handler async; QueueChannel gives true producer/consumer thread decoupling but at the cost of losing the shared transaction.

  • You wrapped the send in @Transactional but the QueueChannel consumer's DB write rolled back independently. Why?
    Because the consumer runs on the poller thread, not the sender's thread. The sender's transaction commits when send() returns (the message is just enqueued). The handler later runs in whatever transaction the poller defines (or none). To make consumption transactional you add transactional advice to the poller — but it is still a distinct transaction from the sender's.
  • How do you get back-pressure with a QueueChannel?
    Construct it with a capacity (bounded). When full, send() with a send-timeout blocks up to that timeout and then returns false / fails, so producers slow down instead of the queue growing unbounded. An unbounded QueueChannel gives no back-pressure and risks OutOfMemoryError under sustained overload.

saying these in an interview costs you the question

  • Claiming DirectChannel is asynchronous
  • Thinking a QueueChannel processes messages by itself without a poller
  • Assuming the sender's transaction covers a QueueChannel consumer
  • Believing QueueChannel is durable/persistent by default (it is in-memory)

context