Blog

Notes on the .Net ecosystem, Angular, Software Architecture, Machine Learning and CyberSecurity

← All posts
Building the rock recommender API
  • ASP.NET Core
  • MongoDB
  • .NET

Building the rock recommender API

We have reached the last article of the series. The road so far: the types of recommendation and cold start (article 1), how to really evaluate a recommender (article 2), training the model with ML.NET (article 3) and evaluating that model in practice (article 4). One piece is missing: putting all of this to work inside an API. The full code is open on GitHub, in the RockRecommender repository, for anyone who wants to follow along line by line.

The architecture, without exaggeration

The goal here was never to build a complex system, so the architecture stays simple, in layers: Domain (the entities and the repository interfaces), Application (the services that decide what to do), Infrastructure (the real MongoDB implementation and the loaded model) and Api (the controllers). Repository pattern to isolate MongoDB from the rest of the code, and one rule that is easy to state and easy to check: a controller only ever talks to a service, never straight to a repository or to an entity. Nothing beyond that, keeping the architecture lean and easy to follow.

The three endpoints

The whole API revolves around three operations:

  • POST /users: creates a new user, receiving the list of bands they like (the cold start from article 1)
  • GET /users/{id}/next-song: returns the next recommended song for that user
  • POST /users/{id}/feedback: records the like or dislike on a recommended song

Storing the data in MongoDB

Each entity has its own collection: users, the song catalog, the like/dislike feedback and a history of what has already been recommended (this last one is what avoids repeating a song, more on that shortly). Access follows the repository pattern, hiding MongoDB.Driver behind an interface. The repository talks to its own Mongo document shape and turns it into the real entity on the way out:

public interface ISongRepository
{
    Task<List<Song>> GetAllAsync();
}

public sealed class SongRepository(MongoContext context) : ISongRepository
{
    public async Task<List<Song>> GetAllAsync()
    {
        var documents = await context.Songs.Find(Builders<SongDocument>.Filter.Empty).ToListAsync();
        return [.. documents.Select(ToDomain)];
    }

    private static Song ToDomain(SongDocument document) =>
        Song.Create(document.Id, document.Title, document.Band, document.Genre).Value!;
}

That Song.Create(...) call is not decoration. Further down you will see that the entities got richer since article 3, and creating one now goes through validation instead of a plain constructor with no checks. The rest of the repositories (users, feedback, recommendation history) follow exactly the same shape, so I won't repeat each one here, but the full code is in the repository for anyone who wants to take a closer look.

The recommendation service: joining content and collaborative

This is the heart of the API, and it's where the two strategies from article 1 meet. The model trained in article 3 is loaded a single time, behind a small port (ICollaborativeRecommender), and reused on every call:

public sealed class RecommendationService(
    ISongRepository songRepository,
    IUserRepository userRepository,
    IFeedbackRepository feedbackRepository,
    IRecommendationLogRepository recommendationLogRepository,
    ICollaborativeRecommender collaborativeRecommender)
{
    public async Task<Result<SongResponse>> GetNextSongAsync(Guid userId)
    {
        var userResult = await GetUserAsync(userId);

        if (!userResult.IsSuccess)
            return Result<SongResponse>.NotFound(userResult.Error!);

        var candidatesResult = await GetCandidateSongsAsync(userId);

        if (!candidatesResult.IsSuccess)
            return Result<SongResponse>.Conflict(candidatesResult.Error!);

        var feedbackHistory = await GetFeedbackHistoryAsync(userId);

        var songResult = feedbackHistory.HasAnyFeedback
            ? PickByCollaborativeModel(userId, candidatesResult.Value!)
            : Result<Song>.Success(PickByContent(userResult.Value!, candidatesResult.Value!));

        if (!songResult.IsSuccess)
            return Result<SongResponse>.Unavailable(songResult.Error!);

        await RegisterAsShownAsync(userId, songResult.Value!);

        return Result<SongResponse>.Success(ToResponse(songResult.Value!));
    }

    private static Song PickByContent(User user, List<Song> candidates)
    {
        var likedSongs = candidates.Where(user.Likes).ToList();
        var pool = likedSongs.Count > 0 ? likedSongs : candidates;

        return pool[Random.Shared.Next(pool.Count)];
    }
}

Two design choices are worth calling out here. Nobody throws an exception when the user doesn't exist, the catalog is empty or the model isn't loaded yet, those are expected outcomes of a lookup, not bugs. Every step returns a Result, a small object that says whether it worked and, if not, why, and the private helpers you don't see here (GetUserAsync, GetCandidateSongsAsync, and so on) each return their own Result too, one responsibility per method. And look at PickByContent: it doesn't compare song.Band against the liked list by hand, it just asks user.Likes(song). The judgment of "does this person like this song" belongs to the User entity itself, not to the service peeking at its fields from outside.

Notice the cold start actually happening: with no feedback yet, the choice prioritizes songs from the bands the person marked as favorite at signup, and picks among them at random so the same person doesn't always hear the same first song. After the first like or dislike, the collaborative model takes over, exactly as explained in article 1.

Avoiding repeating a song

The history of what has already been shown to a user lives behind its own little concept, RecommendationHistory: given the full catalog and the list of songs already shown, it picks the ones still unseen, or falls back to the whole catalog if there is nothing left to discover. Every recommended song gets logged right after it's chosen, and the next calls filter based on that history. Only when the catalog runs out does the API go back to considering everything, allowing a repeat, but as a last resort, not as a rule.

The endpoints, now behind a real controller

The three endpoints used to live directly inside Program.cs as Minimal API routes, but as the services grew a controller reads better and keeps Program.cs boring, which is exactly what you want from it. There is one shared base, ApiControllerBase, whose only job is turning a Result into the right HTTP response, so no controller repeats that logic:

[Route("users")]
public sealed class UsersController(
    UserService userService,
    FeedbackService feedbackService,
    RecommendationService recommendationService) : ApiControllerBase
{
    [HttpPost]
    public async Task<ActionResult<UserResponse>> CreateUser(CreateUserRequest request)
    {
        var result = await userService.CreateUserAsync(request);

        if (!result.IsSuccess)
            return ToActionResult(result);

        return Created($"/users/{result.Value!.Id}", result.Value);
    }

    [HttpGet("{userId:guid}/next-song")]
    public async Task<ActionResult<SongResponse>> GetNextSong(Guid userId)
    {
        var result = await recommendationService.GetNextSongAsync(userId);

        return ToActionResult(result);
    }

    [HttpPost("{userId:guid}/feedback")]
    public async Task<IActionResult> SubmitFeedback(Guid userId, FeedbackRequest request)
    {
        await feedbackService.SubmitFeedbackAsync(userId, request);

        return NoContent();
    }
}

Notice the controller never imports a repository or an entity, only the three application services and the DTOs they speak in (CreateUserRequest, UserResponse, and so on). That is not an accident, it is a rule worth enforcing on purpose: if a controller ever needs to reach into a repository directly, that logic belongs in a service instead. And since a public API is meant to be explored, Swagger is wired in and serves as the home page of the running API, so anyone cloning the repository sees the three endpoints documented the moment it starts.

Testing the recommendation logic

The most important thing to test is precisely the switch between content and collaborative, because it is the central business rule of the service. A unit test, using a fake repository in place of MongoDB:

[Fact]
public async Task GetNextSongAsync_WithNoFeedback_RecommendsSongFromLikedBand()
{
    // Arrange
    var songRepository = new FakeSongRepository();
    songRepository.Songs.Add(Song.Create(Guid.NewGuid(), "Master of Puppets", "Metallica", "Thrash Metal").Value!);
    songRepository.Songs.Add(Song.Create(Guid.NewGuid(), "Run to the Hills", "Iron Maiden", "Heavy Metal").Value!);
    var userRepository = new FakeUserRepository();
    var user = User.Create(Guid.NewGuid(), ["Metallica"]).Value!;
    userRepository.UsersById[user.Id] = user;
    var service = new RecommendationService(
        songRepository,
        userRepository,
        new FakeFeedbackRepository(),
        new FakeRecommendationLogRepository(),
        new FakeCollaborativeRecommender());

    // Act
    var result = await service.GetNextSongAsync(user.Id);

    // Assert
    Assert.True(result.IsSuccess);
    Assert.Equal("Metallica", result.Value!.Band);
}

Series conclusion

Recapping the five articles: we understood the two ways of recommending and the cold start problem, we learned how to really measure whether a recommender is good with Precision@K, Recall@K and NDCG, we trained a collaborative model with ML.NET, we evaluated that model with the same metrics, and now it is alive inside an API, with MongoDB keeping each person's history.

The full code, with the rock dataset, the training, the evaluation and the API, is open in the RockRecommender repository on GitHub. Feel free to clone it, train it with your own musical taste, and watch cold start and the collaborative model take turns in practice.

Sources