Composing Pipelines
From one-off chains to reusable, named transformations — building your own LINQ vocabulary
Once you're comfortable with LINQ, a new opportunity appears: you
can stop copy-pasting pipelines and start naming them. A pipeline
that returns IEnumerable<T> is itself a value you can hand around,
combine, and extend.
This page is about turning ad-hoc chains into a small, reusable vocabulary tailored to your domain.
The "every pipeline is a value" insight
Because LINQ operators return IEnumerable<T>, any chain of
operators is just a method that returns IEnumerable<T>.
IEnumerable<Order> Active(IEnumerable<Order> orders) =>
orders.Where(o => o.Status == "active");
IEnumerable<Order> Recent(IEnumerable<Order> orders, DateTime since) =>
orders.Where(o => o.PlacedAt >= since);
IEnumerable<Order> HighValue(IEnumerable<Order> orders) =>
orders.Where(o => o.Total > 1000);You can now write the intent directly:
var recentActiveHighValue = HighValue(Recent(Active(orders), DateTime.UtcNow.AddDays(-30)));That's already clearer than a 4-line .Where(...).Where(...).Where(...),
but we can do better.
Extension methods: pipelines that look native
Extensions let your custom pipelines chain like built-in LINQ.
The pipeline now reads as English: "active orders, recent, high value, sum total." This is the heart of declarative code.
Building custom operators with yield
You're not limited to combining existing operators. yield return
lets you implement entirely new ones, and they remain lazy.
A classic example: an operator that pairs each element with the previous one.
Notice the operator integrated seamlessly into the chain. Because
it uses yield return, it stays lazy: a million-element source
costs no extra memory.
Anatomy of a composable pipeline
A well-composed pipeline tends to have four phases:
| Phase | Typical operators | Purpose |
|---|---|---|
| Source | array, list, file lines, query | Where data comes from |
| Filter | Where, custom predicates | Discard what doesn't matter |
| Shape | Select, SelectMany, OrderBy, GroupBy | Reshape into the form the consumer wants |
| Aggregate | Sum, Count, Aggregate, ToList | Collapse into a single value or fixed collection |
Custom extension methods usually slot into the filter or shape phases. Aggregations are usually one-line and don't need wrapping.
Composing with higher-order helpers
You can also compose at the lambda level — building predicates and selectors from smaller pieces.
The same trick scales to selector composition (Func<A, B> plus
Func<B, C> = Func<A, C>), giving you a tiny algebra of functions
to combine before they ever touch LINQ.
Practice: assemble a vocabulary
Notice the shape of the solution: each method is tiny — typically one line of LINQ. The power comes from naming the intent, not from clever code.
When NOT to wrap a pipeline
Composition is a tool, not an obligation.
- If a chain is used in only one place and is already clear, leave it inline.
- If the wrapper name is no shorter or clearer than the LINQ it hides, drop it.
- A wrapper that takes 8 parameters is not a wrapper, it's a configuration object pretending to be one.
The goal is to grow a small vocabulary that matches the domain — not to abstract for abstraction's sake.
Why is implementing a custom operator with yield return preferable to one that builds a List<T> internally?
The yield version uses less heap memory because lists are reference types.
The compiler refuses to chain List-returning operators in LINQ.
The yield version preserves laziness — downstream operators like Take(5) can stop the source early.
Lists cannot store generic types.