Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make it easier for library authors to integrate with an existing DbContext #30061

Open
bachratyg opened this issue Jan 13, 2023 · 1 comment
Open

Comments

@bachratyg
Copy link

bachratyg commented Jan 13, 2023

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:

optionsAction?.Invoke(applicationServiceProvider, builder);

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:

Dependencies.ModelCustomizer.Customize(modelBuilder, context);

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:

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 (Obsolete IModelCustomizer #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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants