Streamix: The Core Mental Model
If Streamix is going to be useful, the mental model has to be simple.
Not simplistic. Simple.
The library lives or dies on whether an experienced .NET engineer can look at a pipeline and answer basic behavioral questions without guessing. The easiest way to get there is to start from concepts the platform already has and only add what is necessary.
That is why the default mental model for Streamix is still IAsyncEnumerable<T>.
Start with cardinality
Streamix has two core shapes:
Stream<T>for 0..N valuesSingle<T>for 0..1 values
That split is small, but it carries real design value.
In ordinary application code, not every asynchronous operation produces an open-ended stream. Some produce exactly one logical result, or no result at all. Treating those the same as an arbitrary multi-item sequence makes composition less precise than it could be.
Consider the difference between these two operations:
- fetch a user by ID
- fetch that user’s orders
The first is naturally 0..1. The second is naturally 0..N.
That distinction shows up directly in Streamix:
var products =
GetUser(id) // Single<User>
.FlatMap(user => GetOrders(user)) // Stream<Order>
.Map(o => o.Product); // Stream<string>
There is no advanced theory here. It is just a more honest type-level representation of the shape of the data.
IAsyncEnumerable<T> is still the default mental model
This is the most important point in the entire library.
Streamix is not trying to replace the way .NET thinks about asynchronous sequences. It is trying to build a better compositional surface on top of that model.
The default assumptions are:
- streams are cold by default
- streams are pull-based by default
- enumeration drives execution
- cancellation and failures propagate through the async pipeline
If you already understand await foreach, you are most of the way there.
For example, this:
await foreach (var value in source.WithCancellation(ct))
{
if (value % 2 == 0)
{
await Console.Out.WriteLineAsync($"{value * 10}");
}
}
maps naturally to this:
await Stream.From(source)
.Filter(x => x % 2 == 0)
.Map(x => x * 10)
.ForEachAsync(Console.WriteLine, ct);
The second version is not better because it is shorter. It is better when a pipeline keeps growing and you want the transformation steps to stay visible without manually threading the control flow.
Cold by default means “execution is per subscriber”
In Streamix, the default behavior is intentionally conservative.
A stream is cold unless you explicitly make it hot. That means each subscriber re-enumerates the source. This matters because it keeps the baseline behavior aligned with normal IAsyncEnumerable<T> expectations.
Cold-by-default is a good default for three reasons:
- It avoids surprise sharing.
- It keeps side effects easier to reason about.
- It makes hot behavior an explicit decision instead of an accidental one.
If the source is expensive and should be shared, that needs to be visible in the pipeline through hot-stream primitives such as Publish, Replay, or RefCount. That topic deserves its own post, but the key point is simple: shared execution should not be the default guess.
Terminal execution should be obvious
Another part of the mental model is that building a pipeline is not the same thing as running it.
This should feel familiar to anyone who has worked with IAsyncEnumerable<T>. Defining a stream describes work. Terminal operations perform work.
For example:
var query = Stream.Range(1, 10)
.Filter(x => x % 2 == 0)
.Map(x => x * 10);
await query.ForEachAsync(Console.WriteLine);
The first block composes the pipeline. The second line executes it.
That separation matters because it keeps execution points explicit. It also makes cancellation, errors, and materialization easier to reason about. If the pipeline turns into a list, a channel, a sink, or a streamed HTTP response, that boundary should be visible.
Sequential unless the operator says otherwise
One of the easiest ways to make a stream abstraction unsafe is to hide concurrency behind innocent-looking operators.
Streamix tries not to do that.
The mental model should be:
- sequential operators are sequential
- ordered operators preserve source order
- concurrent unordered operators are explicit
That distinction becomes important fast.
For example:
await stream
.MapAwait(x => EnrichAsync(x))
.ForEachAsync(WriteAsync);
reads like a sequential async transform, and it should behave that way.
By contrast:
await stream
.Map(x => EnrichAsync(x), maxConcurrency: 8)
.ForEachAsync(WriteAsync);
is a different promise. It is concurrent, and by design it may produce results out of source order.
The point is not that one is better. The point is that they mean different things and the code should say which one you chose.
Single<T> is not just a convenience wrapper
It is tempting to treat Single<T> as a cosmetic type. That would undersell it.
In practice, Single<T> helps express service-layer composition more cleanly because many operations are conceptually single-result even when they are asynchronous.
That gives you a few benefits:
- pipeline types stay closer to the domain shape
- transitions from one-result to many-result flows are explicit
- APIs communicate intent more clearly than a generic sequence everywhere approach
For a library like Streamix, that kind of surface-area discipline matters. An early-stage abstraction gets more credible when the core types do a small amount of work well instead of flattening every operation into one universal type.
Where channels fit
Channels are still part of the story, but not the default story.
This is an important design choice. Streamix uses channels as an implementation and coordination tool where boundaries need them, especially for flow control, fan-out, or bounded concurrency. But the primary user-facing model is still stream composition, not manual queue orchestration.
That means you should usually think in this order:
- model the pipeline as a cold async stream
- add explicit concurrency where needed
- introduce channel boundaries when coordination or backpressure behavior actually requires them
This keeps the common case simpler while still leaving room for more operationally demanding workloads.
A useful rule of thumb
If you are unsure how to think about a Streamix pipeline, ask four questions:
- Is this
Single<T>orStream<T>? - Is this cold or explicitly hot?
- Which operator actually causes execution?
- Is concurrency sequential, ordered concurrent, or unordered concurrent?
If those answers are visible in the code, the pipeline is probably on the right track.
What comes next
The core model is intentionally small:
Stream<T>for 0..NSingle<T>for 0..1IAsyncEnumerable<T>as the baseline mental model- cold, pull-based execution by default
The next set of questions are the ones that usually decide whether a streaming abstraction holds up in production:
- when should a stream be shared?
- when should ordering be preserved?
- when should concurrency be allowed to reorder results?
That is where hot versus cold behavior and ordered versus unordered composition start to matter.
Important Links
For a closer look at the code and examples, check out the Streamix repo: https://github.com/khurram-uworx/streamix
Nugets:
- https://www.nuget.org/packages/Streamix
- https://www.nuget.org/packages/Streamix.Extensions
- https://www.nuget.org/packages/Streamix.AspNetCore