Make it easier for library authors to integrate with an existing DbContext
Summary
This proposes introducing extension points resolved from the app service provider to configure the DbContext similar to how IConfigureOptions works. Currently if a feature builds heavily on the DbContext and needs multiple components to work (interceptors, models, translators and custom services, see examples below) then multiple extension methods have to be called from several locations from user code to configure the feature properly. With this proposal the feature can be authored in such a way that user code only ever needs to call a single method.
Proposed solution
Current state of affairs
The user would need to call multiple methods to add all aspects of the feature to the app:
// User code
builder.Services.AddDbContext<AppDb>(opts =>
{
// ...
opts.AddFeatureExtensions(); // 1
});
builder.Services.AddFeatureServices<AppDb>(); // 2
class AppDb : DbContext
{
public AppDb(DbContextOptions<AppDb> opts) : base(opts) { }
protected override void OnModelCreating(modelBuilder)
{
// ...
modelBuilder.AddFeatureModels(); // 3
}
}
// 3rd party code
public static IServiceCollection AddFeatureServices<TContext>(this IServiceCollection services) where TContext : DbContext { ... }
public static DbContextOptionsBuilder AddFeatureInterceptorsAndTranslators(this DbContextOptionsBuilder opts) { ... }
public static ModelBuilder AddFeatureModels(this ModelBuilder modelBuilder) { ... }
Proposed API surface
public static IServiceCollection ConfigureDbContext<TContext>(this IServiceCollection services, Action<DbContextOptionsBuilder> optionsAction) where TContext : DbContext;
public static IServiceCollection ConfigureDbModel<TContext>(this IServiceCollection services, Action<ModelBuilder, DbContext> optionsAction) where TContext : DbContext;
Additional methods/supporting classes may be exposed for convenience.
Simplified user code with the new API
The user only needs a single line of code to start using the feature.
// User code
builder.Services.AddDbContext<AppDb>(opts => ...);
builder.Services.AddFeature<AppDb>(); // 1
class AppDb : DbContext
{
public AppDb(DbContextOptions<AppDb> opts) : base(opts) { }
}
// 3rd party code
public static IServiceCollection AddFeature<TContext>(this IServiceCollection services) where TContext : DbContext
{
builder.AddFeatureServices<AppDb>(); // possibly inlined
builder.Services.ConfigureDbContext<AppDb>(opts =>
{
opts.AddFeatureInterceptorsAndTranslators(); // possibly inlined
});
builder.Services.ConfigureDbModel<AppDb>((modelBuilder, db) =>
{
modelBuilder.AddFeatureModels(); // possibly inlined
});
}
Key implementation points
For brevity I'm only indicating the key points of change and not the related boilerplate.
Configuring the context options is straightforward. Add a few lines here: https://github.com/dotnet/efcore/blob/adf3a1309775f655fd4596237c2f7bb58961661e/src/EFCore/Extensions/EntityFrameworkServiceCollectionExtensions.cs#L1068
optionsAction?.Invoke(applicationServiceProvider, builder);
foreach( var action in applicationServiceProvider.GetServices<IConfigureDbContext<TContext>>() )
action(applicationServiceProvider, builder);
Configuring the model is a bit more involved. The following service/implementation instance should be added to the internal service provider:
interface IModelCustomizers
{
void Customize(ModelBuilder modelBuilder, DbContext context);
}
class ModelCustomizers<TContext> : IModelCustomizers
{
private readonly IEnumerable<IConfigureDbModel<TContext>> _actions;
public ModelConfigurator(IEnumerable<IConfigureDbModel<TContext>> actions) { _actions = actions; }
public void Customize(ModelBuilder modelBuilder, DbContext context)
{
foreach( var action in _actions )
action(modelBuilder, context);
}
}
#8710 should be helpful here.
Add as dependency to ModelSource and execute the actions here: https://github.com/dotnet/efcore/blob/adf3a1309775f655fd4596237c2f7bb58961661e/src/EFCore/Infrastructure/ModelSource.cs#L105
Dependencies.ModelCustomizers.Customize(modelBuilder, context);
Risks
Should be minimal considering there would be no behavioral change if the API is not used.
The existing IModelCustomizer could be reused for IModelCustomizers seeing as it is going away (#29533) but that could be a breaking change.
Examples
A reusable implementation of the outbox pattern
In microservice world this is a common pattern for implementing reliable data exchange between services. Here's an overview for those unfamiliar with it: https://learn.microsoft.com/en-us/azure/architecture/best-practices/transactional-outbox-cosmos#overview
The pattern has the following key requirements:
- The outbox message should be persisted within the business transaction that trigges it, preferably with little overhead
- A background processor should dispatch the message outside the business transaction, preferably as soon as possible
For saving the message the ideal solution is to piggyback the message on the same SaveChanges that persists the rest of the data. This way SaveChanges takes care of the atomic insert and the user does not need to manage a transaction scope. With batch commands this doesn't even need a separate roundtrip to the db.
A message could be added manually (e.g. user calls a feature-provided method) or automatically (e.g. reacting to an entity change). In the latter case the feature needs to register an interceptor to hook SavingChanges and process the change tracker.
The context obviously needs to be made aware of the message model somehow, then the entities can be managed via db.Set<TModel>(). It's the author's decision to expose the model to the user (make entity classes public) or not.
The background processor that fetches and dispatches the outgoing messages can be implemented as a BackgroundService added to the app service provider. A naive implementation would periodically poll the database but this would cause unnecessary delay or excess load. For better reaction time the dispatch should be triggered as the transaction completes and the message becomes visible in the db. This can be detected by hooking SaveChanges and optionally (if there's an outstanding transaction) hook Transaction.TransactionCompleted (for System.Transactions) or register IDbTransactionInterceptor (for context-managed transactions). #20273 would help with this, but it is also possible without.
To summarize the complete feature needs to register the following
- new model
- interceptors
- app services unrelated to the DbContext
Entity history made easy
It's a common requirement in business world to keep a history of changes made to an entity. This is somewhat similar to the outbox pattern with the distinction that it does not dispatch the "message". This would need the following:
- new model: for the history records
- interceptors: to react to SaveChanges and create the history records as necessary
- app services: optionally provide controllers etc to make the history queryable and expose it to the end-user
Tenants
Implementing a tenant-aware context would require the following
- model customizations: query filters to filter marked entities based on the current tenant
- interceptors: hook SaveChanges to set or validate tenant-specific entity properties
- app services: a tenant accessor to determine the current tenant e.g. from a request cookie
Again, a similar set of things to add
Improvements to Microsoft.AspNetCore.Identity.EntityFrameworkCore
Disclaimer: definitely not my call to change anything here.
To use an EF store (AddEntityFrameworkStores<TContext>) you currently have two choices:
- Derive from
IdentityDbContextThis will add and configure the necessary models and set you up completely. The drawback is you can only use a single base class, multiple features cannot be composed to the context this way. It's also easy to forget calling base.OnModelCreating which can lead to unexpected usage. - Add the necessary model to your own context manually This is very very very far from trivial, see https://github.com/dotnet/aspnetcore/blob/dfff223c75fb2a19b3a0596a7115960c4e6f5983/src/Identity/EntityFrameworkCore/src/IdentityUserContext.cs#L118
With this new API ANC.Id.EF could simply hook into an arbitrary context of the user's choice
Additional notes
- Should there be an extension point for configuring the concrete DbContext instance (i.e. to hook DbContext.OnConfiguring)?
- Configuring the model from the
optionsAction/OnConfiguringmethod is actually possible right now by replacing theIModelCustomizerservice. However this interface is being considered for deprecation (#29533). Also only a single feature could use it without conflicting with others. - Should probably provide convenience methods that include the app service provider and a design-time flag.
Possibly related issues
- #22961
- #20273
- #8710
- https://github.com/dotnet/efcore/issues/21578#issuecomment-693685580
- #29597