Dataslope logoDataslope

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.

Code Block
C# 13

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.

Code Block
C# 13

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:

PhaseTypical operatorsPurpose
Sourcearray, list, file lines, queryWhere data comes from
FilterWhere, custom predicatesDiscard what doesn't matter
ShapeSelect, SelectMany, OrderBy, GroupByReshape into the form the consumer wants
AggregateSum, Count, Aggregate, ToListCollapse 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.

Code Block
C# 13

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

Challenge
C# 13

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.

QuestionSelect one

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.

On this page