Song recommendations from C# combinators

LINQ-style composition, including SelectMany and Traverse. This article is part of a larger series titled Alternative ways to design with functional programming. In the previous article, I described, in general terms, a pragmatic small-scale architecture that may look functional, although it really isn't. Please consult the previous articles for context about the example code base. The code shown in this article is from the combinators Git branch. The goal is to extract pure functions from the overall recommendations algorithm and compose them using standard combinators, such as SelectMany (monadic bind), Select, and Traverse. Composition from combinators # Let's start with the completed composition, and subsequently look at the most interesting parts. public Task GetRecommendationsAsync(string userName) {     // 1. Get user's own top scrobbles     // 2. Get other users who listened to the same songs     // 3. Get top scrobbles of those users     // 4. Aggregate the songs into recommendations     return _songService.GetTopScrobblesAsync(userName)         .SelectMany(scrobbles => UserTopScrobbles(scrobbles)             .Traverse(scrobble => _songService                 .GetTopListenersAsync(scrobble.Song.Id)                 .Select(TopListeners)                 .SelectMany(users => users                     .Traverse(user => _songService                         .GetTopScrobblesAsync(user.UserName)                         .Select(TopScrobbles))                     .Select(Songs)))         .Select(TakeTopRecommendations)); } This is a single expression with nested subexpressions. The functions UserTopScrobbles, TopListeners, TopScrobbles, Songs, and TakeTopRecommendations are private helper functions. Here's one of them: private static IEnumerable UserTopScrobbles(IEnumerable scrobbles) {     return scrobbles.OrderByDescending(scrobble => scrobble.ScrobbleCount).Take(100); } The other helpers are also simple, single-expression functions like this one. As Oleksii Holub implies, you could make each of these small functions public if you wished to test them individually. Let's now look at the various building blocks that enable this composition. Asynchronous monad # C# (or .NET) in general only comes with standard combinators for IEnumerable, so whenever you need them for other monads, you have to define them yourself (or pull in a reusable library that defines them). For the above composition, you'll need SelectMany and Select for Task computations. You can see implementations in the article Asynchronous monads, so I'll not repeat the code here. One exception is this extension method, which is a variant monadic return, which I'm not sure if I've published before: internal static Task AsTask(this T source) {     return Task.FromResult(source); } Nothing much is going on here, since it's just a wrapper of Task.FromResult. The this keyword, however, makes AsTask an extension method, which makes usage marginally prettier. It's not used in the above composition, but, as you'll see below, in the implementation of Traverse. Traversal # The traversal could be implemented from a hypothetical Sequence action, but you can also implement it directly, which is what I chose to do here. internal static Task Traverse(     this IEnumerable source,     Func selector) {     return source         .Select(selector)         .Aggregate(             Enumerable.Empty().AsTask(),             async (acc, x) => (await acc).Append(await x)); } Mapping selector over source produces a sequence of tasks. The Aggregate expression subsequently inverts the containers to a single task that contains a sequence of result values. That's really all there is to it. Conclusion # In the previous article, I made no secret of my position on this refactoring. For the example at hand, the benefit is at best marginal. The purpose of this article isn't to insist that you must write code like this. Rather, it's a demonstration of what's possible. If you have a problem which is similar, but more complicated, refactoring to standard combinators may be a good idea. After all, a standard combinator like SelectMany, Traverse, etc. is well-understood and lawful. You should expect combinators to be defect-free, so using them instead of ad-hoc code constructs like nested loops with conditionals could help eliminate some trivial bugs. Additionally, if you're working with a team comfortable with these few abstractions, code assembled from standard combinators may actually turn out to be more read

Jun 16, 2025 - 10:00
 0
Song recommendations from C# combinators

LINQ-style composition, including SelectMany and Traverse.

This article is part of a larger series titled Alternative ways to design with functional programming. In the previous article, I described, in general terms, a pragmatic small-scale architecture that may look functional, although it really isn't.

Please consult the previous articles for context about the example code base. The code shown in this article is from the combinators Git branch.

The goal is to extract pure functions from the overall recommendations algorithm and compose them using standard combinators, such as SelectMany (monadic bind), Select, and Traverse.

Composition from combinators #

Let's start with the completed composition, and subsequently look at the most interesting parts.

public Task<IReadOnlyList<Song>> GetRecommendationsAsync(string userName)
{
    // 1. Get user's own top scrobbles
    // 2. Get other users who listened to the same songs
    // 3. Get top scrobbles of those users
    // 4. Aggregate the songs into recommendations
 
    return _songService.GetTopScrobblesAsync(userName)
        .SelectMany(scrobbles => UserTopScrobbles(scrobbles)
            .Traverse(scrobble => _songService
                .GetTopListenersAsync(scrobble.Song.Id)
                .Select(TopListeners)
                .SelectMany(users => users
                    .Traverse(user => _songService
                        .GetTopScrobblesAsync(user.UserName)
                        .Select(TopScrobbles))
                    .Select(Songs)))
        .Select(TakeTopRecommendations));
}

This is a single expression with nested subexpressions.

The functions UserTopScrobbles, TopListeners, TopScrobbles, Songs, and TakeTopRecommendations are private helper functions. Here's one of them:

private static IEnumerable<ScrobbleUserTopScrobbles(IEnumerable<Scrobblescrobbles)
{
    return scrobbles.OrderByDescending(scrobble => scrobble.ScrobbleCount).Take(100);
}

The other helpers are also simple, single-expression functions like this one.

As Oleksii Holub implies, you could make each of these small functions public if you wished to test them individually.

Let's now look at the various building blocks that enable this composition.

Asynchronous monad #

C# (or .NET) in general only comes with standard combinators for IEnumerable, so whenever you need them for other monads, you have to define them yourself (or pull in a reusable library that defines them). For the above composition, you'll need SelectMany and Select for Task computations. You can see implementations in the article Asynchronous monads, so I'll not repeat the code here.

One exception is this extension method, which is a variant monadic return, which I'm not sure if I've published before:

internal static Task<TAsTask<T>(this T source)
{
    return Task.FromResult(source);
}

Nothing much is going on here, since it's just a wrapper of Task.FromResult. The this keyword, however, makes AsTask an extension method, which makes usage marginally prettier. It's not used in the above composition, but, as you'll see below, in the implementation of Traverse.

Traversal #

The traversal could be implemented from a hypothetical Sequence action, but you can also implement it directly, which is what I chose to do here.

internal static Task<IEnumerable<TResult>> Traverse<TTResult>(
    this IEnumerable<Tsource,
    Func<TTask<TResult>> selector)
{
    return source
        .Select(selector)
        .Aggregate(
            Enumerable.Empty<TResult>().AsTask(),
            async (accx) => (await acc).Append(await x));
}

Mapping selector over source produces a sequence of tasks. The Aggregate expression subsequently inverts the containers to a single task that contains a sequence of result values.

That's really all there is to it.

Conclusion #

In the previous article, I made no secret of my position on this refactoring. For the example at hand, the benefit is at best marginal. The purpose of this article isn't to insist that you must write code like this. Rather, it's a demonstration of what's possible.

If you have a problem which is similar, but more complicated, refactoring to standard combinators may be a good idea. After all, a standard combinator like SelectMany, Traverse, etc. is well-understood and lawful. You should expect combinators to be defect-free, so using them instead of ad-hoc code constructs like nested loops with conditionals could help eliminate some trivial bugs.

Additionally, if you're working with a team comfortable with these few abstractions, code assembled from standard combinators may actually turn out to be more readable that code buried in ad-hoc imperative control flow. And if not everyone on the team is on board with this style, perhaps it's an opportunity to push the envelope a bit.

Of course, if you use a language where such constructs are already idiomatic, colleagues should already be used to this style of programming.

Next: Song recommendations from F# combinators.


This blog is totally free, but if you like it, please consider supporting it.