CQRS db Contexts with .NET Core

We're developing a new app using .NET Core 2.2 and it's time for some redesign. When it came to EF db Contexts we wanted to separate our Reads from our Writes (a la CQRS) more explicitly. We needed to work with the same entities in both BUT we still wanted to optimize performance with Reads and customize Writes separately so below is a quick and easy implementation.

Base Context

Setup a Base Context class that you will inherit from; you can either inherit from DbContext or in this case IdentityDbContext since we're also leveraging Identity Core.

public abstract class BaseContext<TContext> : IdentityDbContext<User> where TContext : DbContext
{
 protected BaseContext(DbContextOptions<TContext> options) : base(options)
 {
 }

 protected override void OnModelCreating(ModelBuilder modelBuilder)
 {
  base.OnModelCreating(modelBuilder);
 }

 // TODO add DbSet<> for each entity/table (as needed)
 public DbSet<Audit> Audit { get; set; }
}

Read and Write Contexts

Setup both a Read and a Write Context that inherit from the Base Context above. In the Read Context you override SaveChangesAsync to avoid any write actions so it's dedicated for read actions only.


public class ReadContext : BaseContext<ReadContext>
{
 public ReadContext(DbContextOptions<ReadContext> options) : base(options)
 {
 } 

 public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default(CancellationToken))
 {
  throw new InvalidOperationException("This context is read-only.");
 }
}

In the Write Context, you can add any custom logic that will only be applicable to write actions such as create, update, and delete e.g. setting the Created Date field globally if you have standard audit fields on your db tables.


public class WriteContext : BaseContext<WriteContext>
{
 public WriteContext(DbContextOptions<WriteContext> options) : base(options)
 {
 }
 
 public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default(CancellationToken))
 {
  // TODO add custom logic
  return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
 }
}

Startup

In Startup.cs, you then setup both contexts and configure each of them separately, for example you can globally enforce NoTracking for the Read Context to improve performance.

public void ConfigureServices(IServiceCollection services)
{
 ...
 
 var connectionString = Configuration.GetConnectionString("DefaultConnection");
 services.AddDbContext<ReadContext>(options =>
  {
   options.UseSqlServer(connectionString);
   options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
  });
 services.AddDbContext<WriteContext>(options => options.UseSqlServer(connectionString));
 
 ...
}

Extra

If your app is large enough to justify Bounded Contexts then you may need an additional level of hierarchy of abstract classes with a limited set of entities i.e. DbSet(s) and multiple corresponding Write (and possibly Read) Contexts.

Comments

Popular posts from this blog

ASP.NET Identity Remember Me

ASP.NET MVC - How to enable/disable CaC/Client Certificate authentication per area or route.

IIS Express Client Certificates