In this blog post, we will explore the practical application of a specific design pattern. To illustrate its usefulness, we will gradually reveal the problem in an “organic” manner, simulating how one might encounter such an issue in their daily programming tasks.
The What and Why
Picture this: you’re working on a music streaming platform, and you already implemented live and offline playback, search functionality, and user ratings. The last piece of the puzzle? Playlist suggestions based on user preferences. Seems simple, right? Just do some simple aggregations over likes and dislikes to figure out what genres of music the user is into, and serve up some recommendations based on that. So you decide to implement just that.
The How
Let’s assume our data is defined by the following entities.
publicenum MusicGenre { Rock, Classical, HipHop, // more genres omitted for clarity }
//for each track with "like" or "dislike", create this publicrecordVote(string TrackId, string UserId, bool IsUpvoted) { }
//Track is the most generic name I could think of :) publicrecordTrack( string Id, string ArtistId, MusicGenre Genre, string Name) { }
Now, let’s see how will the implementation look like. First, for simplicity’s sake, we will use Entity Framework and encapsulate access to track and votes data via the repository pattern.
Note: error and edge case handling are omitted for clarity and also, let’s assume the calling code handles opening and commit/rollback of transactions as needed
//simplistic implementation of data access publicclassTrackRepository : ITrackRepository { privatereadonly DbSet<Track> _tracks; //ctor omitted for clarity
//fetch tracks specified by a list of track IDs public IReadOnlyList<Track> FetchByIDs(IEnumerable<string> trackIdsToFind) { //in this case, EF generates IN statement in the WHERE clause return _tracks.Where(track => trackIdsToFind.Contains(track.Id)) .ToList(); }
//fetch a list of random tracks filtered by specified genre list //note: we selected 100 as max fetch count arbitrarily, obviously this should be configurable public IReadOnlyList<Track> FetchRandomByGenres(IEnumerable<MusicGenre> genres, int maxTracksToFetch = 100) { //in this case, EF generates IN statement in the WHERE clause return _tracks.Where(track => genres.Contains(track.Genre)) .OrderBy(track => Guid.NewGuid()) //ensure randomness :) .Take(maxTracksToFetch) .ToList(); } }
publicclassVoteRepository: IVoteRepository { privatereadonly DbSet<Vote> _votes; //ctor omitted for clarity
public IEnumerable<Track> FetchRecommendationFor(string userId) { //first fetch all the votes and aggregate them var userVotes = _voteRepo.FetchUpvotesFor(userId);
//fetch all upvoted tracks var upvotedTracks = _trackRepo.FetchByIDs(userVotes.Select(v => v.TrackId));
//count how many times each track was upvoted var groupedByGenre = upvotedTracks .GroupBy(x => x.Genre) .Select(x => new { Genre = x.Key, Count = x.Count() }) .OrderByDescending(x => x.Count);
//take the first three most liked genres var mostLikedGenres = groupedByGenre .Take(3) .Select(x => x.Genre);
//now get some random tracks for genres the user likes return _trackRepo.FetchRandomByGenres(mostLikedGenres); } }
After we have a simple but functional implementation of random playlist generation is implemented, the new system goes live. Everything works well, and then, after a while, users another way to generate a playlist by artist that play the music user liked - sort of “more of the same”.
Okay, I might hear you say, that is not that hard. Simply aggregate artists that created the music users liked and fetch more from the same artists. We can start from adding another method to TrackRepository
1 2 3 4 5 6 7 8
//fetch some random tracks from the same artist public IReadOnlyList<Track> FetchRandomByArtist(IEnumerable<string> artistIds) { return _tracks.Where(track => artistIds.Contains(track.ArtistId)) .OrderBy(Guid.NewGuid()) .ToList(); }
Next piece of the puzzle would be to implement the new playlist generator. As we can see, the implementation would be similar to “by genre” playlist generator but different enough that we can’t reuse the same method.
public IEnumerable<Track> FetchRecommendationFor(string userId) { //first fetch all the votes and aggregate them var userVotes = _voteRepo.FetchUpvotesFor(userId);
//fetch all upvoted tracks var upvotedTracks = _trackRepo.FetchByIDs(userVotes.Select(v => v.TrackId));
//now aggregate by artistID var groupedByArtists = upvotedTracks .GroupBy(x => x.ArtistId) .Select(x => new { ArtistId = x.Key, Count = x.Count() }) .OrderByDescending(x => x.Count);
//take the first three most liked genress var mostLikedArtists = groupedByArtists .Take(3) .Select(x => x.ArtistId); return _trackRepo.FetchRandomByArtist(mostLikedArtists); } }
```
After implementing another type of playlist generation, users seem to be happy with the change. A week later, another feature request comes in: users now would like to see a "discovery playlist" generator, which would suggest artists and genres the user never upvoted before. How can something like this be approached? Well, you might decide to create another playlist generator classbutbynowIthinkyouwillagreewithmethatcontinuinginthiswayisnotscalableinthelongterm. So, howcanweapproachthis?
public IEnumerable<Track> FetchRecommendationFor(string userId) => TrackRepo.FetchByPredicate(PredicateFilter); }
Alright, so now we’ve got a class that fetches recommendations, and we can mess around with how it fetches them by overriding the PredicateFilter. But there is something missing: aggregation. If you take a look at the already implemented playlist generators, you would see the following pattern:
Fetch the tracks based on some critera (we just implemented it above)
Aggregate the tracks with “group by” clause and take some random tracks from the aggregated data
Sure, we could try to cram both (1) and (2) into the PredicateFilter, but let’s be honest, in a more complex code-base that’s going to turn into a maintenance nightmare after enough time has passed. Instead, let’s modify the abstract class PlaylistGenerator to include the second stage of the algorithm:
public IEnumerable<Track> FetchRecommendationFor(string userId) { var tracksToAggregate = VoteRepo.FetchByPredicate(PredicateFilter); var tracksOfGroupedTracks = tracksToAggregate .GroupBy(AggregationKey) .Take(MaxTrackGroupsToTake) .SelectMany(x => x.Select(g => g.TrackId));
var results = TrackRepo .FetchByIDs(tracksOfGroupedTracks) .OrderBy(Guid.NewGuid()) .Take(MaxResults) .ToList();
return results; } }
As we can see, in the FetchRecommendationFor implementation above, we have the algorithm structure unchanging but it can be influenced by override of an abstract properties. Now, all we have left is to implement PlaylistRecommenderByArtistPreferences and PlaylistRecommenderByGenrePreferences.
Let’s start from PlaylistRecommenderByGenrePreferences:
So, let’s review our epic battle with the Spaghetti Monster
We’ve fought with the Spaghetti Monster and emerged victorious. By utilizing the Template Method Pattern, we’ve brought that messy code closer to a well-organized and maintainable piece of art. And yes, I might be exaggerating a bit, but you get the idea.
We have improved:
Clarity: Our code is now more readable and easier to understand. No more tangled noodles to decipher!
Stability: By separating the logic into different methods, we’ve reduced the risk of unwanted side effects.
Maintainability: Future updates and changes will be much easier, as each method has a clear purpose and responsibility.
So, the next time you find yourself lost in the depths of bad code, remember that the Template Method Pattern may help to disentangle at least some of the mess. May the clean code be with you, and may your sandwiches always be delicious!