AWS RDS Postgres, npgsql and efcore - Part 1 - ReadOnly DbContext
This is the first part in a series on using DotNet Core with Amazon RDS for Postgres. There is a github repo with a sample project that I’ll be pulling code samples from.
This series will cover:
- EfCore read-only Database Context (this post)
- Configuring a local dev Postgres instance to emulate RDS
- Authenticating to RDS using IAM
- RDS failure modes with DotNet
DbContext
gotchas withIHostedService
and dependency injection
Part 1 - EfCore read-only database context
When using Amazon Aurora RDS you typically configure your database cluster to have two or more nodes. If something bad were to happen to one of the nodes RDS automatically fails over to one of the remaining healthy nodes.
Now you have a resilient database, but you are paying for servers that are sitting idle. Designing your application so you can leverage the read-only replicas for running queries means you have a more resilient and scalable application than if you only make use of the master read-write node.
EfCore doesn’t make it easy to change the connection string in a DbContext
, and exposing the same DbContext
in both read-only and read-write modes will confuse your team, and they will likely default to using the read-write context as it does everything.
A DbContext
typically has one or more DbSet<Table>
representing your relations. In a read-only situation, it makes no sense to use DbSet
, as an IQueryable
expresses your read-only intent in a much clearer way.
Configuring DbContext
In this example I use an abstract base DbContext
. From this base there are three concrete types that have little in the way of implementation, they serve only to allow the DI container to configure each with a different connection string.
Abstract DemoDbContext
-> MigrationDemoDbContext
-> ReadWriteDemoDbContext
-> ReadOnlyDemoDbContext
You could also choose to set this up by combining the Migration and ReadWrite contexts, then inheriting that to get the ReadOnly.
DemoDbContext
-> ReadOnlyDemoDbContext
The interesting part is defining the interfaces that each has. When using dependency injection you don’t depend on the concrete type, but on an interface it implements.
Read-write DbContext
This exposes the essential parts of your DbContext
to your application. You need a DbSet
for each relation you have and the usual SaveChanges()
so you can commit your changes to the DB. This should look like every other DbContext
interface you’ve seen before.
interface IDemoReadWriteDbContext {
DbSet<Customer> Customers { get; }
int SaveChanges(bool acceptAllChangesOnSuccess);
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default);
}
Read-only DbContext
In the read-only context we expose the Customer
entity not as a DbSet
, but an IQueryable
. Now when you inject this interface into your app your intent to only allow queries is much clearer.
interface IDemoReadOnlyDbContext {
IQueryable<Customer> Customers { get; }
}
You may have spotted that both read-only and read-write interfaces have a property named Customers
but with differing and conflicting types. This means when you come to implement the Interface and inherit the DbContext
you’ll have a warning.
‘DemoReadOnlyDbContext.Customers’ hides inherited member ‘DemoDbContext.Customers’. Use the new keyword if hiding was intended.
To resolve this you should implement the interface explicitly.
Alternatively, as the error suggests, you could use the new
keyword to hide the base implementation but this not as convenient to use when it comes to testing.
public class DemoReadOnlyDbContext : DemoDbContext, IDemoReadOnlyDbContext {
IQueryable<Customer> IDemoReadOnlyDbContext.Customers => base.Customers.AsQueryable();
}
Now when you inject IDemoReadOnlyDbContext
it will use the IQueryable
as intended. The IQueryable
is obtained from the inherited DbContexts
DbSet
var ctx = new DemoReadOnlyDbContext();
ctx.Customer... // <-- this is DbSet
var ctx = (IDemoReadOnlyDbContext) new DemoReadOnlyDbContext() ;
ctx.Customer...// <-- this is IQueryable
Explicitly implementing the interface has the desirable side effect of making it easier to test components that depend on the IDemoReadOnlyDbContext
. We can construct a concrete DemoReadOnlyDbContext
populate any data the test requires into the inherited DbSet
in-memory, without using SaveChanges()
, then cast it to IDemoReadOnlyDbContext
to query that data in our test.
Disabling Tracking
For read only queries, there is no need for entity framework to be tracking the objects you select and holding them in memory. Tracking allows Entity Framework to be able to write changes back to the database when you SaveChanges()
. Thanks to Julie Lerman for point this out in the comments.
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) {
base.OnConfiguring(optionsBuilder);
optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}
You can also disable tracking on a per-table basis with the AsNoTracking()
extension method in other situations outside of the read-only context where you don’t need it.
IQueryable<Customer> IDemoReadOnlyDbContext.Customers => base.Customers.AsNoTracking();
Configuring DbContext for dependency injection
Each DbContext
is configured with a different connection string, targeting either the primary read-wite node, or the read-only replica. However you want all other options for the DbContext
to be the same.
First up we need to grab the connection strings from config
var dbMigrator = configuration.GetConnectionString("DemoDbContextMigrator")
var dbWrite = configuration.GetConnectionString("DemoDbContextReadWrite")
var dbRead = configuration.GetConnectionString("DemoDbContextReadOnly")
Now we are ready to configure the DbContexts in exactly the same way except for the connection string
services.AddEntityFrameworkNpgsql()
.AddDbContext<DemoMigratorDbContext>(ef => ConfigureDbContextOptions(ef, dbMigrator))
.AddDbContext<DemoReadWriteDbContext>(ef => ConfigureDbContextOptions(ef, dbWrite))
.AddDbContext<DemoReadOnlyDbContext>(ef => ConfigureDbContextOptions(ef, dbRead));
// local setup function for our db contexts above
static void ConfigureDbContextOptions(DbContextOptionsBuilder efOptions, string connectionString) {
efOptions.UseNpgsql(connectionString, npgsqlOptions => {
npgsqlOptions.UseNodaTime();
});
}
Now expose the read-only and read-write DbContexts via their interfaces to your application.
services.AddScoped<IDemoReadWriteDbContext>(provider => provider.GetService<DemoReadWriteDbContext>());
services.AddScoped<IDemoReadOnlyDbContext>(provider => provider.GetService<DemoReadOnlyDbContext>());
You can see this code in situ in the sample app.
One critical detail to make this work; The abstract base must have a constructor that takes a non-generic DbContextOptions
. Each concrete DbContext has a constructor that requires a generic DbContextOption<T>
where T
is the type of the DbContext. This object contains the connection string and other configuration. It must be passed to the base to successfully configure a DbContext.
public abstract class DemoDbContext : DbContext {
protected DemoDbContext(DbContextOptions options) : base(options) { }
}
public class DemoReadWriteDbContext : DemoDbContext, IDemoReadWriteDbContext {
public DemoReadWriteDbContext(DbContextOptions<DemoReadWriteDbContext> options)
: base(options)
{ }
}
Wrap up
Using a read-only DbContext means your application will be more scalable and utilize database instances in AWS that might otherwise sit idle. Your application can scale its read load across the RDS cluster meaning you might need less powerful instances saving you money on your AWS bill. Your application will be more resilient to failure and easier to reason about by segregating your read-only and read-write workloads.
I covered how to architect your application to have separate interfaces for read-only and read-write and how to configure your DbContext using the standard DotNet Core services collection.
The code samples in this post are available in full on github https://github.com/williamdenton/AwsRdsPostgresDemo