Provide a simpler mechanism for requesting alternative DbContext configurations when using DI
Background/motivation
Azure SQL Database offers read-only geo-replication of databases. To utilise a read-only replica, the connection string used when a DataContext is initialised must be different from that used to for the primary (read/write) database.
You generally want this configured for the current scope - a GET endpoint can use a read-only connection, while add or update operations clearly cannot.
Suggestions for how to do this when using DI include create two DbContext types (e.g. MyDbContext and MyReadOnlyDbContext) and adapting the configuration code based on something it can retrieve from the service provider.
Creating an additional DbContext type adds to memory consumption and startup time (and I guess there are probably further inefficiencies with this approach?).
To pass through an option to influence configuration you therefore have to have an object in DI (accessible in the same scope) that is set before the DbContext is requested from the service provider. Your configuration code can then retrieve that object and adjust configuration accordingly. If your DbContext has a scoped lifetime, this configuration must take place before the first request for an instance from the service provider.
This is not as straightforward for EF users as it could be.
Proposal
Provide built-in functionality for obtaining a DataContext with a specific configuration when using DI.
One option might be something like the following (similar to how IHttpClientFactory offers access to named configurations for HttpClients):
public class DbContextConfiguration<TContext> : IDbContextConfiguration<TContext>
where TContext : DbContext
{
public string? ConfigurationName { get; set; }
}
public sealed partial class DbContextProvider : IDbContextProvider
{
private readonly IServiceProvider _serviceProvider;
public DbContextProvider(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public TDbContext GetDbContext<TDbContext>(string? configName = null)
where TDbContext : DbContext
{
var selector = _serviceProvider.GetRequiredService<IDbContextConfiguration<TDbContext>>();
selector.ConfigurationName = configName;
return _serviceProvider.GetRequiredService<TDbContext>();
}
}
which can be used in configuration:
_ = builder.Services.AddDbContext<MyDbContext>((provider, optionsBuilder) =>
{
var dbContextSelector = provider
.GetRequiredService<IDbContextConfiguration<MyDbContext>>();
var isReadOnly = dbContextSelector.ConfigurationName == DbContextConfigurationNames.ReadOnly;
string connection;
if (isReadOnly)
{
if (useSecondaryForReadOnly && !string.IsNullOrWhiteSpace(secondaryDbConnectionStr))
{
connection = secondaryDbConnectionStr;
}
else
{
connection = primaryDbConnectionStr;
}
}
else
{
connection = primaryDbConnectionStr;
}
and then in your application:
public class MyCommandHandler
{
private readonly MyDbContext _dbContext;
public MyCommandHandler (IDbContextProvider dbContextProvider)
{
_dbContext = dbContextProvider.GetDbContext<MyDbContext>(DbContextConfigurationNames.ReadOnly);
}
You can also inject the configuration option into your DbContext to configure behaviour there:
public sealed class MyDbContext : StorageModelDbContext
{
private readonly IDbContextConfiguration<CataloguesDbContext> _DbContextConfiguration;
public CataloguesDbContext(DbContextOptions< MyDbContext > options,
IDbContextConfiguration< MyDbContext > DbContextConfiguration) : base(options)
{
_DbContextConfiguration = DbContextConfiguration;
}
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
if (_DbContextConfiguration.ConfigurationName == DbContextConfigurationNames.ReadOnly)
{
throw new InvalidOperationException("Cannot save changes to a read-only context.");
}
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
Alternative
Perhaps more complicated, but more flexible, you could pass a configuration object through from application code - rather than just a name - for example:
public class MyDbContextConfiguration : IDbContextConfiguration<MyDbContext>
{
public bool ReadOnly { get; init; }
public IExecutionStrategy? DefaultExecutionStrategy { get; init }
}
and then in configuration:
_ = builder.Services.AddDbContext<MyDbContext>((provider, optionsBuilder) =>
{
var dbContextSelector = provider
.GetRequiredService<MyDbContextConfiguration<MyDbContext>>();
_ = optionsBuilder.UseSqlServer(connection, sqlOptionsBuilder =>
{
if (dbContextSelector.DefaultExecutionStrategy is null)
{
sqlOptionsBuilder.EnableRetryOnFailure();
}
else
{
sqlOptionsBuilder.ExecutionStrategy(dbContextSelector.DefaultExecutionStrategy);
}
});
and then in your application:
public class MyCommandHandler
{
private readonly MyDbContext _dbContext;
public MyCommandHandler (IDbContextProvider dbContextProvider)
{
var selector= new MyDbContextConfiguration { ReadOnly = true, DefaultExecutionStrategy = new MyExecutionStrategy() }
_dbContext = dbContextProvider.GetDbContext<MyDbContext>(selector);
}
(Actually, you could support named configurations retrieved from DI and configuration instances.)
Disadvantages
- Adds another choice to consider when configuring and initialising DbContexts
- Might be confused with DbContextFactory
@frankbuckley What determines which configuration to use? For example, what determines which ConfigurationName is set when the object is resolved from D.I.? Can you show an example of the code that does this?
@frankbuckley What determines which configuration to use? For example, what determines which
ConfigurationNameis set when the object is resolved from D.I.? Can you show an example of the code that does this?
@ajcvickers First, a DbContextConfiguration<TContext> is added to DI:
// in configuration
services.AddDbContextProvider();
// implemented as...
public static IServiceCollection AddDbContextProvider<TContext>(
this IServiceCollection services,
ServiceLifetime contextLifetime = ServiceLifetime.Scoped)
where TContext : DbContext
{
services.TryAdd(new ServiceDescriptor(
typeof(IDbContextProvider),
typeof(DbContextProvider),
contextLifetime));
services.TryAdd(new ServiceDescriptor(
typeof(IDbContextConfiguration<TContext>),
typeof(DbContextConfiguration<TContext>),
contextLifetime));
return services;
}
Then the provider ensures the name is set on the IDbContextConfiguration<TDbContext> instance that is in scope just before the request for the DbContext.
public sealed partial class DbContextProvider : IDbContextProvider
{
private readonly IServiceProvider _serviceProvider;
public DbContextProvider(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public TDbContext GetDbContext<TDbContext>(string? configName = null)
where TDbContext : DbContext
{
var selector = _serviceProvider.GetRequiredService<IDbContextConfiguration<TDbContext>>();
selector.ConfigurationName = configName;
return _serviceProvider.GetRequiredService<TDbContext>();
}
}
This allows the configuration action can access the value when the DbContext is being configured (which was the original problem I was trying to solve):
_ = builder.Services.AddDbContext<MyDbContext>((provider, optionsBuilder) =>
{
var dbContextSelector = provider.GetRequiredService<IDbContextConfiguration<MyDbContext>>();
var isReadOnly = dbContextSelector.ConfigurationName == DbContextConfigurationNames.ReadOnly;
// ...
At first, I tried to keep DbContextConfiguration hidden away as an implementation detail - just to carry the configuration name. However, I then realised it would be handy in some situations to be able to get the configuration currently in scope injected into the DbContext - for example, to enforce read-only behaviour.
There are some potential pitfalls. You want the lifetime of the configuration object tied to to the lifetime of the DbContext - both scoped, or both transient.
One might imagine scenarios where users might wish to provide their own IDbContextConfiguration<TDbContext> implementation - perhaps a multi-tenanted app would want to pass a user id with the configuration name?
@frankbuckley I'm missing why you can't just do normal dependency injection for this? For example, a config:
public interface IDbContextConfiguration
{
bool IsReadOnly { get; set; }
string? ConnectionString { get; set; }
}
public class DbContextConfiguration : IDbContextConfiguration
{
public bool IsReadOnly { get; set; }
public string? ConnectionString { get; set; }
}
Register it in D.I.:
var container = new ServiceCollection()
.AddScoped<IDbContextConfiguration, DbContextConfiguration>()
.AddDbContext<SomeDbContext>()
.BuildServiceProvider();
Inject it and use it in your context:
public class SomeDbContext : DbContext
{
private readonly IDbContextConfiguration _configuration;
public SomeDbContext(IDbContextConfiguration configuration)
{
_configuration = configuration;
}
public DbSet<Blog> Blogs => Set<Blog>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder
.UseSqlServer(_configuration.ConnectionString!)
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging();
}
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
if (_configuration.IsReadOnly)
{
throw new InvalidOperationException("Cannot save changes to a read-only context.");
}
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
}
Full example:
public class Blog
{
public int Id { get; set; }
}
public class Program
{
public static async Task Main()
{
var container = new ServiceCollection()
.AddScoped<IDbContextConfiguration, DbContextConfiguration>()
.AddDbContext<SomeDbContext>()
.BuildServiceProvider();
using var scope = container.CreateScope();
var config = scope.ServiceProvider.GetService<IDbContextConfiguration>()!;
config.ConnectionString = @"Data Source=(LocalDb)\MSSQLLocalDB;Database=AllTogetherNow";
config.IsReadOnly = true;
var context = scope.ServiceProvider.GetService<SomeDbContext>()!;
await context.Database.EnsureDeletedAsync();
await context.Database.EnsureCreatedAsync();
await context.SaveChangesAsync();
}
}
public class SomeDbContext : DbContext
{
private readonly IDbContextConfiguration _configuration;
public SomeDbContext(IDbContextConfiguration configuration)
{
_configuration = configuration;
}
public DbSet<Blog> Blogs => Set<Blog>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder
.UseSqlServer(_configuration.ConnectionString!)
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging();
}
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
if (_configuration.IsReadOnly)
{
throw new InvalidOperationException("Cannot save changes to a read-only context.");
}
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
}
public interface IDbContextConfiguration
{
bool IsReadOnly { get; set; }
string? ConnectionString { get; set; }
}
public class DbContextConfiguration : IDbContextConfiguration
{
public bool IsReadOnly { get; set; }
public string? ConnectionString { get; set; }
}
You could do this. I think there are good reasons for not configuring in OnConfiguring. You often want context (e.g. current environment) when configuring the app, and may want to configure differently for unit and integration testing, etc.
However, the core problem that led me to consider a DbContextProvider akin to IHttpClientFactory is keeping configuration away from the consumer, beyond some well-defined named sets/characteristics.
Consider your example transposed to an API app. You would end up with something like this in each endpoint:
app.MapGet("/{id:int}", (int id, IServiceProvider provider) =>
{
var config = provider.GetService<IDbContextConfiguration>()!;
config.ConnectionString = @"Data Source=(LocalDb)\MSSQLLocalDB;Database=AllTogetherNow";
config.IsReadOnly = true;
var context = provider.GetService<SomeDbContext>()!;
return context.Blogs.FirstOrDefault(b => b.Id == id);
});
I want a way for a consumer to communicate, "give me a context (of this type) that is read-only" but then leave the configuration choices (do we pick a connection string for a geo-replica, or use the primary replica because it is closer to this cluster, do we enforce read-only with interceptors and/or a derived context , etc.) to configuration.
To do this you need to move the 'getting' of the context into a DbContextProvider so all the endpoint does is:
app.MapGet("/{id:int}", (int id, IDbContextProvider dbProvider) =>
{
var context = dbProvider.GetContext<SomeDbContext>("read_only");
return context.Blogs.FirstOrDefault(b => b.Id == id);
});
The DbContextProvider can only 'communicate' with configuration (whether in app startup or DbContext.OnConfiguring()) by having a known object in DI (with the same scope as the context) that can carry information - therefore you need IDbContextConfiguration. (This was your recommendation in https://github.com/dotnet/efcore/issues/9616#issuecomment-326065181 and I cannot see an alternative.)
Given the above, and with an additional AddDbContext extension method, you can then easily pass configuration parameters from consumer to configuration (and/or DbContext):
builder.Services.AddDbContext<SomeDbContext>((provider, options, config) =>
{
if (config.Name == "read_only")
{
}
else
{
}
});
@frankbuckley Still not seeing it. Its fine to configure some things in AddDbContext and others in OnConfiguring if needed.
To be fair, if you want to build something like this that you ship as an external package, then go for it. But it doesn't seem like something that should be built in to EF Core.
@frankbuckley Still not seeing it. Its fine to configure some things in
AddDbContextand others inOnConfiguringif needed.
Yes, but that does not help the consumer (endpoint, handler, etc.) of the DbContext communicate "it can be read only", "it should use the database for this tenant", etc (with minimal ceremony and without ending up with configuration logic all over the place). To do that you need to be able to pass some value from the consumer to the configuration code (wherever that might be) - just like you might say "give me an HttpClient configured appropriately to talk to Stripe" (var client = httpClientFactory.GetClient("stripe")) it is useful to be able to say give me a DbContext configured for xxx.
To be fair, if you want to build something like this that you ship as an external package, then go for it. But it doesn't seem like something that should be built in to EF Core.
Fair enough - I will see if I can extract what we have done and publish it in some way.
I'm certainly not the only person who has hit issues in this area, so it might be helpful for others:
- https://github.com/dotnet/efcore/issues/6525#issuecomment-624754900
- https://stackoverflow.com/a/36840901
- https://stackoverflow.com/a/60033840
- https://stackoverflow.com/a/55907102
- https://gavilan.blog/2020/07/09/configuring-entity-framework-core-with-dynamic-connection-strings-asp-net-core/
- https://www.google.com/search?q=entity+framework+core+change+connection+runtime+dependency+injection+site:stackoverflow.com
@frankbuckley Note that you can register the DbContextOptions as scoped. (In the AddDbContext call.) This means the configuration delegate will be executed for every request and can configure a connection string that is specific to that request. This seems to cover the cases you reference.
@ajcvickers You could do this – if (say) you wanted to apply a policy that all GET requests get a read-only context, but all POST requests get a read/write context – or (say) if you have something in the URL that identifies the user of a multitenant app with a database per user.
However, this strikes me as a bit fragile. You would be duplicating binding logic in the DbContext configuration action. Your endpoint binding is concerned with interpreting the request (GET/POST, path, query string, etc.) and binding that to values in your code (parameters, deserializing POST bodies, etc.). You probably do not want to be trying to also do that in the DbContext configuration action. Better to let your bindings do it (as they will anyway) and then pass the relevant value(s) from your endpoint.
Let’s say you have a GET endpoint that requires write access – perhaps to log something about a request. Now your configuring action must handle a special case. And so on…
Maybe I’m missing something, but it seems to me the fundamental problem remains – how can the DbContext user (endpoint, command handler, etc.) communicate something like “a read only replica is OK in this scope” to the configuring action such that DbContext configuration can be adjusted accordingly (when using DI and with the minimum of fuss)?
There are many different ways of doing this. Which one is best will depend on the specific situation. I certainly don't think something as complex as you are suggesting is necessary, or even a good fit, for most cases. The combination of D.I., factories, AddDbContext, and OnConfiguring can be used in many ways, most of them in quite a simple way. If anything, this conversation and the links you provided indicate that making people aware of how the existing features work is perhaps the most useful thing we could do.