π Table of Contents
- Introduction & Architecture
- Project Setup
- Core Domain Models
- Database Setup & Context
- Repository Pattern Implementation
- Content-Based Filtering Engine
- Collaborative Filtering with ML.NET
- Hybrid Recommendation Engine
- API Controllers & Endpoints
- Configuration & Dependency Injection
- Database Seeding & Test Data
- Error Handling & Middleware
- Testing
- Docker & Deployment
- Running the Application
1. Introduction & Architecture {#section-1}
What We're Building
A complete movie recommendation system that suggests personalized movies using:
- β Content-based filtering (based on movie features)
- β Collaborative filtering (based on user behavior patterns)
- β Hybrid approach (combining multiple strategies)
- β ML.NET matrix factorization (production-ready machine learning)
Architecture Overview
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β API Layer β
β Controllers, Middleware, Authentication β
ββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββ
β Application/Core Layer β
β Services, Entities, Interfaces, Business Logic β
ββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββ
β Infrastructure Layer β
β Repositories, DbContext, ML.NET, Caching β
ββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββ
β Database (SQL Server) β
β Movies, Users, Interactions β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Tech Stack
- .NET 8.0
- ML.NET (Recommendation)
- Entity Framework Core
- SQL Server
- Swagger/OpenAPI
- xUnit (Testing)
2. Project Setup {#section-2}
Step 1: Create Solution Structure
# Create solution directory
mkdir MovieRecommendation
cd MovieRecommendation
# Create projects
dotnet new webapi -n MovieRecommendation.API
dotnet new classlib -n MovieRecommendation.Core
dotnet new classlib -n MovieRecommendation.Infrastructure
dotnet new xunit -n MovieRecommendation.Tests
# Create solution
dotnet new sln -n MovieRecommendation
# Add projects to solution
dotnet sln add MovieRecommendation.API
dotnet sln add MovieRecommendation.Core
dotnet sln add MovieRecommendation.Infrastructure
dotnet sln add MovieRecommendation.Tests
# Add project references
cd MovieRecommendation.API
dotnet add reference ../MovieRecommendation.Core
dotnet add reference ../MovieRecommendation.Infrastructure
cd ..
cd MovieRecommendation.Infrastructure
dotnet add reference ../MovieRecommendation.Core
cd ..
cd MovieRecommendation.Tests
dotnet add reference ../MovieRecommendation.API
dotnet add reference ../MovieRecommendation.Core
dotnet add reference ../MovieRecommendation.Infrastructure
cd ..
Step 2: Install Required NuGet Packages
# Core project - ML.NET
cd MovieRecommendation.Core
dotnet add package Microsoft.ML --version 3.0.1
dotnet add package Microsoft.ML.Recommender --version 0.21.1
cd ..
# Infrastructure project - EF Core
cd MovieRecommendation.Infrastructure
dotnet add package Microsoft.EntityFrameworkCore --version 8.0.0
dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 8.0.0
dotnet add package Microsoft.EntityFrameworkCore.Tools --version 8.0.0
dotnet add package Microsoft.Extensions.Caching.Memory --version 8.0.0
cd ..
# API project
cd MovieRecommendation.API
dotnet add package Swashbuckle.AspNetCore --version 6.5.0
dotnet add package Microsoft.EntityFrameworkCore.Design --version 8.0.0
cd ..
# Tests project
cd MovieRecommendation.Tests
dotnet add package Moq --version 4.20.70
dotnet add package Microsoft.EntityFrameworkCore.InMemory --version 8.0.0
cd ..
3. Core Domain Models {#section-3}
MovieRecommendation.Core/Entities/Movie.cs
namespace MovieRecommendation.Core.Entities;
public class Movie
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public List<string> Genres { get; set; } = new();
public List<string> Actors { get; set; } = new();
public string Director { get; set; } = string.Empty;
public int ReleaseYear { get; set; }
public int DurationMinutes { get; set; }
public double AverageRating { get; set; }
public int TotalRatings { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
MovieRecommendation.Core/Entities/User.cs
namespace MovieRecommendation.Core.Entities;
public class User
{
public int Id { get; set; }
public string Username { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public List<string> FavoriteGenres { get; set; } = new();
public List<string> FavoriteActors { get; set; } = new();
public int PreferredMinYear { get; set; }
public int PreferredMaxYear { get; set; } = 2024;
public int PreferredMinDuration { get; set; }
public int PreferredMaxDuration { get; set; } = 200;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
MovieRecommendation.Core/Entities/UserInteraction.cs
namespace MovieRecommendation.Core.Entities;
public class UserInteraction
{
public int Id { get; set; }
public int UserId { get; set; }
public int MovieId { get; set; }
public InteractionType Type { get; set; }
public double? Rating { get; set; } // 1-5 stars
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
// Navigation properties
public User? User { get; set; }
public Movie? Movie { get; set; }
}
public enum InteractionType
{
View = 1,
Rating = 2,
Favorite = 3,
WatchedComplete = 4,
Dismissed = 5
}
MovieRecommendation.Core/Models/MovieRating.cs (ML.NET Models)
using Microsoft.ML.Data;
namespace MovieRecommendation.Core.Models;
public class MovieRating
{
[LoadColumn(0)]
public float UserId { get; set; }
[LoadColumn(1)]
public float MovieId { get; set; }
[LoadColumn(2)]
public float Rating { get; set; }
}
public class MovieRatingPrediction
{
public float Score { get; set; }
}
MovieRecommendation.Core/Models/RecommendationResult.cs
namespace MovieRecommendation.Core.Models;
public class RecommendationResult
{
public int MovieId { get; set; }
public string Title { get; set; } = string.Empty;
public double OverallScore { get; set; }
public double ContentScore { get; set; }
public double CollaborativeScore { get; set; }
public double PopularityScore { get; set; }
public Dictionary<string, double> DetailedScores { get; set; } = new();
public string ReasonForRecommendation { get; set; } = string.Empty;
public DateTime GeneratedAt { get; set; } = DateTime.UtcNow;
}
public class RecommendationWeights
{
public double ContentWeight { get; set; } = 0.4;
public double CollaborativeWeight { get; set; } = 0.3;
public double PopularityWeight { get; set; } = 0.2;
public double RecencyWeight { get; set; } = 0.1;
// Content-based sub-weights
public double GenreWeight { get; set; } = 0.35;
public double ActorWeight { get; set; } = 0.25;
public double YearWeight { get; set; } = 0.20;
public double DurationWeight { get; set; } = 0.20;
}
4. Database Setup & Context {#section-4}
MovieRecommendation.Infrastructure/Data/AppDbContext.cs
using Microsoft.EntityFrameworkCore;
using MovieRecommendation.Core.Entities;
namespace MovieRecommendation.Infrastructure.Data;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public DbSet<Movie> Movies => Set<Movie>();
public DbSet<User> Users => Set<User>();
public DbSet<UserInteraction> UserInteractions => Set<UserInteraction>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Movie configuration
modelBuilder.Entity<Movie>(entity =>
{
entity.ToTable("Movies");
entity.HasKey(e => e.Id);
entity.Property(e => e.Title)
.IsRequired()
.HasMaxLength(200);
entity.Property(e => e.Description)
.HasMaxLength(2000);
entity.Property(e => e.Director)
.HasMaxLength(100);
// Convert List<string> to comma-separated string for storage
entity.Property(e => e.Genres)
.HasConversion(
v => string.Join(',', v),
v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList())
.HasMaxLength(500);
entity.Property(e => e.Actors)
.HasConversion(
v => string.Join(',', v),
v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList())
.HasMaxLength(1000);
// Indexes for better query performance
entity.HasIndex(e => e.ReleaseYear);
entity.HasIndex(e => e.AverageRating);
entity.HasIndex(e => e.CreatedAt);
});
// User configuration
modelBuilder.Entity<User>(entity =>
{
entity.ToTable("Users");
entity.HasKey(e => e.Id);
entity.Property(e => e.Username)
.IsRequired()
.HasMaxLength(100);
entity.Property(e => e.Email)
.IsRequired()
.HasMaxLength(200);
entity.Property(e => e.FavoriteGenres)
.HasConversion(
v => string.Join(',', v),
v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList())
.HasMaxLength(500);
entity.Property(e => e.FavoriteActors)
.HasConversion(
v => string.Join(',', v),
v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList())
.HasMaxLength(1000);
entity.HasIndex(e => e.Username).IsUnique();
entity.HasIndex(e => e.Email).IsUnique();
});
// UserInteraction configuration
modelBuilder.Entity<UserInteraction>(entity =>
{
entity.ToTable("UserInteractions");
entity.HasKey(e => e.Id);
entity.Property(e => e.Type)
.HasConversion<string>()
.HasMaxLength(50);
// Relationships
entity.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Movie)
.WithMany()
.HasForeignKey(e => e.MovieId)
.OnDelete(DeleteBehavior.Cascade);
// Indexes
entity.HasIndex(e => new { e.UserId, e.MovieId });
entity.HasIndex(e => e.Timestamp);
entity.HasIndex(e => e.Type);
});
}
}
5. Repository Pattern Implementation {#section-5}
MovieRecommendation.Core/Repositories/IMovieRepository.cs
using MovieRecommendation.Core.Entities;
namespace MovieRecommendation.Core.Repositories;
public interface IMovieRepository
{
Task<Movie?> GetByIdAsync(int id);
Task<List<Movie>> GetAvailableMoviesAsync(int limit);
Task<List<Movie>> GetMoviesByGenreAsync(string genre, int limit);
Task<List<Movie>> GetMoviesByActorAsync(string actor, int limit);
Task<Movie> AddAsync(Movie movie);
Task UpdateAsync(Movie movie);
Task DeleteAsync(int id);
}
MovieRecommendation.Core/Repositories/IUserRepository.cs
using MovieRecommendation.Core.Entities;
namespace MovieRecommendation.Core.Repositories;
public interface IUserRepository
{
Task<User?> GetByIdAsync(int id);
Task<User?> GetByUsernameAsync(string username);
Task<User> AddAsync(User user);
Task UpdateAsync(User user);
Task DeleteAsync(int id);
}
MovieRecommendation.Core/Repositories/IInteractionRepository.cs
using MovieRecommendation.Core.Entities;
namespace MovieRecommendation.Core.Repositories;
public interface IInteractionRepository
{
Task<List<UserInteraction>> GetUserInteractionsAsync(int userId);
Task<List<UserInteraction>> GetAllRatingsAsync();
Task<UserInteraction> AddAsync(UserInteraction interaction);
Task<int> GetInteractionCountAsync();
}
MovieRecommendation.Infrastructure/Repositories/MovieRepository.cs
using Microsoft.EntityFrameworkCore;
using MovieRecommendation.Core.Entities;
using MovieRecommendation.Core.Repositories;
using MovieRecommendation.Infrastructure.Data;
namespace MovieRecommendation.Infrastructure.Repositories;
public class MovieRepository : IMovieRepository
{
private readonly AppDbContext _context;
public MovieRepository(AppDbContext context)
{
_context = context;
}
public async Task<Movie?> GetByIdAsync(int id)
{
return await _context.Movies.FindAsync(id);
}
public async Task<List<Movie>> GetAvailableMoviesAsync(int limit)
{
return await _context.Movies
.OrderByDescending(m => m.CreatedAt)
.Take(limit)
.ToListAsync();
}
public async Task<List<Movie>> GetMoviesByGenreAsync(string genre, int limit)
{
return await _context.Movies
.Where(m => m.Genres.Contains(genre))
.OrderByDescending(m => m.AverageRating)
.Take(limit)
.ToListAsync();
}
public async Task<List<Movie>> GetMoviesByActorAsync(string actor, int limit)
{
return await _context.Movies
.Where(m => m.Actors.Contains(actor))
.OrderByDescending(m => m.AverageRating)
.Take(limit)
.ToListAsync();
}
public async Task<Movie> AddAsync(Movie movie)
{
_context.Movies.Add(movie);
await _context.SaveChangesAsync();
return movie;
}
public async Task UpdateAsync(Movie movie)
{
_context.Movies.Update(movie);
await _context.SaveChangesAsync();
}
public async Task DeleteAsync(int id)
{
var movie = await GetByIdAsync(id);
if (movie != null)
{
_context.Movies.Remove(movie);
await _context.SaveChangesAsync();
}
}
}
MovieRecommendation.Infrastructure/Repositories/UserRepository.cs
using Microsoft.EntityFrameworkCore;
using MovieRecommendation.Core.Entities;
using MovieRecommendation.Core.Repositories;
using MovieRecommendation.Infrastructure.Data;
namespace MovieRecommendation.Infrastructure.Repositories;
public class UserRepository : IUserRepository
{
private readonly AppDbContext _context;
public UserRepository(AppDbContext context)
{
_context = context;
}
public async Task<User?> GetByIdAsync(int id)
{
return await _context.Users.FindAsync(id);
}
public async Task<User?> GetByUsernameAsync(string username)
{
return await _context.Users
.FirstOrDefaultAsync(u => u.Username == username);
}
public async Task<User> AddAsync(User user)
{
_context.Users.Add(user);
await _context.SaveChangesAsync();
return user;
}
public async Task UpdateAsync(User user)
{
_context.Users.Update(user);
await _context.SaveChangesAsync();
}
public async Task DeleteAsync(int id)
{
var user = await GetByIdAsync(id);
if (user != null)
{
_context.Users.Remove(user);
await _context.SaveChangesAsync();
}
}
}
MovieRecommendation.Infrastructure/Repositories/InteractionRepository.cs
using Microsoft.EntityFrameworkCore;
using MovieRecommendation.Core.Entities;
using MovieRecommendation.Core.Repositories;
using MovieRecommendation.Infrastructure.Data;
namespace MovieRecommendation.Infrastructure.Repositories;
public class InteractionRepository : IInteractionRepository
{
private readonly AppDbContext _context;
public InteractionRepository(AppDbContext context)
{
_context = context;
}
public async Task<List<UserInteraction>> GetUserInteractionsAsync(int userId)
{
return await _context.UserInteractions
.Where(i => i.UserId == userId)
.OrderByDescending(i => i.Timestamp)
.ToListAsync();
}
public async Task<List<UserInteraction>> GetAllRatingsAsync()
{
return await _context.UserInteractions
.Where(i => i.Type == InteractionType.Rating && i.Rating.HasValue)
.ToListAsync();
}
public async Task<UserInteraction> AddAsync(UserInteraction interaction)
{
_context.UserInteractions.Add(interaction);
await _context.SaveChangesAsync();
return interaction;
}
public async Task<int> GetInteractionCountAsync()
{
return await _context.UserInteractions
.Where(i => i.Type == InteractionType.Rating)
.CountAsync();
}
}
6. Content-Based Filtering Engine {#section-6}
MovieRecommendation.Core/Services/IContentBasedEngine.cs
using MovieRecommendation.Core.Entities;
using MovieRecommendation.Core.Models;
namespace MovieRecommendation.Core.Services;
public interface IContentBasedEngine
{
Task<double> CalculateSimilarityScore(User user, Movie movie);
Task<List<RecommendationResult>> GetContentBasedRecommendations(int userId, int limit = 10);
}
MovieRecommendation.Infrastructure/Services/ContentBasedEngine.cs
using MovieRecommendation.Core.Entities;
using MovieRecommendation.Core.Models;
using MovieRecommendation.Core.Repositories;
using MovieRecommendation.Core.Services;
using Microsoft.Extensions.Logging;
namespace MovieRecommendation.Infrastructure.Services;
public class ContentBasedEngine : IContentBasedEngine
{
private readonly IMovieRepository _movieRepository;
private readonly IUserRepository _userRepository;
private readonly IInteractionRepository _interactionRepository;
private readonly ILogger<ContentBasedEngine> _logger;
public ContentBasedEngine(
IMovieRepository movieRepository,
IUserRepository userRepository,
IInteractionRepository interactionRepository,
ILogger<ContentBasedEngine> logger)
{
_movieRepository = movieRepository;
_userRepository = userRepository;
_interactionRepository = interactionRepository;
_logger = logger;
}
public async Task<double> CalculateSimilarityScore(User user, Movie movie)
{
var weights = new RecommendationWeights();
double totalScore = 0.0;
// Genre similarity (35%)
if (user.FavoriteGenres.Any())
{
var genreScore = CalculateGenreScore(user.FavoriteGenres, movie.Genres);
totalScore += genreScore * weights.GenreWeight;
}
// Actor similarity (25%)
if (user.FavoriteActors.Any())
{
var actorScore = CalculateActorScore(user.FavoriteActors, movie.Actors);
totalScore += actorScore * weights.ActorWeight;
}
// Year preference (20%)
var yearScore = CalculateYearScore(
user.PreferredMinYear, user.PreferredMaxYear, movie.ReleaseYear);
totalScore += yearScore * weights.YearWeight;
// Duration preference (20%)
var durationScore = CalculateDurationScore(
user.PreferredMinDuration, user.PreferredMaxDuration, movie.DurationMinutes);
totalScore += durationScore * weights.DurationWeight;
return Math.Max(0.0, Math.Min(1.0, totalScore));
}
private double CalculateGenreScore(List<string> userGenres, List<string> movieGenres)
{
if (!movieGenres.Any()) return 0.3;
// Jaccard similarity: intersection / union
var intersection = userGenres.Intersect(movieGenres, StringComparer.OrdinalIgnoreCase).Count();
var union = userGenres.Union(movieGenres, StringComparer.OrdinalIgnoreCase).Count();
if (union == 0) return 0.3;
double jaccardScore = (double)intersection / union;
// Bonus if all user preferences are met
var allPreferencesMet = userGenres.All(g =>
movieGenres.Contains(g, StringComparer.OrdinalIgnoreCase));
if (allPreferencesMet && intersection > 0)
jaccardScore = Math.Min(1.0, jaccardScore + 0.2);
return jaccardScore;
}
private double CalculateActorScore(List<string> userActors, List<string> movieActors)
{
if (!movieActors.Any()) return 0.5;
var matchCount = userActors.Count(ua =>
movieActors.Contains(ua, StringComparer.OrdinalIgnoreCase));
if (matchCount == 0) return 0.3;
return Math.Min(1.0, (double)matchCount / userActors.Count + 0.2);
}
private double CalculateYearScore(int minYear, int maxYear, int movieYear)
{
if (minYear == 0 && maxYear == 0) return 0.5;
if (movieYear >= minYear && movieYear <= maxYear)
return 1.0;
// Calculate distance from range with tolerance
int distance;
if (movieYear < minYear)
distance = minYear - movieYear;
else
distance = movieYear - maxYear;
var tolerance = 10; // 10 years tolerance
return Math.Max(0.0, 1.0 - (double)distance / tolerance);
}
private double CalculateDurationScore(int minDuration, int maxDuration, int movieDuration)
{
if (minDuration == 0 && maxDuration == 0) return 0.5;
if (movieDuration >= minDuration && movieDuration <= maxDuration)
return 1.0;
// Within 20% tolerance
var midPoint = (minDuration + maxDuration) / 2.0;
var difference = Math.Abs(movieDuration - midPoint);
var tolerance = midPoint * 0.2;
return Math.Max(0.0, 1.0 - difference / tolerance);
}
public async Task<List<RecommendationResult>> GetContentBasedRecommendations(
int userId, int limit = 10)
{
var user = await _userRepository.GetByIdAsync(userId);
if (user == null)
{
_logger.LogWarning("User {UserId} not found", userId);
return new List<RecommendationResult>();
}
// Get movies user hasn't interacted with
var userInteractions = await _interactionRepository.GetUserInteractionsAsync(userId);
var watchedMovieIds = userInteractions.Select(i => i.MovieId).ToHashSet();
// Get candidate movies
var candidateMovies = await _movieRepository.GetAvailableMoviesAsync(limit * 5);
candidateMovies = candidateMovies
.Where(m => !watchedMovieIds.Contains(m.Id))
.ToList();
var recommendations = new List<RecommendationResult>();
foreach (var movie in candidateMovies)
{
var similarityScore = await CalculateSimilarityScore(user, movie);
recommendations.Add(new RecommendationResult
{
MovieId = movie.Id,
Title = movie.Title,
ContentScore = similarityScore,
DetailedScores = new Dictionary<string, double>
{
["Genre"] = CalculateGenreScore(user.FavoriteGenres, movie.Genres),
["Actor"] = CalculateActorScore(user.FavoriteActors, movie.Actors),
["Year"] = CalculateYearScore(user.PreferredMinYear, user.PreferredMaxYear, movie.ReleaseYear),
["Duration"] = CalculateDurationScore(user.PreferredMinDuration, user.PreferredMaxDuration, movie.DurationMinutes),
["Rating"] = movie.AverageRating / 5.0
}
});
}
return recommendations
.OrderByDescending(r => r.ContentScore)
.Take(limit)
.ToList();
}
}
7. Collaborative Filtering with ML.NET {#section-7}
MovieRecommendation.Core/Services/ICollaborativeEngine.cs
using MovieRecommendation.Core.Models;
namespace MovieRecommendation.Core.Services;
public interface ICollaborativeEngine
{
Task<bool> TrainModelAsync();
Task<double> PredictRating(int userId, int movieId);
Task<List<RecommendationResult>> GetCollaborativeRecommendations(int userId, int limit = 10);
}
MovieRecommendation.Infrastructure/Services/CollaborativeEngine.cs
using Microsoft.ML;
using Microsoft.ML.Trainers;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using MovieRecommendation.Core.Models;
using MovieRecommendation.Core.Repositories;
using MovieRecommendation.Core.Services;
namespace MovieRecommendation.Infrastructure.Services;
public class CollaborativeEngine : ICollaborativeEngine
{
private readonly IInteractionRepository _interactionRepository;
private readonly IMovieRepository _movieRepository;
private readonly IMemoryCache _cache;
private readonly ILogger<CollaborativeEngine> _logger;
private readonly MLContext _mlContext;
private const string ModelCacheKey = "collaborative_model";
private readonly TimeSpan ModelCacheExpiration = TimeSpan.FromHours(24);
public CollaborativeEngine(
IInteractionRepository interactionRepository,
IMovieRepository movieRepository,
IMemoryCache cache,
ILogger<CollaborativeEngine> logger)
{
_interactionRepository = interactionRepository;
_movieRepository = movieRepository;
_cache = cache;
_logger = logger;
_mlContext = new MLContext(seed: 0);
}
public async Task<bool> TrainModelAsync()
{
try
{
_logger.LogInformation("Starting collaborative filtering model training...");
// Get training data
var interactions = await _interactionRepository.GetAllRatingsAsync();
if (interactions.Count < 100)
{
_logger.LogWarning(
"Not enough data to train model. Need at least 100 ratings, got {Count}",
interactions.Count);
return false;
}
// Convert to ML.NET format
var trainingData = interactions.Select(i => new MovieRating
{
UserId = i.UserId,
MovieId = i.MovieId,
Rating = (float)(i.Rating ?? 3.0)
}).ToList();
var dataView = _mlContext.Data.LoadFromEnumerable(trainingData);
// Split data for validation
var dataSplit = _mlContext.Data.TrainTestSplit(dataView, testFraction: 0.2);
// Configure matrix factorization
var options = new MatrixFactorizationTrainer.Options
{
MatrixColumnIndexColumnName = nameof(MovieRating.UserId),
MatrixRowIndexColumnName = nameof(MovieRating.MovieId),
LabelColumnName = nameof(MovieRating.Rating),
NumberOfIterations = 20,
ApproximationRank = 100,
LearningRate = 0.001,
Quiet = false
};
// Train the model
_logger.LogInformation("Training matrix factorization model...");
var trainer = _mlContext.Recommendation().Trainers.MatrixFactorization(options);
var model = trainer.Fit(dataSplit.TrainSet);
// Evaluate
var predictions = model.Transform(dataSplit.TestSet);
var metrics = _mlContext.Regression.Evaluate(
predictions,
labelColumnName: nameof(MovieRating.Rating));
_logger.LogInformation(
"Model trained. RMSE: {RMSE:F3}, RΒ²: {RSquared:F3}",
metrics.RootMeanSquaredError,
metrics.RSquared);
// Cache the model
var cacheOptions = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = ModelCacheExpiration,
Size = 100
};
_cache.Set(ModelCacheKey, model, cacheOptions);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error training collaborative filtering model");
return false;
}
}
public async Task<double> PredictRating(int userId, int movieId)
{
try
{
if (!_cache.TryGetValue(ModelCacheKey, out ITransformer? model) || model == null)
{
_logger.LogDebug("Model not in cache, returning neutral score");
return 2.5;
}
var predictionEngine = _mlContext.Model
.CreatePredictionEngine<MovieRating, MovieRatingPrediction>(model);
var input = new MovieRating
{
UserId = userId,
MovieId = movieId
};
var prediction = predictionEngine.Predict(input);
// Clamp to 0-5 range
return Math.Max(0, Math.Min(5, prediction.Score));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error predicting rating for user {UserId}, movie {MovieId}",
userId, movieId);
return 2.5;
}
}
public async Task<List<RecommendationResult>> GetCollaborativeRecommendations(
int userId, int limit = 10)
{
try
{
var userInteractions = await _interactionRepository.GetUserInteractionsAsync(userId);
var watchedMovieIds = userInteractions.Select(i => i.MovieId).ToHashSet();
var candidateMovies = await _movieRepository.GetAvailableMoviesAsync(limit * 5);
candidateMovies = candidateMovies
.Where(m => !watchedMovieIds.Contains(m.Id))
.ToList();
var recommendations = new List<RecommendationResult>();
foreach (var movie in candidateMovies)
{
var predictedRating = await PredictRating(userId, movie.Id);
var normalizedScore = predictedRating / 5.0;
recommendations.Add(new RecommendationResult
{
MovieId = movie.Id,
Title = movie.Title,
CollaborativeScore = normalizedScore
});
}
return recommendations
.OrderByDescending(r => r.CollaborativeScore)
.Take(limit)
.ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting collaborative recommendations for user {UserId}", userId);
return new List<RecommendationResult>();
}
}
}
8. Hybrid Recommendation Engine {#section-8}
MovieRecommendation.Core/Services/IRecommendationEngine.cs
using MovieRecommendation.Core.Entities;
using MovieRecommendation.Core.Models;
namespace MovieRecommendation.Core.Services;
public interface IRecommendationEngine
{
Task<List<RecommendationResult>> GetRecommendations(
int userId,
int limit = 10,
RecommendationWeights? weights = null);
Task TrackInteraction(int userId, int movieId, InteractionType type, double? rating = null);
}
MovieRecommendation.Infrastructure/Services/RecommendationEngine.cs
using MovieRecommendation.Core.Entities;
using MovieRecommendation.Core.Models;
using MovieRecommendation.Core.Repositories;
using MovieRecommendation.Core.Services;
using Microsoft.Extensions.Logging;
namespace MovieRecommendation.Infrastructure.Services;
public class RecommendationEngine : IRecommendationEngine
{
private readonly IContentBasedEngine _contentEngine;
private readonly ICollaborativeEngine _collaborativeEngine;
private readonly IMovieRepository _movieRepository;
private readonly IInteractionRepository _interactionRepository;
private readonly ILogger<RecommendationEngine> _logger;
public RecommendationEngine(
IContentBasedEngine contentEngine,
ICollaborativeEngine collaborativeEngine,
IMovieRepository movieRepository,
IInteractionRepository interactionRepository,
ILogger<RecommendationEngine> logger)
{
_contentEngine = contentEngine;
_collaborativeEngine = collaborativeEngine;
_movieRepository = movieRepository;
_interactionRepository = interactionRepository;
_logger = logger;
}
public async Task<List<RecommendationResult>> GetRecommendations(
int userId,
int limit = 10,
RecommendationWeights? weights = null)
{
weights ??= new RecommendationWeights();
try
{
_logger.LogInformation("Generating recommendations for user {UserId} with limit {Limit}",
userId, limit);
// Get recommendations from both engines in parallel
var contentTask = _contentEngine.GetContentBasedRecommendations(userId, limit * 3);
var collaborativeTask = _collaborativeEngine.GetCollaborativeRecommendations(userId, limit * 3);
await Task.WhenAll(contentTask, collaborativeTask);
var contentRecommendations = contentTask.Result;
var collaborativeRecommendations = collaborativeTask.Result;
// Merge recommendations
var mergedRecommendations = MergeRecommendations(
contentRecommendations,
collaborativeRecommendations);
// Calculate overall scores
foreach (var recommendation in mergedRecommendations)
{
var movie = await _movieRepository.GetByIdAsync(recommendation.MovieId);
if (movie == null) continue;
// Calculate popularity score
var popularityScore = CalculatePopularityScore(movie);
recommendation.PopularityScore = popularityScore;
// Calculate recency score
var recencyScore = CalculateRecencyScore(movie);
// Calculate overall weighted score
recommendation.OverallScore =
(recommendation.ContentScore * weights.ContentWeight) +
(recommendation.CollaborativeScore * weights.CollaborativeWeight) +
(popularityScore * weights.PopularityWeight) +
(recencyScore * weights.RecencyWeight);
// Generate human-readable explanation
recommendation.ReasonForRecommendation =
GenerateRecommendationReason(recommendation);
}
var finalRecommendations = mergedRecommendations
.OrderByDescending(r => r.OverallScore)
.Take(limit)
.ToList();
_logger.LogInformation(
"Generated {Count} recommendations for user {UserId}",
finalRecommendations.Count, userId);
return finalRecommendations;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating recommendations for user {UserId}", userId);
return new List<RecommendationResult>();
}
}
private List<RecommendationResult> MergeRecommendations(
List<RecommendationResult> contentRecs,
List<RecommendationResult> collaborativeRecs)
{
var merged = new Dictionary<int, RecommendationResult>();
// Add content-based recommendations
foreach (var rec in contentRecs)
{
merged[rec.MovieId] = rec;
}
// Merge collaborative recommendations
foreach (var rec in collaborativeRecs)
{
if (merged.ContainsKey(rec.MovieId))
{
// Update existing recommendation with collaborative score
merged[rec.MovieId].CollaborativeScore = rec.CollaborativeScore;
}
else
{
// Add new recommendation
merged[rec.MovieId] = rec;
}
}
return merged.Values.ToList();
}
private double CalculatePopularityScore(Core.Entities.Movie movie)
{
// Combine rating quality and number of ratings
if (movie.TotalRatings == 0)
return 0.3; // New movies get neutral score
// Normalize rating (0-5 scale to 0-1)
var ratingScore = movie.AverageRating / 5.0;
// Normalize popularity (use logarithmic scale for diminishing returns)
var popularityScore = Math.Min(1.0, Math.Log10(movie.TotalRatings + 1) / 5.0);
// Weighted combination: 70% rating, 30% popularity
return (ratingScore * 0.7) + (popularityScore * 0.3);
}
private double CalculateRecencyScore(Core.Entities.Movie movie)
{
var daysSinceCreation = (DateTime.UtcNow - movie.CreatedAt).Days;
// Recent movies get higher score
if (daysSinceCreation <= 30) return 1.0; // New (last month)
if (daysSinceCreation <= 90) return 0.8; // Recent (last 3 months)
if (daysSinceCreation <= 180) return 0.6; // Semi-recent (last 6 months)
if (daysSinceCreation <= 365) return 0.4; // This year
return 0.2; // Older
}
private string GenerateRecommendationReason(RecommendationResult recommendation)
{
var reasons = new List<string>();
// Check content-based factors
if (recommendation.ContentScore > 0.7)
reasons.Add("matches your preferences");
// Check collaborative filtering
if (recommendation.CollaborativeScore > 0.7)
reasons.Add("loved by users like you");
// Check popularity
if (recommendation.PopularityScore > 0.7)
reasons.Add("highly rated by many viewers");
// Check detailed scores
if (recommendation.DetailedScores.TryGetValue("Genre", out var genreScore)
&& genreScore > 0.8)
reasons.Add("in your favorite genre");
if (recommendation.DetailedScores.TryGetValue("Actor", out var actorScore)
&& actorScore > 0.8)
reasons.Add("features your favorite actors");
// Generate natural language explanation
if (!reasons.Any())
return "Recommended for you based on our analysis";
if (reasons.Count == 1)
return $"Recommended because it {reasons[0]}";
if (reasons.Count == 2)
return $"Recommended because it {reasons[0]} and {reasons[1]}";
var lastReason = reasons.Last();
reasons.RemoveAt(reasons.Count - 1);
return $"Recommended because it {string.Join(", ", reasons)}, and {lastReason}";
}
public async Task TrackInteraction(
int userId,
int movieId,
InteractionType type,
double? rating = null)
{
try
{
var interaction = new UserInteraction
{
UserId = userId,
MovieId = movieId,
Type = type,
Rating = rating,
Timestamp = DateTime.UtcNow
};
await _interactionRepository.AddAsync(interaction);
_logger.LogInformation(
"Tracked interaction: User {UserId}, Movie {MovieId}, Type {Type}, Rating {Rating}",
userId, movieId, type, rating);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error tracking interaction");
throw;
}
}
}
9. API Controllers & Endpoints {#section-9}
MovieRecommendation.API/Controllers/RecommendationsController.cs
using Microsoft.AspNetCore.Mvc;
using MovieRecommendation.Core.Entities;
using MovieRecommendation.Core.Models;
using MovieRecommendation.Core.Services;
namespace MovieRecommendation.API.Controllers;
[ApiController]
[Route("api/[controller]")]
public class RecommendationsController : ControllerBase
{
private readonly IRecommendationEngine _recommendationEngine;
private readonly ICollaborativeEngine _collaborativeEngine;
private readonly ILogger<RecommendationsController> _logger;
public RecommendationsController(
IRecommendationEngine recommendationEngine,
ICollaborativeEngine collaborativeEngine,
ILogger<RecommendationsController> logger)
{
_recommendationEngine = recommendationEngine;
_collaborativeEngine = collaborativeEngine;
_logger = logger;
}
/// <summary>
/// Get personalized movie recommendations for a user
/// </summary>
/// <param name="userId">User ID</param>
/// <param name="limit">Number of recommendations (default: 10, max: 50)</param>
/// <returns>List of recommended movies with scores</returns>
[HttpGet("user/{userId}")]
[ProducesResponseType(typeof(List<RecommendationResult>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<ActionResult<List<RecommendationResult>>> GetRecommendations(
int userId,
[FromQuery] int limit = 10)
{
if (userId <= 0)
return BadRequest(new { error = "Invalid user ID" });
if (limit <= 0 || limit > 50)
return BadRequest(new { error = "Limit must be between 1 and 50" });
try
{
var recommendations = await _recommendationEngine.GetRecommendations(userId, limit);
return Ok(recommendations);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting recommendations for user {UserId}", userId);
return StatusCode(500, new { error = "An error occurred while getting recommendations" });
}
}
/// <summary>
/// Track user interaction with a movie
/// </summary>
/// <param name="request">Interaction details</param>
[HttpPost("track")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> TrackInteraction([FromBody] TrackInteractionRequest request)
{
if (request.UserId <= 0 || request.MovieId <= 0)
return BadRequest(new { error = "Invalid user or movie ID" });
if (request.Type == InteractionType.Rating &&
(request.Rating < 1 || request.Rating > 5))
return BadRequest(new { error = "Rating must be between 1 and 5" });
try
{
await _recommendationEngine.TrackInteraction(
request.UserId,
request.MovieId,
request.Type,
request.Rating);
return Ok(new { message = "Interaction tracked successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error tracking interaction");
return StatusCode(500, new { error = "An error occurred while tracking interaction" });
}
}
/// <summary>
/// Train the collaborative filtering ML model
/// </summary>
[HttpPost("train")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> TrainModel()
{
try
{
_logger.LogInformation("Manual model training triggered");
var success = await _collaborativeEngine.TrainModelAsync();
if (success)
return Ok(new { message = "Model trained successfully" });
else
return BadRequest(new { error = "Model training failed - not enough data" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error training model");
return StatusCode(500, new { error = "An error occurred while training model" });
}
}
}
public record TrackInteractionRequest(
int UserId,
int MovieId,
InteractionType Type,
double? Rating = null);
MovieRecommendation.API/Controllers/MoviesController.cs
using Microsoft.AspNetCore.Mvc;
using MovieRecommendation.Core.Entities;
using MovieRecommendation.Core.Repositories;
namespace MovieRecommendation.API.Controllers;
[ApiController]
[Route("api/[controller]")]
public class MoviesController : ControllerBase
{
private readonly IMovieRepository _movieRepository;
private readonly ILogger<MoviesController> _logger;
public MoviesController(IMovieRepository movieRepository, ILogger<MoviesController> logger)
{
_movieRepository = movieRepository;
_logger = logger;
}
/// <summary>
/// Get all movies with pagination
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(List<Movie>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<Movie>>> GetMovies([FromQuery] int limit = 20)
{
if (limit <= 0 || limit > 100)
return BadRequest(new { error = "Limit must be between 1 and 100" });
var movies = await _movieRepository.GetAvailableMoviesAsync(limit);
return Ok(movies);
}
/// <summary>
/// Get a specific movie by ID
/// </summary>
[HttpGet("{id}")]
[ProducesResponseType(typeof(Movie), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Movie>> GetMovie(int id)
{
var movie = await _movieRepository.GetByIdAsync(id);
if (movie == null)
return NotFound(new { error = $"Movie with ID {id} not found" });
return Ok(movie);
}
/// <summary>
/// Get movies by genre
/// </summary>
[HttpGet("genre/{genre}")]
[ProducesResponseType(typeof(List<Movie>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<Movie>>> GetMoviesByGenre(string genre, [FromQuery] int limit = 20)
{
var movies = await _movieRepository.GetMoviesByGenreAsync(genre, limit);
return Ok(movies);
}
/// <summary>
/// Create a new movie
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(Movie), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<Movie>> CreateMovie([FromBody] CreateMovieRequest request)
{
var movie = new Movie
{
Title = request.Title,
Description = request.Description,
Genres = request.Genres,
Actors = request.Actors,
Director = request.Director,
ReleaseYear = request.ReleaseYear,
DurationMinutes = request.DurationMinutes,
AverageRating = 0,
TotalRatings = 0
};
var created = await _movieRepository.AddAsync(movie);
return CreatedAtAction(nameof(GetMovie), new { id = created.Id }, created);
}
}
public record CreateMovieRequest(
string Title,
string Description,
List<string> Genres,
List<string> Actors,
string Director,
int ReleaseYear,
int DurationMinutes);
MovieRecommendation.API/Controllers/UsersController.cs
using Microsoft.AspNetCore.Mvc;
using MovieRecommendation.Core.Entities;
using MovieRecommendation.Core.Repositories;
namespace MovieRecommendation.API.Controllers;
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly IUserRepository _userRepository;
private readonly ILogger<UsersController> _logger;
public UsersController(IUserRepository userRepository, ILogger<UsersController> logger)
{
_userRepository = userRepository;
_logger = logger;
}
/// <summary>
/// Get user by ID
/// </summary>
[HttpGet("{id}")]
[ProducesResponseType(typeof(User), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<User>> GetUser(int id)
{
var user = await _userRepository.GetByIdAsync(id);
if (user == null)
return NotFound(new { error = $"User with ID {id} not found" });
return Ok(user);
}
/// <summary>
/// Create a new user
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(User), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<User>> CreateUser([FromBody] CreateUserRequest request)
{
// Check if username exists
var existingUser = await _userRepository.GetByUsernameAsync(request.Username);
if (existingUser != null)
return BadRequest(new { error = "Username already exists" });
var user = new User
{
Username = request.Username,
Email = request.Email,
FavoriteGenres = request.FavoriteGenres ?? new List<string>(),
FavoriteActors = request.FavoriteActors ?? new List<string>(),
PreferredMinYear = request.PreferredMinYear,
PreferredMaxYear = request.PreferredMaxYear,
PreferredMinDuration = request.PreferredMinDuration,
PreferredMaxDuration = request.PreferredMaxDuration
};
var created = await _userRepository.AddAsync(user);
return CreatedAtAction(nameof(GetUser), new { id = created.Id }, created);
}
/// <summary>
/// Update user preferences
/// </summary>
[HttpPut("{id}/preferences")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdatePreferences(
int id,
[FromBody] UpdatePreferencesRequest request)
{
var user = await _userRepository.GetByIdAsync(id);
if (user == null)
return NotFound(new { error = $"User with ID {id} not found" });
user.FavoriteGenres = request.FavoriteGenres;
user.FavoriteActors = request.FavoriteActors;
user.PreferredMinYear = request.PreferredMinYear;
user.PreferredMaxYear = request.PreferredMaxYear;
user.PreferredMinDuration = request.PreferredMinDuration;
user.PreferredMaxDuration = request.PreferredMaxDuration;
await _userRepository.UpdateAsync(user);
return NoContent();
}
}
public record CreateUserRequest(
string Username,
string Email,
List<string>? FavoriteGenres,
List<string>? FavoriteActors,
int PreferredMinYear,
int PreferredMaxYear,
int PreferredMinDuration,
int PreferredMaxDuration);
public record UpdatePreferencesRequest(
List<string> FavoriteGenres,
List<string> FavoriteActors,
int PreferredMinYear,
int PreferredMaxYear,
int PreferredMinDuration,
int PreferredMaxDuration);
10. Configuration & Dependency Injection {#section-10}
MovieRecommendation.API/appsettings.json
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=MovieRecommendation;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=true"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"MovieRecommendation": "Debug"
}
},
"AllowedHosts": "*",
"RecommendationSettings": {
"DefaultLimit": 10,
"MaxLimit": 50,
"CacheExpirationHours": 24,
"MinimumInteractionsForTraining": 100,
"TrainingIntervalHours": 24
},
"Cors": {
"AllowedOrigins": [
"http://localhost:3000",
"http://localhost:4200",
"http://localhost:5173"
]
}
}
MovieRecommendation.API/appsettings.Development.json
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=MovieRecommendationDev;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=true"
},
"Logging": {
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information",
"Microsoft.EntityFrameworkCore": "Information"
}
}
}
MovieRecommendation.API/Program.cs (COMPLETE)
using Microsoft.EntityFrameworkCore;
using MovieRecommendation.Core.Repositories;
using MovieRecommendation.Core.Services;
using MovieRecommendation.Infrastructure.Data;
using MovieRecommendation.Infrastructure.Repositories;
using MovieRecommendation.Infrastructure.Services;
using MovieRecommendation.API.Middleware;
var builder = WebApplication.CreateBuilder(args);
// ===== Add Services =====
// Controllers
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
// Swagger/OpenAPI
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo
{
Title = "Movie Recommendation API",
Version = "v1",
Description = "AI-powered movie recommendation system using ML.NET and collaborative filtering",
Contact = new Microsoft.OpenApi.Models.OpenApiContact
{
Name = "Your Name",
Email = "your.email@example.com"
}
});
// Include XML comments if available
var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
if (File.Exists(xmlPath))
{
options.IncludeXmlComments(xmlPath);
}
});
// Database Context
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(
builder.Configuration.GetConnectionString("DefaultConnection"),
sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: null);
sqlOptions.CommandTimeout(60);
});
});
// Memory Cache
builder.Services.AddMemoryCache(options =>
{
options.SizeLimit = 1024; // MB
options.CompactionPercentage = 0.75;
});
// Logging
builder.Services.AddLogging(logging =>
{
logging.ClearProviders();
logging.AddConsole();
logging.AddDebug();
if (builder.Environment.IsDevelopment())
{
logging.SetMinimumLevel(LogLevel.Debug);
}
});
// Register Repositories
builder.Services.AddScoped<IMovieRepository, MovieRepository>();
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IInteractionRepository, InteractionRepository>();
// Register Recommendation Services
builder.Services.AddScoped<IContentBasedEngine, ContentBasedEngine>();
builder.Services.AddScoped<ICollaborativeEngine, CollaborativeEngine>();
builder.Services.AddScoped<IRecommendationEngine, RecommendationEngine>();
// Background Services
builder.Services.AddHostedService<ModelTrainingBackgroundService>();
// CORS
var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>()
?? new[] { "http://localhost:3000" };
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.WithOrigins(allowedOrigins)
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
});
});
// Health Checks
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>("database");
// ===== Build App =====
var app = builder.Build();
// ===== Configure Middleware Pipeline =====
// Global Exception Handling
app.UseMiddleware<GlobalExceptionMiddleware>();
// Development-specific middleware
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/swagger/v1/swagger.json", "Movie Recommendation API v1");
options.RoutePrefix = string.Empty; // Swagger at root
});
app.UseDeveloperExceptionPage();
}
else
{
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseCors();
app.UseAuthorization();
// Health check endpoint
app.MapHealthChecks("/health");
// Map controllers
app.MapControllers();
// ===== Database Initialization =====
using (var scope = app.Services.CreateScope())
{
var services = scope.ServiceProvider;
try
{
var context = services.GetRequiredService<AppDbContext>();
var logger = services.GetRequiredService<ILogger<Program>>();
logger.LogInformation("Applying database migrations...");
await context.Database.MigrateAsync();
if (app.Environment.IsDevelopment())
{
logger.LogInformation("Seeding database...");
await DatabaseSeeder.SeedAsync(context, logger);
}
logger.LogInformation("Database initialization completed");
}
catch (Exception ex)
{
var logger = services.GetRequiredService<ILogger<Program>>();
logger.LogError(ex, "An error occurred while initializing the database");
throw;
}
}
// ===== Run Application =====
app.Run();
11. Database Seeding & Test Data {#section-11}
MovieRecommendation.Infrastructure/Data/DatabaseSeeder.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using MovieRecommendation.Core.Entities;
namespace MovieRecommendation.Infrastructure.Data;
public static class DatabaseSeeder
{
public static async Task SeedAsync(AppDbContext context, ILogger logger)
{
try
{
// Check if already seeded
if (await context.Movies.AnyAsync())
{
logger.LogInformation("Database already seeded");
return;
}
logger.LogInformation("Seeding movies...");
await SeedMoviesAsync(context);
logger.LogInformation("Seeding users...");
await SeedUsersAsync(context);
logger.LogInformation("Seeding interactions...");
await SeedInteractionsAsync(context);
logger.LogInformation("Database seeding completed successfully");
}
catch (Exception ex)
{
logger.LogError(ex, "Error seeding database");
throw;
}
}
private static async Task SeedMoviesAsync(AppDbContext context)
{
var movies = new List<Movie>
{
new Movie
{
Title = "The Shawshank Redemption",
Description = "Two imprisoned men bond over a number of years, finding solace and eventual redemption through acts of common decency.",
Genres = new List<string> { "Drama" },
Actors = new List<string> { "Tim Robbins", "Morgan Freeman", "Bob Gunton" },
Director = "Frank Darabont",
ReleaseYear = 1994,
DurationMinutes = 142,
AverageRating = 4.9,
TotalRatings = 2500000,
CreatedAt = DateTime.UtcNow.AddDays(-365)
},
new Movie
{
Title = "The Dark Knight",
Description = "When the menace known as the Joker wreaks havoc and chaos on the people of Gotham, Batman must accept one of the greatest psychological tests.",
Genres = new List<string> { "Action", "Crime", "Drama" },
Actors = new List<string> { "Christian Bale", "Heath Ledger", "Aaron Eckhart" },
Director = "Christopher Nolan",
ReleaseYear = 2008,
DurationMinutes = 152,
AverageRating = 4.8,
TotalRatings = 2300000,
CreatedAt = DateTime.UtcNow.AddDays(-300)
},
new Movie
{
Title = "Inception",
Description = "A thief who steals corporate secrets through the use of dream-sharing technology is given the inverse task of planting an idea.",
Genres = new List<string> { "Sci-Fi", "Action", "Thriller" },
Actors = new List<string> { "Leonardo DiCaprio", "Tom Hardy", "Ellen Page" },
Director = "Christopher Nolan",
ReleaseYear = 2010,
DurationMinutes = 148,
AverageRating = 4.7,
TotalRatings = 2100000,
CreatedAt = DateTime.UtcNow.AddDays(-250)
},
new Movie
{
Title = "The Godfather",
Description = "The aging patriarch of an organized crime dynasty transfers control of his clandestine empire to his reluctant son.",
Genres = new List<string> { "Crime", "Drama" },
Actors = new List<string> { "Marlon Brando", "Al Pacino", "James Caan" },
Director = "Francis Ford Coppola",
ReleaseYear = 1972,
DurationMinutes = 175,
AverageRating = 4.9,
TotalRatings = 1800000,
CreatedAt = DateTime.UtcNow.AddDays(-400)
},
new Movie
{
Title = "Pulp Fiction",
Description = "The lives of two mob hitmen, a boxer, a gangster and his wife intertwine in four tales of violence and redemption.",
Genres = new List<string> { "Crime", "Drama" },
Actors = new List<string> { "John Travolta", "Uma Thurman", "Samuel L. Jackson" },
Director = "Quentin Tarantino",
ReleaseYear = 1994,
DurationMinutes = 154,
AverageRating = 4.7,
TotalRatings = 1900000,
CreatedAt = DateTime.UtcNow.AddDays(-350)
},
new Movie
{
Title = "Forrest Gump",
Description = "The presidencies of Kennedy and Johnson unfold through the perspective of an Alabama man with an IQ of 75.",
Genres = new List<string> { "Drama", "Romance" },
Actors = new List<string> { "Tom Hanks", "Robin Wright", "Gary Sinise" },
Director = "Robert Zemeckis",
ReleaseYear = 1994,
DurationMinutes = 142,
AverageRating = 4.6,
TotalRatings = 1950000,
CreatedAt = DateTime.UtcNow.AddDays(-330)
},
new Movie
{
Title = "The Matrix",
Description = "A computer hacker learns from mysterious rebels about the true nature of his reality and his role in the war against its controllers.",
Genres = new List<string> { "Sci-Fi", "Action" },
Actors = new List<string> { "Keanu Reeves", "Laurence Fishburne", "Carrie-Anne Moss" },
Director = "Lana Wachowski",
ReleaseYear = 1999,
DurationMinutes = 136,
AverageRating = 4.6,
TotalRatings = 1850000,
CreatedAt = DateTime.UtcNow.AddDays(-280)
},
new Movie
{
Title = "Interstellar",
Description = "A team of explorers travel through a wormhole in space in an attempt to ensure humanity's survival.",
Genres = new List<string> { "Sci-Fi", "Drama", "Adventure" },
Actors = new List<string> { "Matthew McConaughey", "Anne Hathaway", "Jessica Chastain" },
Director = "Christopher Nolan",
ReleaseYear = 2014,
DurationMinutes = 169,
AverageRating = 4.6,
TotalRatings = 1700000,
CreatedAt = DateTime.UtcNow.AddDays(-200)
},
new Movie
{
Title = "The Silence of the Lambs",
Description = "A young FBI cadet must receive the help of an incarcerated cannibal killer to catch another serial killer.",
Genres = new List<string> { "Thriller", "Crime", "Drama" },
Actors = new List<string> { "Jodie Foster", "Anthony Hopkins", "Lawrence A. Bonney" },
Director = "Jonathan Demme",
ReleaseYear = 1991,
DurationMinutes = 118,
AverageRating = 4.6,
TotalRatings = 1400000,
CreatedAt = DateTime.UtcNow.AddDays(-380)
},
new Movie
{
Title = "The Green Mile",
Description = "The lives of guards on Death Row are affected by one of their charges: a black man accused of child murder.",
Genres = new List<string> { "Drama", "Fantasy" },
Actors = new List<string> { "Tom Hanks", "Michael Clarke Duncan", "David Morse" },
Director = "Frank Darabont",
ReleaseYear = 1999,
DurationMinutes = 189,
AverageRating = 4.6,
TotalRatings = 1300000,
CreatedAt = DateTime.UtcNow.AddDays(-290)
},
// Add 10 more varied movies
new Movie
{
Title = "The Avengers",
Description = "Earth's mightiest heroes must come together to stop Loki and his alien army.",
Genres = new List<string> { "Action", "Adventure", "Sci-Fi" },
Actors = new List<string> { "Robert Downey Jr.", "Chris Evans", "Scarlett Johansson" },
Director = "Joss Whedon",
ReleaseYear = 2012,
DurationMinutes = 143,
AverageRating = 4.3,
TotalRatings = 1600000,
CreatedAt = DateTime.UtcNow.AddDays(-220)
},
new Movie
{
Title = "Titanic",
Description = "A seventeen-year-old aristocrat falls in love with a kind but poor artist aboard the luxurious, ill-fated R.M.S. Titanic.",
Genres = new List<string> { "Romance", "Drama" },
Actors = new List<string> { "Leonardo DiCaprio", "Kate Winslet", "Billy Zane" },
Director = "James Cameron",
ReleaseYear = 1997,
DurationMinutes = 194,
AverageRating = 4.4,
TotalRatings = 1100000,
CreatedAt = DateTime.UtcNow.AddDays(-310)
},
new Movie
{
Title = "Gladiator",
Description = "A former Roman General sets out to exact vengeance against the corrupt emperor who murdered his family.",
Genres = new List<string> { "Action", "Drama", "Adventure" },
Actors = new List<string> { "Russell Crowe", "Joaquin Phoenix", "Connie Nielsen" },
Director = "Ridley Scott",
ReleaseYear = 2000,
DurationMinutes = 155,
AverageRating = 4.5,
TotalRatings = 1400000,
CreatedAt = DateTime.UtcNow.AddDays(-270)
},
new Movie
{
Title = "The Departed",
Description = "An undercover cop and a mole in the police attempt to identify each other while infiltrating an Irish gang in Boston.",
Genres = new List<string> { "Crime", "Drama", "Thriller" },
Actors = new List<string> { "Leonardo DiCaprio", "Matt Damon", "Jack Nicholson" },
Director = "Martin Scorsese",
ReleaseYear = 2006,
DurationMinutes = 151,
AverageRating = 4.5,
TotalRatings = 1300000,
CreatedAt = DateTime.UtcNow.AddDays(-260)
},
new Movie
{
Title = "The Prestige",
Description = "After a tragic accident, two stage magicians engage in a battle to create the ultimate illusion.",
Genres = new List<string> { "Drama", "Mystery", "Thriller" },
Actors = new List<string> { "Christian Bale", "Hugh Jackman", "Scarlett Johansson" },
Director = "Christopher Nolan",
ReleaseYear = 2006,
DurationMinutes = 130,
AverageRating = 4.5,
TotalRatings = 1200000,
CreatedAt = DateTime.UtcNow.AddDays(-255)
},
new Movie
{
Title = "Django Unchained",
Description = "With the help of a German bounty hunter, a freed slave sets out to rescue his wife from a brutal Mississippi plantation owner.",
Genres = new List<string> { "Western", "Drama" },
Actors = new List<string> { "Jamie Foxx", "Christoph Waltz", "Leonardo DiCaprio" },
Director = "Quentin Tarantino",
ReleaseYear = 2012,
DurationMinutes = 165,
AverageRating = 4.5,
TotalRatings = 1400000,
CreatedAt = DateTime.UtcNow.AddDays(-215)
},
new Movie
{
Title = "The Grand Budapest Hotel",
Description = "A writer encounters the owner of an aging high-class hotel, who tells him of his early years.",
Genres = new List<string> { "Comedy", "Drama", "Adventure" },
Actors = new List<string> { "Ralph Fiennes", "F. Murray Abraham", "Mathieu Amalric" },
Director = "Wes Anderson",
ReleaseYear = 2014,
DurationMinutes = 99,
AverageRating = 4.4,
TotalRatings = 850000,
CreatedAt = DateTime.UtcNow.AddDays(-195)
},
new Movie
{
Title = "Mad Max: Fury Road",
Description = "In a post-apocalyptic wasteland, a woman rebels against a tyrannical ruler in search for her homeland.",
Genres = new List<string> { "Action", "Adventure", "Sci-Fi" },
Actors = new List<string> { "Tom Hardy", "Charlize Theron", "Nicholas Hoult" },
Director = "George Miller",
ReleaseYear = 2015,
DurationMinutes = 120,
AverageRating = 4.5,
TotalRatings = 950000,
CreatedAt = DateTime.UtcNow.AddDays(-180)
},
new Movie
{
Title = "The Social Network",
Description = "As Harvard student Mark Zuckerberg creates the social networking site Facebook.",
Genres = new List<string> { "Biography", "Drama" },
Actors = new List<string> { "Jesse Eisenberg", "Andrew Garfield", "Justin Timberlake" },
Director = "David Fincher",
ReleaseYear = 2010,
DurationMinutes = 120,
AverageRating = 4.4,
TotalRatings = 680000,
CreatedAt = DateTime.UtcNow.AddDays(-245)
},
new Movie
{
Title = "Whiplash",
Description = "A promising young drummer enrolls at a cut-throat music conservatory where his dreams hang in the balance.",
Genres = new List<string> { "Drama", "Music" },
Actors = new List<string> { "Miles Teller", "J.K. Simmons", "Melissa Benoist" },
Director = "Damien Chazelle",
ReleaseYear = 2014,
DurationMinutes = 106,
AverageRating = 4.6,
TotalRatings = 830000,
CreatedAt = DateTime.UtcNow.AddDays(-190)
}
};
await context.Movies.AddRangeAsync(movies);
await context.SaveChangesAsync();
}
private static async Task SeedUsersAsync(AppDbContext context)
{
var users = new List<User>
{
new User
{
Username = "john_doe",
Email = "john@example.com",
FavoriteGenres = new List<string> { "Action", "Sci-Fi", "Thriller" },
FavoriteActors = new List<string> { "Leonardo DiCaprio", "Christian Bale", "Tom Hardy" },
PreferredMinYear = 2000,
PreferredMaxYear = 2024,
PreferredMinDuration = 90,
PreferredMaxDuration = 180,
CreatedAt = DateTime.UtcNow.AddDays(-180)
},
new User
{
Username = "jane_smith",
Email = "jane@example.com",
FavoriteGenres = new List<string> { "Drama", "Romance" },
FavoriteActors = new List<string> { "Tom Hanks", "Morgan Freeman", "Kate Winslet" },
PreferredMinYear = 1990,
PreferredMaxYear = 2020,
PreferredMinDuration = 100,
PreferredMaxDuration = 200,
CreatedAt = DateTime.UtcNow.AddDays(-150)
},
new User
{
Username = "mike_johnson",
Email = "mike@example.com",
FavoriteGenres = new List<string> { "Crime", "Thriller", "Drama" },
FavoriteActors = new List<string> { "Al Pacino", "Robert De Niro", "Matt Damon" },
PreferredMinYear = 1970,
PreferredMaxYear = 2015,
PreferredMinDuration = 120,
PreferredMaxDuration = 180,
CreatedAt = DateTime.UtcNow.AddDays(-120)
},
new User
{
Username = "sarah_williams",
Email = "sarah@example.com",
FavoriteGenres = new List<string> { "Comedy", "Adventure", "Drama" },
FavoriteActors = new List<string> { "Tom Hanks", "Robin Williams", "Bill Murray" },
PreferredMinYear = 1995,
PreferredMaxYear = 2024,
PreferredMinDuration = 90,
PreferredMaxDuration = 150,
CreatedAt = DateTime.UtcNow.AddDays(-90)
},
new User
{
Username = "alex_brown",
Email = "alex@example.com",
FavoriteGenres = new List<string> { "Sci-Fi", "Action", "Adventure" },
FavoriteActors = new List<string> { "Keanu Reeves", "Matthew McConaughey", "Scarlett Johansson" },
PreferredMinYear = 2000,
PreferredMaxYear = 2024,
PreferredMinDuration = 100,
PreferredMaxDuration = 170,
CreatedAt = DateTime.UtcNow.AddDays(-60)
}
};
await context.Users.AddRangeAsync(users);
await context.SaveChangesAsync();
}
private static async Task SeedInteractionsAsync(AppDbContext context)
{
var random = new Random(42); // Fixed seed for reproducibility
var interactions = new List<UserInteraction>();
// User 1 (John): Likes Action, Sci-Fi, Thriller
interactions.AddRange(new[]
{
CreateInteraction(1, 2, InteractionType.Rating, 5.0, -30), // The Dark Knight
CreateInteraction(1, 3, InteractionType.Rating, 5.0, -28), // Inception
CreateInteraction(1, 7, InteractionType.Rating, 4.5, -25), // The Matrix
CreateInteraction(1, 8, InteractionType.Rating, 4.5, -20), // Interstellar
CreateInteraction(1, 11, InteractionType.Rating, 4.0, -15), // The Avengers
CreateInteraction(1, 1, InteractionType.View, null, -10),
CreateInteraction(1, 4, InteractionType.View, null, -8),
CreateInteraction(1, 14, InteractionType.Rating, 4.5, -5), // The Departed
});
// User 2 (Jane): Likes Drama, Romance
interactions.AddRange(new[]
{
CreateInteraction(2, 1, InteractionType.Rating, 5.0, -25), // Shawshank
CreateInteraction(2, 6, InteractionType.Rating, 5.0, -23), // Forrest Gump
CreateInteraction(2, 10, InteractionType.Rating, 4.5, -20), // The Green Mile
CreateInteraction(2, 12, InteractionType.Rating, 4.0, -18), // Titanic
CreateInteraction(2, 4, InteractionType.Rating, 4.5, -15), // The Godfather
CreateInteraction(2, 2, InteractionType.View, null, -10),
CreateInteraction(2, 5, InteractionType.Rating, 4.0, -5), // Pulp Fiction
});
// User 3 (Mike): Likes Crime, Thriller, Drama
interactions.AddRange(new[]
{
CreateInteraction(3, 4, InteractionType.Rating, 5.0, -30), // The Godfather
CreateInteraction(3, 5, InteractionType.Rating, 5.0, -28), // Pulp Fiction
CreateInteraction(3, 2, InteractionType.Rating, 4.5, -25), // The Dark Knight
CreateInteraction(3, 9, InteractionType.Rating, 4.5, -22), // Silence of the Lambs
CreateInteraction(3, 14, InteractionType.Rating, 5.0, -18), // The Departed
CreateInteraction(3, 1, InteractionType.Rating, 4.5, -15), // Shawshank
CreateInteraction(3, 15, InteractionType.Rating, 4.0, -10), // The Prestige
});
// User 4 (Sarah): Likes Comedy, Adventure, Drama
interactions.AddRange(new[]
{
CreateInteraction(4, 6, InteractionType.Rating, 5.0, -28), // Forrest Gump
CreateInteraction(4, 1, InteractionType.Rating, 4.5, -25), // Shawshank
CreateInteraction(4, 17, InteractionType.Rating, 5.0, -20), // Grand Budapest Hotel
CreateInteraction(4, 13, InteractionType.Rating, 4.0, -18), // Gladiator
CreateInteraction(4, 10, InteractionType.Rating, 4.5, -15), // The Green Mile
CreateInteraction(4, 12, InteractionType.View, null, -10),
});
// User 5 (Alex): Likes Sci-Fi, Action, Adventure
interactions.AddRange(new[]
{
CreateInteraction(5, 7, InteractionType.Rating, 5.0, -30), // The Matrix
CreateInteraction(5, 3, InteractionType.Rating, 5.0, -27), // Inception
CreateInteraction(5, 8, InteractionType.Rating, 5.0, -24), // Interstellar
CreateInteraction(5, 11, InteractionType.Rating, 4.5, -20), // The Avengers
CreateInteraction(5, 2, InteractionType.Rating, 4.5, -17), // The Dark Knight
CreateInteraction(5, 18, InteractionType.Rating, 4.5, -12), // Mad Max
CreateInteraction(5, 13, InteractionType.Rating, 4.0, -8), // Gladiator
});
// Add some random interactions for variety
for (int i = 1; i <= 5; i++)
{
for (int j = 0; j < 3; j++)
{
var movieId = random.Next(1, 21);
var rating = 3.0 + (random.NextDouble() * 2.0); // 3.0 to 5.0
var daysAgo = random.Next(1, 90);
// Avoid duplicates
if (!interactions.Any(x => x.UserId == i && x.MovieId == movieId))
{
interactions.Add(CreateInteraction(i, movieId, InteractionType.Rating, rating, -daysAgo));
}
}
}
await context.UserInteractions.AddRangeAsync(interactions);
await context.SaveChangesAsync();
}
private static UserInteraction CreateInteraction(
int userId,
int movieId,
InteractionType type,
double? rating,
int daysAgo)
{
return new UserInteraction
{
UserId = userId,
MovieId = movieId,
Type = type,
Rating = rating,
Timestamp = DateTime.UtcNow.AddDays(daysAgo)
};
}
}
12. Error Handling & Middleware {#section-12}
MovieRecommendation.API/Middleware/GlobalExceptionMiddleware.cs
using System.Net;
using System.Text.Json;
namespace MovieRecommendation.API.Middleware;
public class GlobalExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<GlobalExceptionMiddleware> _logger;
private readonly IHostEnvironment _environment;
public GlobalExceptionMiddleware(
RequestDelegate next,
ILogger<GlobalExceptionMiddleware> _logger,
IHostEnvironment environment)
{
_next = next;
this._logger = _logger;
_environment = environment;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, "An unhandled exception occurred: {Message}", ex.Message);
await HandleExceptionAsync(context, ex);
}
}
private Task HandleExceptionAsync(HttpContext context, Exception exception)
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
var response = new
{
error = "An error occurred processing your request",
message = _environment.IsDevelopment() ? exception.Message : "Internal server error",
stackTrace = _environment.IsDevelopment() ? exception.StackTrace : null,
timestamp = DateTime.UtcNow
};
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = _environment.IsDevelopment()
};
var json = JsonSerializer.Serialize(response, options);
return context.Response.WriteAsync(json);
}
}
MovieRecommendation.Infrastructure/Services/ModelTrainingBackgroundService.cs
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using MovieRecommendation.Core.Services;
namespace MovieRecommendation.Infrastructure.Services;
public class ModelTrainingBackgroundService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<ModelTrainingBackgroundService> _logger;
private readonly TimeSpan _trainingInterval = TimeSpan.FromHours(24);
public ModelTrainingBackgroundService(
IServiceProvider serviceProvider,
ILogger<ModelTrainingBackgroundService> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Model Training Background Service started");
// Wait 1 minute before first training attempt
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
_logger.LogInformation("Starting scheduled model training...");
using var scope = _serviceProvider.CreateScope();
var collaborativeEngine = scope.ServiceProvider
.GetRequiredService<ICollaborativeEngine>();
var success = await collaborativeEngine.TrainModelAsync();
if (success)
{
_logger.LogInformation("Scheduled model training completed successfully");
}
else
{
_logger.LogWarning("Scheduled model training failed - not enough data");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in model training background service");
}
// Wait for next training interval
await Task.Delay(_trainingInterval, stoppingToken);
}
_logger.LogInformation("Model Training Background Service stopped");
}
}
13. Testing {#section-13}
MovieRecommendation.Tests/ContentBasedEngineTests.cs
using Xunit;
using Moq;
using Microsoft.Extensions.Logging;
using MovieRecommendation.Core.Entities;
using MovieRecommendation.Core.Repositories;
using MovieRecommendation.Infrastructure.Services;
namespace MovieRecommendation.Tests;
public class ContentBasedEngineTests
{
private readonly Mock<IMovieRepository> _mockMovieRepo;
private readonly Mock<IUserRepository> _mockUserRepo;
private readonly Mock<IInteractionRepository> _mockInteractionRepo;
private readonly Mock<ILogger<ContentBasedEngine>> _mockLogger;
private readonly ContentBasedEngine _engine;
public ContentBasedEngineTests()
{
_mockMovieRepo = new Mock<IMovieRepository>();
_mockUserRepo = new Mock<IUserRepository>();
_mockInteractionRepo = new Mock<IInteractionRepository>();
_mockLogger = new Mock<ILogger<ContentBasedEngine>>();
_engine = new ContentBasedEngine(
_mockMovieRepo.Object,
_mockUserRepo.Object,
_mockInteractionRepo.Object,
_mockLogger.Object);
}
[Fact]
public async Task CalculateSimilarityScore_ExactGenreMatch_ReturnsHighScore()
{
// Arrange
var user = new User
{
Id = 1,
FavoriteGenres = new List<string> { "Action", "Sci-Fi" },
PreferredMinYear = 2000,
PreferredMaxYear = 2024
};
var movie = new Movie
{
Id = 1,
Genres = new List<string> { "Action", "Sci-Fi" },
ReleaseYear = 2020,
DurationMinutes = 120,
AverageRating = 4.5
};
// Act
var score = await _engine.CalculateSimilarityScore(user, movie);
// Assert
Assert.True(score > 0.7, $"Expected score > 0.7, got {score}");
}
[Fact]
public async Task CalculateSimilarityScore_NoGenreMatch_ReturnsLowScore()
{
// Arrange
var user = new User
{
Id = 1,
FavoriteGenres = new List<string> { "Romance", "Comedy" },
PreferredMinYear = 2000,
PreferredMaxYear = 2024
};
var movie = new Movie
{
Id = 1,
Genres = new List<string> { "Horror", "Thriller" },
ReleaseYear = 2020,
DurationMinutes = 120,
AverageRating = 4.5
};
// Act
var score = await _engine.CalculateSimilarityScore(user, movie);
// Assert
Assert.True(score < 0.5, $"Expected score < 0.5, got {score}");
}
[Fact]
public async Task GetContentBasedRecommendations_ReturnsLimitedResults()
{
// Arrange
var userId = 1;
var limit = 5;
var user = new User
{
Id = userId,
FavoriteGenres = new List<string> { "Action" }
};
var movies = Enumerable.Range(1, 10).Select(i => new Movie
{
Id = i,
Title = $"Movie {i}",
Genres = new List<string> { "Action" },
ReleaseYear = 2020,
DurationMinutes = 120,
AverageRating = 4.0
}).ToList();
_mockUserRepo.Setup(r => r.GetByIdAsync(userId))
.ReturnsAsync(user);
_mockInteractionRepo.Setup(r => r.GetUserInteractionsAsync(userId))
.ReturnsAsync(new List<UserInteraction>());
_mockMovieRepo.Setup(r => r.GetAvailableMoviesAsync(It.IsAny<int>()))
.ReturnsAsync(movies);
// Act
var recommendations = await _engine.GetContentBasedRecommendations(userId, limit);
// Assert
Assert.NotNull(recommendations);
Assert.True(recommendations.Count <= limit);
}
}
MovieRecommendation.Tests/RecommendationEngineTests.cs
using Xunit;
using Moq;
using Microsoft.Extensions.Logging;
using MovieRecommendation.Core.Models;
using MovieRecommendation.Core.Repositories;
using MovieRecommendation.Core.Services;
using MovieRecommendation.Infrastructure.Services;
namespace MovieRecommendation.Tests;
public class RecommendationEngineTests
{
[Fact]
public async Task GetRecommendations_CombinesBothEngines()
{
// Arrange
var mockContentEngine = new Mock<IContentBasedEngine>();
var mockCollaborativeEngine = new Mock<ICollaborativeEngine>();
var mockMovieRepo = new Mock<IMovieRepository>();
var mockInteractionRepo = new Mock<IInteractionRepository>();
var mockLogger = new Mock<ILogger<RecommendationEngine>>();
var contentResults = new List<RecommendationResult>
{
new RecommendationResult { MovieId = 1, ContentScore = 0.9 },
new RecommendationResult { MovieId = 2, ContentScore = 0.8 }
};
var collaborativeResults = new List<RecommendationResult>
{
new RecommendationResult { MovieId = 1, CollaborativeScore = 0.85 },
new RecommendationResult { MovieId = 3, CollaborativeScore = 0.75 }
};
mockContentEngine.Setup(e => e.GetContentBasedRecommendations(It.IsAny<int>(), It.IsAny<int>()))
.ReturnsAsync(contentResults);
mockCollaborativeEngine.Setup(e => e.GetCollaborativeRecommendations(It.IsAny<int>(), It.IsAny<int>()))
.ReturnsAsync(collaborativeResults);
var engine = new RecommendationEngine(
mockContentEngine.Object,
mockCollaborativeEngine.Object,
mockMovieRepo.Object,
mockInteractionRepo.Object,
mockLogger.Object);
// Act
var recommendations = await engine.GetRecommendations(1, 10);
// Assert
Assert.NotNull(recommendations);
mockContentEngine.Verify(e => e.GetContentBasedRecommendations(1, 30), Times.Once);
mockCollaborativeEngine.Verify(e => e.GetCollaborativeRecommendations(1, 30), Times.Once);
}
}
14. Docker & Deployment {#section-14}
Dockerfile
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Copy project files
COPY ["MovieRecommendation.API/MovieRecommendation.API.csproj", "MovieRecommendation.API/"]
COPY ["MovieRecommendation.Core/MovieRecommendation.Core.csproj", "MovieRecommendation.Core/"]
COPY ["MovieRecommendation.Infrastructure/MovieRecommendation.Infrastructure.csproj", "MovieRecommendation.Infrastructure/"]
# Restore dependencies
RUN dotnet restore "MovieRecommendation.API/MovieRecommendation.API.csproj"
# Copy everything else
COPY . .
# Build
WORKDIR "/src/MovieRecommendation.API"
RUN dotnet build "MovieRecommendation.API.csproj" -c Release -o /app/build
# Publish
FROM build AS publish
RUN dotnet publish "MovieRecommendation.API.csproj" -c Release -o /app/publish /p:UseAppHost=false
# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
EXPOSE 80
EXPOSE 443
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MovieRecommendation.API.dll"]
docker-compose.yml
version: '3.8'
services:
api:
build:
context: .
dockerfile: Dockerfile
container_name: movie-recommendation-api
ports:
- "5000:80"
- "5001:443"
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ASPNETCORE_URLS=http://+:80
- ConnectionStrings__DefaultConnection=Server=db;Database=MovieRecommendation;User=sa;Password=YourStrong@Passw0rd123;TrustServerCertificate=True;MultipleActiveResultSets=true
depends_on:
- db
networks:
- movie-network
restart: unless-stopped
db:
image: mcr.microsoft.com/mssql/server:2022-latest
container_name: movie-recommendation-db
environment:
- ACCEPT_EULA=Y
- SA_PASSWORD=YourStrong@Passw0rd123
- MSSQL_PID=Express
ports:
- "1433:1433"
volumes:
- sqldata:/var/opt/mssql
networks:
- movie-network
restart: unless-stopped
healthcheck:
test: /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P YourStrong@Passw0rd123 -Q "SELECT 1" || exit 1
interval: 10s
timeout: 3s
retries: 10
start_period: 10s
networks:
movie-network:
driver: bridge
volumes:
sqldata:
driver: local
.dockerignore
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
**/compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
README.md
15. Running the Application {#section-15}
Prerequisites
- .NET 8.0 SDK installed
- SQL Server or Docker
- Visual Studio 2022 / VS Code / Rider
Option 1: Run Locally (Without Docker)
Step 1: Update Connection String
Edit appsettings.Development.json:
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=MovieRecommendationDev;Trusted_Connection=True;TrustServerCertificate=True"
}
}
Step 2: Create Database & Apply Migrations
# Navigate to API project
cd MovieRecommendation.API
# Add migration
dotnet ef migrations add InitialCreate --project ../MovieRecommendation.Infrastructure --startup-project .
# Update database
dotnet ef database update --project ../MovieRecommendation.Infrastructure --startup-project .
Step 3: Run the Application
dotnet run
The API will be available at:
- HTTP:
http://localhost:5000 - HTTPS:
https://localhost:5001 - Swagger UI:
http://localhost:5000(root)
Option 2: Run with Docker
# Build and start containers
docker-compose up -d
# View logs
docker-compose logs -f api
# Stop containers
docker-compose down
The API will be available at:
- HTTP:
http://localhost:5000
Testing the API
1. Create a User:
curl -X POST "http://localhost:5000/api/users" \
-H "Content-Type: application/json" \
-d '{
"username": "testuser",
"email": "test@example.com",
"favoriteGenres": ["Action", "Sci-Fi"],
"favoriteActors": ["Leonardo DiCaprio"],
"preferredMinYear": 2000,
"preferredMaxYear": 2024,
"preferredMinDuration": 90,
"preferredMaxDuration": 180
}'
2. Get Recommendations:
curl "http://localhost:5000/api/recommendations/user/1?limit=10"
3. Track Interaction:
curl -X POST "http://localhost:5000/api/recommendations/track" \
-H "Content-Type: application/json" \
-d '{
"userId": 1,
"movieId": 2,
"type": "Rating",
"rating": 5.0
}'
4. Train ML Model:
curl -X POST "http://localhost:5000/api/recommendations/train"
5. Get Movies:
curl "http://localhost:5000/api/movies?limit=20"
Sample Response
[
{
"movieId": 3,
"title": "Inception",
"overallScore": 0.89,
"contentScore": 0.92,
"collaborativeScore": 0.85,
"popularityScore": 0.91,
"detailedScores": {
"Genre": 0.95,
"Actor": 0.88,
"Year": 1.0,
"Duration": 0.90,
"Rating": 0.94
},
"reasonForRecommendation": "Recommended because it matches your preferences, in your favorite genre, and features your favorite actors",
"generatedAt": "2024-01-15T10:30:00Z"
}
]
π COMPLETE! What You've Built
β Features Implemented:
-
Content-Based Filtering
- Genre matching with Jaccard similarity
- Actor preference matching
- Year and duration compatibility
- Multi-factor weighted scoring
-
Collaborative Filtering
- ML.NET matrix factorization
- User behavior pattern recognition
- Rating prediction
- Automated model training
-
Hybrid Recommendation
- Combines multiple strategies
- Configurable weights
- Popularity and recency factors
- Human-readable explanations
-
Production-Ready Infrastructure
- Clean architecture (Core/Infrastructure/API)
- Repository pattern
- Dependency injection
- Error handling middleware
- Background services
- Database seeding
- Health checks
-
API Features
- RESTful endpoints
- Swagger documentation
- Input validation
- CORS support
- Logging
-
DevOps
- Docker support
- docker-compose for local development
- Database migrations
- Unit tests
π Next Steps & Enhancements
Phase 2 Improvements:
- Add authentication (JWT/OAuth)
- Implement Redis caching
- Add rate limiting
- Create admin dashboard
- Add user feedback collection
- Implement A/B testing framework
Phase 3 Advanced Features:
- Deep learning models (neural collaborative filtering)
- Image-based recommendations
- Natural language search
- Real-time recommendations with SignalR
- Multi-armed bandit algorithms
- Contextual recommendations (time, location, device)
π Resources
- ML.NET Documentation
- Recommendation Systems Theory
- Matrix Factorization Explained
- Clean Architecture Pattern
π Summary
You now have a complete, production-ready AI recommendation system with:
- β 2 recommendation strategies (content + collaborative)
- β ML.NET integration for machine learning
- β Clean architecture with separation of concerns
- β Full CRUD API with Swagger docs
- β Database with EF Core
- β Docker containerization
- β Unit tests
- β Background ML model training
- β 20+ sample movies and test data
The system is ready to run, test, and extend! π
π¬ Questions? Issues? Improvements?
I'd love to hear your thoughts and feedback!
π Connect with me on LinkedIn: HAMZA ZERYOUH
Whether you have questions about the implementation, suggestions for improvements, or want to share your own ML.NET experiences β feel free to reach out!
π§ Let's discuss:
- How you're using ML.NET in production
- Challenges you faced with recommendation systems
- Ideas for Part 2 of this tutorial
Found this helpful? Don't forget to:
- β Star the repository (if you create one)
- π Share this article with your network
- π¬ Drop a comment with your experience
#dotnet #machinelearning #mlnet #ai #tutorial #recommendations #cleanarchitecture
Top comments (0)