Design Patterns #8: If Repository Is Not Enough – Exploring Real-World Data Patterns
Serhii Korol

Serhii Korol @serhii_korol_ab7776c50dba

About: Software Engineer, .NET developer

Location:
Kyiv, Ukraine
Joined:
May 28, 2022

Design Patterns #8: If Repository Is Not Enough – Exploring Real-World Data Patterns

Publish Date: May 22
2 1

Today, I’d like to talk about a design pattern that many developers use by default in almost every project — the Repository pattern. It's a popular approach for abstracting data access, and in many cases, it gets the job done.

But what happens when it’s not enough?

In this article, I’ll walk you through alternative approaches built on top of the Repository pattern, including Unit of Work and Lazy Loading Proxies. These patterns can help you tackle more complex scenarios while keeping your code clean and maintainable.

Let’s dive in.

Repository

First, let’s start with a typical implementation of the Repository pattern. We’ll use a SQLite database and implement the basic CRUD operations — Create, Read, Update, and Delete.
The database file used in this example is included in the repository for this article.

To keep things simple, we’ll define a pair of related entities that demonstrate how the pattern works in practice.

public class Album
{
    public int AlbumId { get; set; }
    public string? Title { get; set; }
    public int ArtistId { get; set; }
    public Artist? Artist { get; set; }
}

public class Artist
{
    public int ArtistId { get; set; }
    public string? Name { get; set; }
    public List<Album>? Albums { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

What’s a Repository without a database context?
Let’s add the DbContext, where we configure the connection string and define the relationships between our tables.

public class MusicDbContext : DbContext
{
    public DbSet<Artist> Artists { get; set; }
    public DbSet<Album> Albums { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlite("Data Source=chinook.db");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Artist>(entity =>
        {
            entity.ToTable("artists");
            entity.HasKey(e => e.ArtistId);
            entity.Property(e => e.ArtistId).HasColumnName("ArtistId");
            entity.Property(e => e.Name).HasColumnName("Name").HasMaxLength(120);

            entity.HasMany(a => a.Albums)
                .WithOne(b => b.Artist)
                .HasForeignKey(b => b.ArtistId);
        });

        modelBuilder.Entity<Album>(entity =>
        {
            entity.ToTable("albums");
            entity.HasKey(e => e.AlbumId);
            entity.Property(e => e.AlbumId).HasColumnName("AlbumId");
            entity.Property(e => e.Title).HasColumnName("Title").HasMaxLength(160);
            entity.Property(e => e.ArtistId).HasColumnName("ArtistId");
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, let’s implement the Repository itself. Since most repositories share the same basic CRUD operations, we’ll start by creating a generic base Repository to avoid duplication and keep things clean.

public interface IRepository<T> where T : class
{
    Task<IEnumerable<T>> GetAllAsync();
    Task<T?> GetByIdAsync(int id);
    Task AddAsync(T entity);
    Task UpdateAsync(T entity);
    Task DeleteAsync(int id);
}

public abstract class Repository<T> : IRepository<T> where T : class
{
    private readonly MusicDbContext _context;
    private readonly DbSet<T> _dbSet;

    protected Repository(MusicDbContext context)
    {
        _context = context;
        _dbSet = _context.Set<T>();
    }

    public virtual async Task<IEnumerable<T>> GetAllAsync()
    {
        return await _dbSet.ToListAsync();
    }

    public virtual async Task<T?> GetByIdAsync(int id)
    {
        return await _dbSet.FindAsync(id);
    }

    public virtual async Task AddAsync(T entity)
    {
        await _dbSet.AddAsync(entity);
        await _context.SaveChangesAsync();
    }

    public virtual async Task UpdateAsync(T entity)
    {
        _dbSet.Update(entity);
        await _context.SaveChangesAsync();
    }

    public virtual async Task DeleteAsync(int id)
    {
        var entity = await GetByIdAsync(id);
        if (entity != null)
        {
            _dbSet.Remove(entity);
            await _context.SaveChangesAsync();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, let’s create concrete repositories for each entity. In this example, all methods are overridden, but if you need entity-specific queries, you can add them only to the corresponding repository. Notice that we include related tables using eager loading—this ensures the related data is loaded alongside the main entity, whether it's used or not. While eager loading can be convenient, including too many related tables may lead to performance issues due to unnecessary data retrieval.

public class AlbumRepository(MusicDbContext context)  : Repository<Album>(context)
{
    public override async Task<IEnumerable<Album>> GetAllAsync()
    {
        return await context.Albums.Include(x => x.Artist).ToListAsync();
    }

    public override async Task<Album?> GetByIdAsync(int id)
    {
        return await context.Albums.Include(x => x.Artist).FirstOrDefaultAsync(x => x.AlbumId == id);
    }

    public override async Task AddAsync(Album album)
    {
        await base.AddAsync(album);
    }

    public override async Task UpdateAsync(Album album)
    {
        await base.UpdateAsync(album);
    }

    public override async Task DeleteAsync(int id)
    {
        var entity = await GetByIdAsync(id);
        if (entity != null)
        {
            await base.DeleteAsync(id);
        }
    }
}

public class ArtistRepository(MusicDbContext context) : Repository<Artist>(context)
{
    public override async Task<IEnumerable<Artist>> GetAllAsync()
    {
        return await context.Artists.Include(x => x.Albums).ToListAsync();
    }

    public override async Task<Artist?> GetByIdAsync(int id)
    {
        return await context.Artists.Include(x => x.Albums).FirstOrDefaultAsync(x => x.ArtistId == id);
    }

    public override async Task AddAsync(Artist artist)
    {
        await base.AddAsync(artist);
    }

    public override async Task UpdateAsync(Artist artist)
    {
        await base.UpdateAsync(artist);
    }

    public override async Task DeleteAsync(int id)
    {
        var entity = await GetByIdAsync(id);
        if (entity != null)
        {
            await base.DeleteAsync(id);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Optionally, you can introduce services to handle data retrieval and modification. While it's possible to call repositories directly, using services is more common in real-world applications, as it helps encapsulate business logic and maintain separation of concerns.

public class ArtistService(IRepository<Artist> artistRepository)
{
    public Task<IEnumerable<Artist>> GetAllArtistsAsync() => artistRepository.GetAllAsync();

    public Task<Artist?> GetArtistByIdAsync(int id) => artistRepository.GetByIdAsync(id);

    public Task AddArtistAsync(Artist artist) => artistRepository.AddAsync(artist);

    public Task UpdateArtistAsync(Artist artist) => artistRepository.UpdateAsync(artist);

    public Task DeleteArtistAsync(int id) => artistRepository.DeleteAsync(id);
}

public class AlbumService(IRepository<Album> albumRepository)
{
    public Task DeleteAlbumAsync(int id) => albumRepository.DeleteAsync(id);
}
Enter fullscreen mode Exit fullscreen mode

To test and run the scenario, define the following method:

public static async Task RunRepository()
    {
        //init
        await using var dbContext = new Repository.Context.MusicDbContext();
        var artistRepository = new ArtistRepository(dbContext);
        var albumRepository = new AlbumRepository(dbContext);
        var artistService = new Repository.Services.ArtistService(artistRepository);
        var albumService = new Repository.Services.AlbumService(albumRepository);

        //get all artists
        Console.WriteLine("All artists:");
        var artists = await artistService.GetAllArtistsAsync();
        foreach (var artist in artists)
        {
            var albums = artist.Albums != null && artist.Albums.Any()
                ? artist.Albums.Select(x => x.Title).ToList()
                : ["No albums"];
            Console.WriteLine($"{artist.ArtistId}: {artist.Name}. Discography: {string.Join(", ", albums)}");
        }

        // Add new artist
        var newArtist = new Artist
            { Name = "EF Core Artist", Albums = new List<Album> { new Album { Title = "EF Core Album" } } };
        await artistService.AddArtistAsync(newArtist);
        Console.WriteLine($"Added artist with ID {newArtist.ArtistId}");

        // Update artist
        newArtist.Name = "Updated EF Core Artist";
        await artistService.UpdateArtistAsync(newArtist);
        Console.WriteLine("Updated artist.");

        // Get by ID
        var artistById = await artistService.GetArtistByIdAsync(newArtist.ArtistId);
        if (artistById?.Albums != null)
        {
            var albums = artistById.Albums.Select(x => x.Title).ToList();
            Console.WriteLine(
                $"Artist by ID: {artistById.ArtistId} - {artistById.Name} ({string.Join(", ", albums.Count > 0 ? albums : ["No albums"])})");

            //Delete album
            await albumService.DeleteAlbumAsync(artistById.Albums.First().AlbumId);
            artistById = await artistService.GetArtistByIdAsync(newArtist.ArtistId);
            if (artistById?.Albums != null) albums = artistById.Albums.Select(x => x.Title).ToList();
            Console.WriteLine(
                $"Artist by ID: {artistById?.ArtistId} - {artistById?.Name} ({string.Join(", ", albums.Count > 0 ? albums : ["No albums"])})");
        }

        // Delete artist
        await artistService.DeleteArtistAsync(newArtist.ArtistId);
        Console.WriteLine("Deleted artist.");

        Console.WriteLine("Done.");
    }
Enter fullscreen mode Exit fullscreen mode

The result should be like this:

All artists:
1: AC/DC. Discography: For Those About To Rock We Salute You, Let There Be Rock
2: Accept. Discography: Balls to the Wall, Restless and Wild
3: Aerosmith. Discography: Big Ones
...
273: C. Monteverdi, Nigel Rogers - Chiaroscuro; London Baroque; London Cornett & Sackbu. Discography: Monteverdi: L'Orfeo
274: Nash Ensemble. Discography: Mozart: Chamber Music
275: Philip Glass Ensemble. Discography: Koyaanisqatsi (Soundtrack from the Motion Picture)
Added artist with ID 280
Updated artist.
Artist by ID: 280 - Updated EF Core Artist (EF Core Album)
Artist by ID: 280 - Updated EF Core Artist (No albums)
Deleted artist.
Done.

Process finished with exit code 0.
Enter fullscreen mode Exit fullscreen mode

Let's examine the strengths and weaknesses of this approach:

✅ Pros:
  • Simple and straightforward to implement
  • Widely adopted and well-understood
  • Delivers high performance in most scenarios
❌ Cons:
  • Requires manual inclusion of related tables for each query
  • Eager loading can negatively impact performance if overused
  • Code readability can suffer when managing many repositories

How can we improve this?

UnitOfWork

This approach aims to improve readability without compromising performance. It requires only minimal changes to your existing codebase. The core idea behind this pattern is to introduce a class that encapsulates all your repositories, providing a single entry point for data access.

public interface IUnitOfWork : IAsyncDisposable
{
    public IRepository<Artist> ArtistRepository { get; }
    public IRepository<Album> AlbumRepository { get; }

    Task<int> SaveChangesAsync();
}

public class UnitOfWork : IUnitOfWork
{
    private readonly MusicDbContext _context;
    public IRepository<Artist> ArtistRepository { get; }

    public IRepository<Album> AlbumRepository { get; }

    public UnitOfWork(MusicDbContext context)
    {
        _context = context;
        ArtistRepository = new ArtistRepository(_context);
        AlbumRepository = new AlbumRepository(_context);
    }

    public async Task<int> SaveChangesAsync()
    {
        return await _context.SaveChangesAsync();
    }

    public async ValueTask DisposeAsync()
    {
        await _context.DisposeAsync();
    }
}
Enter fullscreen mode Exit fullscreen mode

Next, update your services to use the UnitOfWork instead of individual repositories.

public class ArtistService(IUnitOfWork uow)
{
    public Task<IEnumerable<Artist>> GetAllArtistsAsync() => uow.ArtistRepository.GetAllAsync();

    public Task<Artist?> GetArtistByIdAsync(int id) => uow.ArtistRepository.GetByIdAsync(id);

    public Task AddArtistAsync(Artist artist) => uow.ArtistRepository.AddAsync(artist);

    public Task UpdateArtistAsync(Artist artist) => uow.ArtistRepository.UpdateAsync(artist);

    public Task DeleteArtistAsync(int id) => uow.ArtistRepository.DeleteAsync(id);
}

public class AlbumService(IUnitOfWork uow)
{
    public Task DeleteAlbumAsync(int id) => uow.AlbumRepository.DeleteAsync(id);
}
Enter fullscreen mode Exit fullscreen mode

To try this out, add the following code:

    public static async Task RunUnitOfWork()
    {
        //init
        await using var dbContext = new UnitOfWork.Context.MusicDbContext();
        var uow = new UnitOfWork.UoW.UnitOfWork(dbContext);
        var artistService = new UnitOfWork.Services.ArtistService(uow);
        var albumService = new UnitOfWork.Services.AlbumService(uow);

        //get all artists
        Console.WriteLine("All artists:");
        var artists = await artistService.GetAllArtistsAsync();
        foreach (var artist in artists)
        {
            var albums = artist.Albums.Any() ? artist.Albums.Select(x => x.Title).ToList() : ["No albums"];
            Console.WriteLine($"{artist.ArtistId}: {artist.Name}. Discography: {string.Join(", ", albums)}");
        }

        // Add new artist
        var newArtist = new UnitOfWork.Models.Artist
        {
            Name = "EF Core Artist",
            Albums = new List<UnitOfWork.Models.Album> { new UnitOfWork.Models.Album { Title = "EF Core Album" } }
        };
        await artistService.AddArtistAsync(newArtist);
        Console.WriteLine($"Added artist with ID {newArtist.ArtistId}");

        // Update artist
        newArtist.Name = "Updated EF Core Artist";
        await artistService.UpdateArtistAsync(newArtist);
        Console.WriteLine("Updated artist.");

        // Get by ID
        var artistById = await artistService.GetArtistByIdAsync(newArtist.ArtistId);
        if (artistById?.Albums != null)
        {
            var albums = artistById.Albums.Select(x => x.Title).ToList();
            Console.WriteLine(
                $"Artist by ID: {artistById.ArtistId} - {artistById.Name} ({string.Join(", ", albums.Count > 0 ? albums : ["No albums"])})");

            //Delete album
            await albumService.DeleteAlbumAsync(artistById.Albums.First().AlbumId);
            artistById = await artistService.GetArtistByIdAsync(newArtist.ArtistId);
            if (artistById?.Albums != null) albums = artistById.Albums.Select(x => x.Title).ToList();
            Console.WriteLine(
                $"Artist by ID: {artistById?.ArtistId} - {artistById?.Name} ({string.Join(", ", albums.Count > 0 ? albums : ["No albums"])})");
        }

        // Delete artist
        await artistService.DeleteArtistAsync(newArtist.ArtistId);
        Console.WriteLine("Deleted artist.");

        Console.WriteLine("Done.");
    }
Enter fullscreen mode Exit fullscreen mode

The result should be the same as before. Now, let’s take a look at the pros and cons:

✅ Pros
  • Improved readability
  • Retains the same benefits as the Repository pattern
❌ Cons
  • Uses eager loading, which can potentially impact performance
  • May be overkill for small databases

The main drawback of both previous approaches is the use of eager loading. How can this be addressed? The answer is lazy loading, which I will explain in the next chapter.

Naive lazy loading

I’ll show you an approach that doesn’t rely on third-party packages and involves working with additional tables manually. This method requires more substantial changes to your codebase. The main goal of this implementation is to apply the Proxy pattern, which I’ll cover in more detail in future articles.

With this approach, we don’t access entities directly when querying data—instead, we use proxy entities to act on their behalf.

public delegate Task<List<Album>> LazyAlbumsLoader(int artistId);

public class LazyArtist : Artist
{
    private readonly LazyAlbumsLoader _lazyLoader;
    private List<Album>? _albums;
    private bool _isLoaded;
    private readonly SemaphoreSlim _semaphore = new(1, 1);

    public LazyArtist(Artist artist, LazyAlbumsLoader lazyLoader)
    {
        ArtistId = artist.ArtistId;
        Name = artist.Name;
        _lazyLoader = lazyLoader;
        _isLoaded = false;
    }

    public new Task<List<Album>?> Albums => GetAlbumsAsync();

    private async Task<List<Album>?> GetAlbumsAsync()
    {
        if (_isLoaded) return _albums;

        await _semaphore.WaitAsync();
        try
        {
            if (!_isLoaded)
            {
                _albums = await _lazyLoader(ArtistId);
                _isLoaded = true;
            }
        }
        finally
        {
            _semaphore.Release();
        }

        return _albums;
    }
}
Enter fullscreen mode Exit fullscreen mode

This class inherits from the base entity and loads related data on demand. The private method used for this is fully thread-safe.

Next, you’ll need to update your repositories and remove all Include statements.

public class AlbumRepository(MusicDbContext context) : Repository<Album>(context)
{
    public override async Task<IEnumerable<Album>> GetAllAsync()
    {
        return await context.Albums.ToListAsync();
    }

    public override async Task<Album?> GetByIdAsync(int id)
    {
        return await context.Albums.FirstOrDefaultAsync(x => x.AlbumId == id);
    }

    public async Task<List<Album>> GetByForeignKeyAsync(int id)
    {
        return await context.Albums.Where(x => x.ArtistId == id).ToListAsync();
    }

    public override async Task AddAsync(Album album)
    {
        await base.AddAsync(album);
    }

    public override async Task UpdateAsync(Album album)
    {
        await base.UpdateAsync(album);
    }

    public override async Task DeleteAsync(int id)
    {
        var entity = await GetByIdAsync(id);
        if (entity != null)
        {
            await base.DeleteAsync(id);
        }
    }
}

public class ArtistRepository(MusicDbContext context) : Repository<Artist>(context)
{
    public override async Task<IEnumerable<Artist>> GetAllAsync()
    {
        return await context.Artists.ToListAsync();
    }

    public override async Task<Artist?> GetByIdAsync(int id)
    {
        return await context.Artists.FirstOrDefaultAsync(x => x.ArtistId == id);
    }

    public override async Task AddAsync(Artist artist)
    {
        await base.AddAsync(artist);
    }

    public override async Task UpdateAsync(Artist artist)
    {
        await base.UpdateAsync(artist);
    }

    public override async Task DeleteAsync(int id)
    {
        var entity = await GetByIdAsync(id);
        if (entity != null)
        {
            await base.DeleteAsync(id);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

To enable lazy loading, an intermediate layer is also required. This class loads the necessary data on demand.

public class LazyFactory(ArtistRepository artistRepo, AlbumRepository albumRepo) : ILazyFactory
{
    public async Task<List<LazyArtist>> GetAllLazyArtistsAsync()
    {
        var artists = await artistRepo.GetAllAsync();
        var lazyArtists = artists.Select(a => new LazyArtist(a, artistId => albumRepo.GetByForeignKeyAsync(artistId)))
            .ToList();
        return lazyArtists;
    }

    public async Task<LazyArtist?> GetLazyArtistByIdAsync(int id)
    {
        var artist = await artistRepo.GetByIdAsync(id);
        if (artist == null) return null;

        var lazyArtist = new LazyArtist(artist, async artistId =>
            (await albumRepo.GetByForeignKeyAsync(artistId)).ToList());
        return lazyArtist;
    }
}

public interface ILazyFactory
{
    Task<List<LazyArtist>> GetAllLazyArtistsAsync();
    Task<LazyArtist?> GetLazyArtistByIdAsync(int id);
}
Enter fullscreen mode Exit fullscreen mode

Use this class in your service implementation:

public class ArtistService(IRepository<Artist> artistRepository, ILazyFactory lazyFactory)
{
    public Task<List<LazyArtist>> GetAllArtistsAsync() => lazyFactory.GetAllLazyArtistsAsync();

    public Task<LazyArtist?> GetArtistByIdAsync(int id) => lazyFactory.GetLazyArtistByIdAsync(id);

    public Task AddArtistAsync(Artist artist) => artistRepository.AddAsync(artist);

    public Task UpdateArtistAsync(Artist artist) => artistRepository.UpdateAsync(artist);

    public Task DeleteArtistAsync(int id) => artistRepository.DeleteAsync(id);
}
Enter fullscreen mode Exit fullscreen mode

You can test it by adding this method:

public static async Task RunLazyLoadingProxyNaive()
    {
        await using var dbContext = new LazyLoadingProxyNaive.Context.MusicDbContext();
        var artistRepository = new LazyLoadingProxyNaive.Repositories.ArtistRepository(dbContext);
        var albumRepository = new LazyLoadingProxyNaive.Repositories.AlbumRepository(dbContext);
        var lazyFactory = new LazyFactory(artistRepository, albumRepository);
        var artistService = new LazyLoadingProxyNaive.Services.ArtistService(artistRepository, lazyFactory);
        var albumService = new LazyLoadingProxyNaive.Services.AlbumService(albumRepository);

        Console.WriteLine("All artists:");
        var artists = await artistService.GetAllArtistsAsync();
        foreach (var artist in artists)
        {
            var albums = await artist.Albums;
            List<string?> albumTitles;
            if (albums != null)
            {
                albumTitles = albums.Any() ? albums.Select(x => x.Title).ToList() : ["No albums"];
            }
            else
            {
                albumTitles = ["No albums"];
            }

            Console.WriteLine($"{artist.ArtistId}: {artist.Name}. Discography: {string.Join(", ", albumTitles)}");
        }

        // Add new artist
        var newArtist = new LazyLoadingProxyNaive.Models.Artist
        {
            Name = "EF Core Artist",
            Albums = new List<LazyLoadingProxyNaive.Models.Album>
                { new LazyLoadingProxyNaive.Models.Album { Title = "EF Core Album" } }
        };
        await artistService.AddArtistAsync(newArtist);
        Console.WriteLine($"Added artist with ID {newArtist.ArtistId}");

        // Update artist
        newArtist.Name = "Updated EF Core Artist";
        await artistService.UpdateArtistAsync(newArtist);
        Console.WriteLine("Updated artist.");

        // Get by ID
        var artistById = await artistService.GetArtistByIdAsync(newArtist.ArtistId);
        if (artistById?.Albums != null)
        {
            var albumsSingleArtist = await artistById.Albums;
            if (albumsSingleArtist != null)
            {
                var albums = albumsSingleArtist.Select(x => x.Title).ToList();
                Console.WriteLine(
                    $"Artist by ID: {artistById.ArtistId} - {artistById.Name} ({string.Join(", ", albums.Count > 0 ? albums : ["No albums"])})");

                //Delete album
                await albumService.DeleteAlbumAsync(albumsSingleArtist.First().AlbumId);
                artistById = await artistService.GetArtistByIdAsync(newArtist.ArtistId);
                if (artistById?.Albums != null) albums = (await artistById.Albums).Select(x => x.Title).ToList();
                Console.WriteLine(
                    $"Artist by ID: {artistById?.ArtistId} - {artistById?.Name} ({string.Join(", ", albums.Count > 0 ? albums : ["No albums"])})");
            }
        }

        // Delete artist
        await artistService.DeleteArtistAsync(newArtist.ArtistId);
        Console.WriteLine("Deleted artist.");

        Console.WriteLine("Done.");
    }
Enter fullscreen mode Exit fullscreen mode

If you’re seeing the same result, let’s go over the pros and cons:

✅ Pros
  • No need for Include statements
  • Lazy loading happens on demand
❌ Cons
  • More complex implementation
  • Potential performance overhead

You might be wondering: Can this be simplified?
The answer is yes—there’s an alternative approach.

Microsoft.EntityFrameworkCore.Proxies

You’ll need to install the Microsoft.EntityFrameworkCore.Proxies package and make a small modification to your DbContext.

public class MusicDbContext : DbContext
{
    public DbSet<Artist> Artists { get; set; }
    public DbSet<Album> Albums { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlite("Data Source=chinook.db")
            .UseLazyLoadingProxies();
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Artist>(entity =>
        {
            entity.ToTable("artists");
            entity.HasKey(e => e.ArtistId);
            entity.Property(e => e.ArtistId).HasColumnName("ArtistId");
            entity.Property(e => e.Name).HasColumnName("Name").HasMaxLength(120);

            entity.HasMany(a => a.Albums)
                .WithOne(b => b.Artist)
                .HasForeignKey(b => b.ArtistId);
        });

        modelBuilder.Entity<Album>(entity =>
        {
            entity.ToTable("albums");
            entity.HasKey(e => e.AlbumId);
            entity.Property(e => e.AlbumId).HasColumnName("AlbumId");
            entity.Property(e => e.Title).HasColumnName("Title").HasMaxLength(160);
            entity.Property(e => e.ArtistId).HasColumnName("ArtistId");
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

You’ll also need to make a small change to your entities: any navigation properties should be marked as virtual.

public class Album
{
    public int AlbumId { get; set; }
    public string? Title { get; set; }
    public int ArtistId { get; set; }
    public virtual Artist? Artist { get; set; }
}

public class Artist
{
    public int ArtistId { get; set; }
    public string? Name { get; set; }
    public virtual List<Album>? Albums { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

The rest of the code remains the same as in the Repository approach. This package also uses proxies under the hood, so minimal changes are required.

To verify the setup:

    public static async Task RunLazyLoadingProxyPackage()
    {
        //init
        await using var dbContext = new LazyLoadingProxyPackage.Context.MusicDbContext();
        var artistRepository = new LazyLoadingProxyPackage.Repositories.ArtistRepository(dbContext);
        var albumRepository = new LazyLoadingProxyPackage.Repositories.AlbumRepository(dbContext);
        var artistService = new LazyLoadingProxyPackage.Services.ArtistService(artistRepository);
        var albumService = new LazyLoadingProxyPackage.Services.AlbumService(albumRepository);

        //get all artists
        Console.WriteLine("All artists:");
        var artists = await artistService.GetAllArtistsAsync();
        foreach (var artist in artists)
        {
            var albums = artist.Albums != null && artist.Albums.Any()
                ? artist.Albums.Select(x => x.Title).ToList()
                : ["No albums"];
            Console.WriteLine($"{artist.ArtistId}: {artist.Name}. Discography: {string.Join(", ", albums)}");
        }

        // Add new artist
        var newArtist = new LazyLoadingProxyPackage.Models.Artist
        {
            Name = "EF Core Artist",
            Albums = new List<LazyLoadingProxyPackage.Models.Album>
                { new LazyLoadingProxyPackage.Models.Album { Title = "EF Core Album" } }
        };
        await artistService.AddArtistAsync(newArtist);
        Console.WriteLine($"Added artist with ID {newArtist.ArtistId}");

        // Update artist
        newArtist.Name = "Updated EF Core Artist";
        await artistService.UpdateArtistAsync(newArtist);
        Console.WriteLine("Updated artist.");

        // Get by ID
        var artistById = await artistService.GetArtistByIdAsync(newArtist.ArtistId);
        if (artistById?.Albums != null)
        {
            var albums = artistById.Albums.Select(x => x.Title).ToList();
            Console.WriteLine(
                $"Artist by ID: {artistById.ArtistId} - {artistById.Name} ({string.Join(", ", albums.Count > 0 ? albums : ["No albums"])})");

            //Delete album
            await albumService.DeleteAlbumAsync(artistById.Albums.First().AlbumId);
            artistById = await artistService.GetArtistByIdAsync(newArtist.ArtistId);
            if (artistById?.Albums != null) albums = artistById.Albums.Select(x => x.Title).ToList();
            Console.WriteLine(
                $"Artist by ID: {artistById?.ArtistId} - {artistById?.Name} ({string.Join(", ", albums.Count > 0 ? albums : ["No albums"])})");
        }

        // Delete artist
        await artistService.DeleteArtistAsync(newArtist.ArtistId);
        Console.WriteLine("Deleted artist.");

        Console.WriteLine("Done.");
    }
Enter fullscreen mode Exit fullscreen mode

This approach is simpler than the previous one but comes with a significant drawback: reduced performance. The performance impact stems from the use of generated proxy classes, which can introduce overhead.

Let’s summarize this approach:

✅ Pros
  • Simpler implementation
  • Supports lazy loading
❌ Cons
  • Lower performance due to proxy generation

Benchmark

Let's evaluate the performance of all four approaches.

benchmark

As you can see, the last approach is the slowest because it uses a dynamic proxy.

Conclusion

When should you use these data access patterns?

  • Repository: This is the default and de facto standard approach for data access.
  • UnitOfWork: Use this pattern when working with multiple repositories, as it simplifies managing and coordinating access across them.
  • Custom Lazy Loading: Choose this if you need fine-grained control over data loading to optimize performance.
  • Lazy Loading Proxy Package: Ideal when dealing with many related data entities, helping improve performance by avoiding unnecessary loading of unused related data.

I hope you found this guide helpful and that it encourages you to implement similar solutions in your own projects.

For your convenience, the complete source code is available on my GitHub repository for reference and further exploration.

Buy Me A Beer

Comments 1 total

  • Spyros Ponaris
    Spyros PonarisMay 22, 2025

    Thanks for sharing! I used to write custom repositories in the past, but I’ve moved away from that approach. Since Entity Framework already implements the Repository and Unit of Work patterns internally, I find that writing additional layers often adds unnecessary complexity without much benefit.

    Instead, I prefer leveraging EF’s built-in capabilities directly, which keeps the codebase cleaner and reduces boilerplate. Of course, there are cases where custom repositories make sense ,especially when dealing with legacy systems or when you need strict separation of concerns ,but for most modern EF projects, the built-in features are usually sufficient.

Add comment