Skip to main content

Stream Pipeline Behaviors

A stream pipeline behavior wraps every mediator.stream(...) dispatch in a decorator chain, the same way Pipeline Behaviors wrap send(). The difference: stream behaviors compose cold Flows instead of awaiting a single result, so cross-cutting concerns like logging, throttling, and tracing become Flow operator chains.

stream(request)
└─ StreamBehavior 1 (order=-100, outermost)
└─ StreamBehavior 2 (order=0)
└─ StreamRequestHandler → Flow<T>

Lower order = outermost wrapper. Behaviors are sorted by order at dispatch time; ties break in registration order.


How it differs from PipelineBehavior

AspectPipelineBehaviorStreamPipelineBehavior
WrapsRequest<TResult> via send()StreamRequest<T> via stream()
processsuspend, returns TResultnot suspend, returns Flow<T> immediately
next delegateRequestHandlerDelegate (suspend)StreamHandlerDelegate (plain function)
StagesStage.Pre / Default / PostNo stages — order alone controls position
Work happensInline, when the behavior runsLazily, when the caller collects the flow

The interface:

interface StreamPipelineBehavior {
val order: Int get() = 0
val isEnabled: Boolean get() = true
fun appliesTo(request: StreamRequest<*>): Boolean = true

fun <TRequest : StreamRequest<T>, T> process(
requestContext: RequestContext,
next: StreamHandlerDelegate<TRequest, T>,
request: TRequest,
): Flow<T>
}

Call next(request) to obtain the downstream flow, transform it with Flow operators, and return it. Returning a different flow without calling next short-circuits the remaining behaviors and the handler.

:::info process is intentionally not suspend stream() resolves the handler and returns the cold Flow immediately — nothing suspends until the caller collects. Your behavior must follow the same contract: build the flow synchronously, do the actual work inside operators like onStart, onEach, and onCompletion. :::


Example 1 — Logging stream behavior

Logs when collection starts, how many items were emitted, and whether the stream completed or failed:

class LoggingStreamBehavior(
private val log: (String) -> Unit = ::println,
) : StreamPipelineBehavior {
override val order = -100 // outermost — sees the whole stream lifecycle

override fun <TRequest : StreamRequest<T>, T> process(
requestContext: RequestContext,
next: StreamHandlerDelegate<TRequest, T>,
request: TRequest,
): Flow<T> {
val name = request::class.simpleName
var emitted = 0
return next(request)
.onStart {
emitted = 0
log("→ $name collection started")
}
.onEach { emitted++ }
.onCompletion { error ->
when (error) {
null -> log("← $name completed after $emitted item(s)")
else -> log("✕ $name failed after $emitted item(s): ${error.message}")
}
}
}
}

onStart fires on every collection (cold flows can be collected more than once), so the counter resets each time.


Example 2 — Throttling / rate-limiting stream behavior

High-volume streams — price tickers, sensor feeds, live search — can overwhelm collectors and UIs. A throttling behavior slows the stream down without touching the handler:

class ThrottlingStreamBehavior(
private val minInterval: Duration = 100.milliseconds,
) : StreamPipelineBehavior {
override val order = 0

// Only throttle streams that opt in via a marker interface
override fun appliesTo(request: StreamRequest<*>): Boolean =
request is HighVolumeStream

override fun <TRequest : StreamRequest<T>, T> process(
requestContext: RequestContext,
next: StreamHandlerDelegate<TRequest, T>,
request: TRequest,
): Flow<T> = next(request).onEach { delay(minInterval) }
}

onEach { delay(...) } applies backpressure: every item is delivered, at most one per interval — the producer suspends between emissions. For UI updates where only the freshest value matters, drop stale items instead:

// Latest-value rate limiting — stale items are dropped, never delivered late
override fun <TRequest : StreamRequest<T>, T> process(
requestContext: RequestContext,
next: StreamHandlerDelegate<TRequest, T>,
request: TRequest,
): Flow<T> = next(request).conflate().onEach { delay(minInterval) }
Operator chainSemantics
onEach { delay(t) }Every item delivered; producer suspends — true backpressure
conflate().onEach { … }At most one item per interval; always the most recent value
sample(t)Emits the latest value every t; requires @OptIn(FlowPreview::class)

Registering stream behaviors

Pass them via the streamPipelineBehaviors parameter of MediatorFactory.create — a separate list from regular pipelineBehaviors:

val mediator = MediatorFactory.create(
registrars = listOf(AppRegistrar()),
pipelineBehaviors = listOf(LoggingBehavior()), // wraps send()
streamPipelineBehaviors = listOf(
LoggingStreamBehavior(), // order -100, outermost
ThrottlingStreamBehavior(), // order 0
),
)

Regular PipelineBehaviors never see stream requests, and StreamPipelineBehaviors never see regular requests — the two chains are fully independent.


Next

Fallback Chains