Repository Pattern (EF Core)

A generic base repository for the CRUD every entity needs, plus specific repositories for query methods that don't fit the generic shape.

Generic base

IRepository.cs
public interface IRepository<T> where T : class
{
    Task<T?> GetByIdAsync(Guid id);
    Task<T> AddAsync(T entity);
    Task UpdateAsync(T entity);
    Task DeleteAsync(T entity);
}

Specific repository extends it

Domain-specific query methods live on their own interface, which inherits the generic one. Keep this interface in Domain so Application can depend on it without knowing about EF Core.

IRatingRepository.cs (Domain)
public interface IRatingRepository : IRepository<Rating>
{
    Task<IEnumerable<Rating>> GetUserRatingsAsync(Guid userId);
    Task<Rating?> GetUserCategoryRatingAsync(Guid userId, Guid categoryId);
    Task<Rating> GetOrCreateRatingAsync(Guid userId, Guid categoryId);
}
RatingRepository.cs (Infrastructure)
public class RatingRepository : Repository<Rating>, IRatingRepository
{
    private readonly AppDbContext _context;
    private const double InitialRating = 1200;

    public RatingRepository(AppDbContext context) : base(context)
    {
        _context = context;
    }

    public async Task<Rating?> GetUserCategoryRatingAsync(Guid userId, Guid categoryId)
        => await _context.Ratings
            .Include(r => r.User)
            .FirstOrDefaultAsync(r => r.UserId == userId
                && r.CategoryId == categoryId
                && r.DeletedAt == null);

    public async Task<Rating> GetOrCreateRatingAsync(Guid userId, Guid categoryId)
    {
        var existing = await GetUserCategoryRatingAsync(userId, categoryId);
        if (existing != null) return existing;

        var created = new Rating
        {
            Id = Guid.NewGuid(),
            UserId = userId,
            CategoryId = categoryId,
            CurrentRating = InitialRating,
            CreatedAt = DateTime.UtcNow
        };
        return await AddAsync(created);
    }
}

Soft delete via a global query filter

Configuration lives per-entity as an extension method on EntityTypeBuilder<T>, applied from OnModelCreating. HasQueryFilter makes "deleted" rows invisible everywhere without repeating a Where clause in every query.

Configurations/MatchParticipantConfiguration.cs
public static class MatchParticipantConfiguration
{
    public static void Configure(this EntityTypeBuilder<MatchParticipant> builder)
    {
        builder.HasOne(mp => mp.Match)
            .WithMany(m => m.Participants)
            .HasForeignKey(mp => mp.MatchId)
            .OnDelete(DeleteBehavior.Cascade);

        builder.HasIndex(mp => new { mp.MatchId, mp.UserId }).IsUnique();
        builder.HasQueryFilter(mp => mp.DeletedAt == null);
    }
}
AppDbContext.cs
protected override void OnModelCreating(ModelBuilder builder)
{
    builder.Entity<User>(o => o.Configure());
    builder.Entity<MatchParticipant>(o => o.Configure());
    base.OnModelCreating(builder);
}

Seeding on startup

Same idea: a static extension per entity, invoked from OnConfiguring's UseSeeding hook, so seed data is declarative and only runs when the table is empty.

Seeders/UserSeeder.cs
public static class UserSeeder
{
    public static void UseUserSeeder(this DbContext context)
    {
        var set = context.Set<User>();
        if (set.Any()) return;

        set.AddRange(new User
        {
            Email = "admin@example.com",
            HashedPassword = "$2a$12$...", // pre-hashed
            Role = UserRole.Admin,
            CreatedAt = DateTime.UtcNow
        });
        context.SaveChanges();
    }
}

// in AppDbContext.OnConfiguring:
builder.UseSeeding((context, _) => context.UseUserSeeder());