Make it easier for library authors to integrate with an existing DbContext
- Dominant language
- C#
- Stars
- 14.8k
- Forks
- 3.4k
- PR merge metrics
- PR metrics pending
Description
## 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:
``` c#
// User code
builder.Services.AddDbContext(opts =>
{
// ...
opts.AddFeatureExtensions(); // 1
});
builder.Services.AddFeatureServices(); // 2
class AppDb : DbContext
{
public AppDb(DbContextOptions opts) : base(opts) { }
protected override void OnModelCreating(modelBuilder)
{
// ...
modelBuilder.AddFeatureModels(); // 3
}
}
// 3rd party code
public static IServiceCollection AddFeatureServices(this IServiceCollection services) where TContext : DbContext { ... }
public static DbContextOptionsBuilder AddFeatureInterceptorsAndTranslators(this DbContextOptionsBuilder opts) { ... }
public static ModelBuilder AddFeatureModels(this ModelBuilder modelBuilder) { ... }
```
### Proposed API surface
```c#
public static IServiceCollection ConfigureDbContext(this IServiceCollection services, Action optionsAction) where TContext : DbContext;
public static IServiceCollection ConfigureDbModel(this IServiceCollection services, Action 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.
```c#
// User code
builder.Services.AddDbContext(opts => ...);
builder.Services.AddFeature(); // 1
class AppDb : DbContext
{
public AppDb(DbContextOptions opts) : base(opts) { }
}
// 3rd party code
public static IServiceCollection AddFeature(this IServiceCollection services) where TContext : DbContext
{
builder.AddFeatureServices(); // possibly inlined
builder.Services.ConfigureDbContext(opts =>
{
opts.AddFeatureInterceptorsAndTranslators(); // possibly inlined
});
builder.Services.ConfigureDbModel((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
```c#
optionsAction?.Invoke(applicationServiceProvider, builder);
foreach( var action in applicationServiceProvider.GetServices>() )
action(applicationServiceProvider, builder);
```
Configuring the model is a bit more involved. The following service/implementation instance should be added to the internal service provider:
```c#
interface IModelCustomizers
{
void Customize(ModelBuilder modelBuilder, DbContext context);
}
class ModelCustomizers : IModelCustomizers
{
private readonly IEnumerable> _actions;
public ModelConfigurator(IEnumerable> 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
```c#
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()`. 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`) you currently have two choices:
* Derive from `IdentityDbContext`
This 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`/`OnConfiguring` method is actually possible right now by replacing the `IModelCustomizer` service. 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
Contributor guide
Assessment
This issue has not been assessed yet.