Search code examples
c#asp.net.netentity-framework

Entity Framework - Registering a repository implementation with different interfaces


In our codebase we have a repository class that has this inheritance pattern:

public interface IReadOnlyRepository 
{
   public IEnumerable<TEntity> Get<TEntity>() where TEntity : BaseEntity
}

public interface IReadWriteRepository : IReadOnlyRepository{}
{
   public void Add<TEntity>(TEntity entity) where TEntity : BaseEntity

   public void Update<TEntity>(TEntity entity) where TEntity : BaseEntity

   // more CRUD methods etc
}

public class Repository(IReadWriteRepository repo) : IReadWriteRepository
{
   public IEnumerable<TEntity> Get<TEntity>() where TEntity : BaseEntity
   {
      // implementation
   }

   public void Add<TEntity>() where TEntity : BaseEntity
   {
      // implementation
   }

   // rest of CRUD method implementations
}

Then when registering services, we register two instances of the Repository class, but with different interface definitions using the factory pattern:

// Read write repo
services.AddScoped(typeof(IReadWriteRepository, p => new Repository(p.GetRequiredService<DbContext>));

// Read only repo
services.AddScoped(typeof(IReadOnlyRepository, p => new Repository(p.GetRequiredService<DbContext>));

Question is, will the second registration still only have access to the Get() method of IReadOnlyRepository even if we register it as the base type with method definitions for all the CRUD methods? If so, what happens to the CRUD methods in this case? Are they just not included in the read only version of Repository?


Solution

  • I explained why this is an antipattern in the comments - this code wraps a high-level Unit-of-Work with a low-level CRUD class, not a Repository. The Repository class itself is still writeable and someone can just cast IReadOnlyRepository to IReadWriteRepository or even directly to Repository and start deleting. It's as simple as :

    (_myReadOnlyRepo as Repository).Delete(something);
    

    Even worse, the objects returned by IReadOnlyRepository<TEntity>.Get will be fully tracked, which means any change made to them could get persisted, even without casting. If a Controller has both an IReadOnlyRepository and a IReadWriteRepository dependency then both of them will use the same DbContext instance. Someone could eg load blog posts from the "read-only" repo, make modifications to them and then update votes in the other repository. That Update would also persist the changes to the blog posts.

    DbContext is a Unit-of-Work that detects changes to all the objects it tracks, so the way to make it read-only is to disable change tracking. This is possible either for individual queries with AsNoTracking() or for the entire DbContext with a call to DbContextOptionsBuilder.UseQueryTrackingBehavior() during configuration.

    In a proper Domain Repository someone could disable at just the query level, eg :

    public Blog GetBlogPostsForRendering(int blogId,DateOnly since)
    {
        var blogAndPosts = _context.Blog.AsNoTracking()
                                   .Include(b=>b.Posts.Where(p=>p.Created>=since))
                                   .ToList();
        return blogAndPosts;
    }
    

    In the question's case, the entire DbContext instance has to be made read-only. This can be done with UseQueryTrackingBehavior eg :

    services.AddScoped<IReadOnlyRepository>(p => {
        var contextOptions = new DbContextOptionsBuilder<MyContext>()
            .UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=Test")
            .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking)
            .Options;
        var context=new MyContext(options);
        return new Repository(context);
    });
    

    To avoid duplication the common configuration options can be extracted to a separate method :

    DbContextOptionsBuilder<MyContext> ContextConfiguration(
        IServiceProvider services,
        bool readonly)
    {
      var config=services.GetRequiredService<IConfiguration>();
      var cns=config.GetConnectionString("mydb");
      var builder=new DbContextOptionsBuilder<MyContext>()
            .UseSqlServer(cns)
            ...;
      if(readonly)
      {
          builder=builder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
      }
      return builder;
    }
    
    ...
    services.AddScoped<IReadOnlyRepository>(p => {
        var contextOptions = ContextConfiguration(p,true).Options;
        var context=new MyContext(options);
        return new Repository(context);
    });