Click here to Skip to main content
15,886,693 members
Articles / Web Development / ASP.NET / ASP.NET Core

Scalable Scenario to Configuring Entity Framework DbContext

Rate me:
Please Sign up or sign in to vote.
4.91/5 (3 votes)
12 Oct 2020CPOL8 min read 13K   6  
A new way to configure our application with the startup class using a flexible way to implement this configuration
In this post, we will show different ways to configure our application using the ASP.NET CORE 3.x and create a flexible Startup class.

Table of Contents

Introduction

When we are configuring the DbContext in an ASP.NET Core web application, we typically use AddDbContext extension method as follows:

C#
services.AddDbContext<xxxDbContext>(dbContextOptionsBuilder =>
        dbContextOptionsBuilder.UseSqlServer(Configuration.GetConnectionString
        ("The name of the connection string in the configuration file.")
    ));
// Or
services.AddDbContext<xxxDbContext>((serviceProvider, dbContextOptionsBuilder) =>
{
    var service = serviceProvider.GetService<xxx>();
    dbContextOptionsBuilder.UseSqlServer(Configuration.GetConnectionString
    ("The name of the connection string in the configuration file.");
});

If we take a closer look at the parameter of the AddDbContext extension method, we’ll find that is an action, and through an Action, we can encapsulate a method, delegate, in-line delegate, lambda, etc.
In this case, the Action must construct the DbContext options.
What interests us most is configuring the DbContext according to our configuration ‘{environment}settings.json.

How do we implement this scenario and why?

The answer to the question why would we do this:
We want to dramatically simplify and improve the experience for configuring DbContext and make them truly composable, where we can try to create a flexible scenario that automatically configures DbContext so that it can encapsulate a complete feature and provide an instant utility without having to go through various steps on how to manually configure it in different places in the Startup configuration class.

From this point on, we will try to figure out how to implement this scenario and try to simplify all the concepts that we will encounter.

Using the Code

Startup Class

We will try to start with the main class through which we can start configuring the program that we are working on.
It is definitely a Startup class, and I am aware in advance that everyone who writes ASP.NET Core code is familiar with it, and knows what they are doing in detail, but let’s get over it in a simple and quick way to talk.

Each ASP.NET Core application must have its own configuration code to configure the app’s services and to create the app’s request processing pipeline.
We can do this in two different ways:

1. Configure Services without Startup

By calling ConfigureServices and Configure convenience methods on the host builder.

C#
public class Program
{
    public static void Main(string[] args)
    {
        CreateHostBuilder(args).Build().Run();
    }

    public static IHostBuilder CreateHostBuilder
           (string[] args) => Host.CreateDefaultBuilder(args)
        .ConfigureAppConfiguration((hostingContext, config) => { })
        .ConfigureWebHostDefaults(webBuilder =>
        {
            webBuilder.ConfigureServices(services =>
            {
                services.AddControllersWithViews();
                // ...
            }
        )
      .Configure(app =>
      {
          if (env.IsDevelopment())
          {
              app.UseDeveloperExceptionPage();
          }
          else
          {
              // ...
          }
          // ...
      });
    });
}

2. Configure Services with Startup

The Startup class name is a convention by ASP.NET Core, we can give any name to the Startup class.
Optionally, the Startup class has two methods, the ConfigureServices method that tells ASP.NET Core which features are available and the Configure method that tells it how to use it.

C#
public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddRazorPages();
        // ...
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        else
        {
            app.UseExceptionHandler("/Error");
            app.UseHsts();
        }
        //...
    }
}

When we start the ASP.NET Core application, the ASP.NET Core creates a new instance of the Startup class and calls the ConfigureServices method to create its services. Then it calls the Configure method that sets up the request pipeline to handle incoming HTTP requests.

The Startup class is typically specified by calling the WebHostBuilderExtensions.UseStartup<TStartup> method on the host builder:

C#
public class Program
{
    public static void Main(string[] args)
    {
        BuildWebHost(args).Run();
    }

    public static IWebHost BuildWebHost(string[] args)
    {
        WebHost.CreateDefaultBuilder(args)
            .UseStartup<Startup>()
            .Build();
    }
}

Thus, the Startup class in our program will initially look like this:

C#
public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        // ...
        services.AddDbContext<xxxDbContext>(dbContextOptionsBuilder =>
            dbContextOptionsBuilder.UseSqlServer(Configuration.GetConnectionString
            ("The name of the connection string in the configuration file.")
        ));

        //Or

        services.AddDbContext<xxxDbContext>((serviceProvider, dbContextOptionsBuilder) =>
        {
            var service = serviceProvider.GetService<xxx>();
            dbContextOptionsBuilder.UseSqlServer(Configuration.GetConnectionString
            ("The name of the connection string in the configuration file."));
        });
        // ...
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        //...
    }
}

But such a DbContext will be found in small programs and sometimes in medium programs, but in large programs, it will be difficult to manage the program with one DbContext.
We will see that we have to split a single DbContext into multiple DbContext serving the same specific context, and so we will have a group of DbContext that we always want to configure in all development environments, and we have to take that into account, sometimes we also change the entity framework provider.
At first, you will think that the matter is simple and what is to change the ConnectionString and everything will be fine, this is what we will also do, but in a different way, which we will see later, but at this moment, don’t forget what we said earlier that sometimes we change the data Entity Framework Provider.
Let us give an example to illustrate the problem in a test environment. Usually, we need to change the Entity Framework provider to an InMemory provider or a Sqlite provider and in the Sqlite provider, there are two possible scenarios either in InMemory or in System File.
However, these providers are not exclusive but are more commonly used in with SQL.
Now let’s get out of this context and talk about the most important pattern to designing such large DbContext, which is Bounded Context.

Domain-Driven DesignBounded Context

According to Martin Fowler:

Bounded Context is a central pattern in Domain-Driven Design. It is the focus of DDD’s strategic design section which is all about dealing with large models and teams. DDD deals with large models by dividing them into different Bounded Contexts and being explicit about their interrelationships.

According to Julie Lerman:

When you’re working with a large model and a large application, there are numerous benefits to designing smaller, more-compact models that are targeted to specific application tasks, rather than having a single model for the entire solution. In this column, I’ll introduce you to a concept from domain-driven design (DDD) — Bounded Context — and show you how to apply it to build a targeted model with EF, focusing on doing this with the greater flexibility of the EF Code First feature. If you’re new to DDD, this a great approach to learn even if you aren’t committing fully to DDD. And if you’re already using DDD, you’ll benefit by seeing how you can use EF while following DDD practices.

Accordingly to this pattern, we have to split a single DbContext into multiple DbContexts, our new Startup class will become:

C#
public class Startup
{
    // ...
    public void ConfigureServices(IServiceCollection services)
    {
        // ...
        services
            .AddDbContextPool<DbContextA>(dbContextOptionsBuilder =>
                dbContextOptionsBuilder.UseSqlServer(Configuration.GetConnectionString
                ("The name of the connection string in the configuration file.")
            ))
            .AddDbContextPool<DbContextB>(dbContextOptionsBuilder =>
                dbContextOptionsBuilder.UseSqlServer(Configuration.GetConnectionString
                ("The name of the connection string in the configuration file.")
            ))
            .AddDbContextPool<DbContextC>(dbContextOptionsBuilder =>
                dbContextOptionsBuilder.UseSqlServer(Configuration.GetConnectionString
                ("The name of the connection string in the configuration file.")
            ));
        // ...
    }
    // ...
}

DbContextConfigurer Pattern

We will notice from the above that any small change can lead to a major modification in our code.
So now, we’ll use some design patterns and OOP principles and create some classes that will help us facilitate this process.

Options Pattern

To make our application settings more organized, and to provide strongly typed access to groups of related settings, we will use the Options pattern in ASP.NET Core.
We will create two new classes to configure the connection string settings and the entity framework provider settings:

C#
public class ConnectionStringsOptions : IOptions<ConnectionStringsOptions>
{
    public const string KEY_NAME = "ConnectionStringsOptions";
    public ConnectionStringsOptions() : this(null, null, null, null) { }
    public ConnectionStringsOptions(string serverName, string databaseName,
                                    string userId, string password)
    {
        ServerName = serverName;
        DatabaseName = databaseName;
        UserId = userId;
        Password = password;
    }

    public string ServerName { get; set; }
    public string DatabaseName { get; set; }
    public string UserId { get; set; }
    public string Password { get; set; }

    ConnectionStringsOptions IOptions<ConnectionStringsOptions>.Value => this;
}

public static class EntityFrameworkProviders
{
    public static string SqlServer = "SQL-SERVER";
    public static string SQLite = "SQLITE";
    public static string InMemor = "IN-MEMOR";
}

public class EntityFrameworkOptions : IOptions<EntityFrameworkOptions>
{
    public const string KEY_NAME = "EntityFrameworkOptions";
    public EntityFrameworkOptions() : this(EntityFrameworkProviders.SqlServer, true) { }
    public EntityFrameworkOptions(string provider, bool canMigrate)
    {
        Provider = provider;
        CanMigrate = canMigrate;
    }

    public string Provider { get; set; }
    /// <summary>
    /// In some providers, we must not execute migration
    /// </summary>
    public bool CanMigrate { get; set; }

    EntityFrameworkOptions IOptions<EntityFrameworkOptions>.Value => this;
}

To use the current structure, we have to make a little change to our appsettings.json and appsettings.Development.json. This is shown below:

C#
// appsettings.json
{
  "EntityFrameworkOptions": {
    "Provider": "SQL-SERVER",
    "CanMigrate": true
  },
  "ConnectionStringsOptions": {
    "ServerName": "xxx.database.windows.net",
    "DatabaseName": "xxx",
    "UserId": "xxx_Developers",
    "Password": "xxxx-xxx-xxx-xxx"
  }
}
//appsettings.Development.json
{
  "EntityFrameworkOptions": {
    "Provider": "SQLITE",
    "CanMigrate": true
  },
  "ConnectionStringsOptions": {
    "ServerName": null,
    "DatabaseName": "dev.db",
    "UserId": null,
    "Password": null
  }
}

When we need to access the strongly typed settings, we just need to inject an instance of an IOptions<> class into the constructor of our consuming class, and let dependency injection handle the rest:

C#
using System.Collections.Generic;
using ConfigureEFDbContext.Options;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;

namespace ConfigureEFDbContext.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class OptionsPatternController : ControllerBase
    {
        private readonly EntityFrameworkOptions _entityFrameworkOptions;
        private readonly ConnectionStringsOptions _connectionStringsOptions;

        public OptionsPatternController(IOptions<EntityFrameworkOptions>
        entityFrameworkOptions, IOptions<ConnectionStringsOptions> connectionStringsOptions)
        {
            _entityFrameworkOptions = entityFrameworkOptions.Value;
            _connectionStringsOptions = connectionStringsOptions.Value;
        }

        [HttpGet]
        public IEnumerable<string> Get() => new[] { _entityFrameworkOptions.Provider,
                                            _connectionStringsOptions.DatabaseName };
    }
}

DbContext Configurer Factory Pattern

Because we need to provide a high level of flexibility in our code when we configure our DbContext and we need to separate the object’s construction from the objects themselves, we go to using Factory Pattern.

Click to enlarge image

According to Wikipedia

In class-based programming, the factory method pattern is a creational pattern that uses factory methods to deal with the problem of creating objects without having to specify the exact class of the object that will be created. This is done by creating objects by calling a factory method—either specified in an interface and implemented by child classes, or implemented in a base class and optionally overridden by derived classes—rather than by calling a constructor.

The IDbContextConfigurerFactory is the Factory interface and the DbContextConfigurerFactory is the implementation for the Factory.

C#
using System;
using System.Collections.Generic;
using System.Reflection;
using ConfigureEFDbContext.EFProviderConnectionOptions;
using ConfigureEFDbContext.Options;
using Microsoft.Extensions.Options;

namespace ConfigureEFDbContext
{
    public interface IDbContextConfigurerFactory
    {
        IDbContextConfigurer GetConfigurer(string migrationsAssembly = null);
    }

    public class DbContextConfigurerFactory : IDbContextConfigurerFactory
    {
        public DbContextConfigurerFactory(IOptions<EntityFrameworkOptions> options,
        IEntityFrameworkProviderConnectionOptions dbProviderConnectionOptions)
        {
            EntityFrameworkOptions = options.Value;
            Factories = new Dictionary<string, Func<string, IDbContextConfigurer>>() {
                {EntityFrameworkProviders.SqlServer, (migrationsAssembly) =>
                 CreateSqlServerSetup(dbProviderConnectionOptions, migrationsAssembly)},
                {EntityFrameworkProviders.SQLite, (migrationsAssembly) =>
                 CreateSqliteSetup(dbProviderConnectionOptions, migrationsAssembly)},
                {EntityFrameworkProviders.InMemor, (migrationsAssembly) =>
                 CreateInMemorySetup(dbProviderConnectionOptions, migrationsAssembly)},
            };
        }

        protected EntityFrameworkOptions EntityFrameworkOptions { get; }
        protected Dictionary<string, Func<string, IDbContextConfigurer>> Factories { get; }

        public virtual IDbContextConfigurer GetConfigurer(string migrationsAssembly = null)
                       => Factories.ContainsKey(EntityFrameworkOptions.Provider)
            ? Factories[EntityFrameworkOptions.Provider]
            (migrationsAssembly ?? Assembly.GetCallingAssembly().GetName().Name)
            : default;

        protected virtual IDbContextConfigurer CreateSqlServerConfigurer
        (IEntityFrameworkProviderConnectionOptions dbProviderConnectionOptions,
        string migrationsAssembly) => new SqlServerDbContextConfigurer
        (dbProviderConnectionOptions, migrationsAssembly);
        protected virtual IDbContextConfigurer CreateSqliteConfigurer
        (IEntityFrameworkProviderConnectionOptions dbProviderConnectionOptions,
        string migrationsAssembly) => new SqliteDbContextConfigurer
        (dbProviderConnectionOptions, migrationsAssembly);
        protected virtual IDbContextConfigurer CreateInMemoryConfigurer
        (IEntityFrameworkProviderConnectionOptions dbProviderConnectionOptions,
        string migrationsAssembly) => new InMemoryDbContextConfigurer
        (dbProviderConnectionOptions, migrationsAssembly);
    }

    public class CacheableDbContextConfigurerFactory : DbContextConfigurerFactory
    {
        protected IDbContextConfigurer _sqlServerConfigurer;
        protected IDbContextConfigurer _sqliteConfigurer;
        protected IDbContextConfigurer _inMemoryConfigurer;

        public CacheableDbContextConfigurerFactory(IOptions<EntityFrameworkOptions> options,
        IEntityFrameworkProviderConnectionOptions dbProviderConnectionOptions) :
        base(options, dbProviderConnectionOptions) { }

        protected override IDbContextConfigurer CreateSqlServerConfigurer
        (IEntityFrameworkProviderConnectionOptions dbProviderConnectionOptions,
        string migrationsAssembly) => _sqlServerConfigurer ??= base.CreateSqlServerSetup
        (dbProviderConnectionOptions, migrationsAssembly);
        protected override IDbContextConfigurer CreateSqliteConfigurer
        (IEntityFrameworkProviderConnectionOptions dbProviderConnectionOptions,
        string migrationsAssembly) => _sqliteConfigurer ??= base.CreateSqliteSetup
        (dbProviderConnectionOptions, migrationsAssembly);
        protected override IDbContextConfigurer CreateInMemoryConfigurer
        (IEntityFrameworkProviderConnectionOptions dbProviderConnectionOptions,
        string migrationsAssembly) => _inMemoryConfigurer ??= base.CreateInMemorySetup
        (dbProviderConnectionOptions, migrationsAssembly);
    }
}

and here is the implementation for the Product interface and concrete classes:

C#
using System;
using ConfigureEFDbContext.EFProviderConnectionOptions;
using Microsoft.EntityFrameworkCore;

namespace ConfigureEFDbContext
{
    public interface IDbContextConfigurer
    {
        void Configure(IServiceProvider serviceProvider, DbContextOptionsBuilder builder);
    }
    public abstract class DbContextConfigurer : IDbContextConfigurer
    {
        protected DbContextConfigurer
        (IEntityFrameworkProviderConnectionOptions dbProviderConnectionOptions,
        string migrationsAssembly)
        {
            DbProviderConnectionOptions = dbProviderConnectionOptions;
            MigrationsAssembly = migrationsAssembly;
        }

        public IEntityFrameworkProviderConnectionOptions DbProviderConnectionOptions { get; }
        public string MigrationsAssembly { get; }

        public abstract void Configure(IServiceProvider serviceProvider,
                                       DbContextOptionsBuilder builder);
    }

    public class SqlServerDbContextConfigurer : DbContextConfigurer
    {
        public SqlServerDbContextConfigurer
        (IEntityFrameworkProviderConnectionOptions dbProviderConnectionOptions,
        string migrationsAssembly) : base(dbProviderConnectionOptions, migrationsAssembly) { }

        public override void Configure(IServiceProvider serviceProvider,
                                       DbContextOptionsBuilder builder)
        {
            if (DbProviderConnectionOptions.UseConnectionString)
            {
                builder.UseSqlServer(
                    connectionString: DbProviderConnectionOptions.GetConnectionString(),
                    sqlServerDbContextOptionsBuilder =>
                    sqlServerDbContextOptionsBuilder.MigrationsAssembly(MigrationsAssembly)
                );
            }
            else
            {
                builder.UseSqlServer(
                    connection: DbProviderConnectionOptions.GetConnection(),
                    sqlServerDbContextOptionsBuilder =>
                    sqlServerDbContextOptionsBuilder.MigrationsAssembly(MigrationsAssembly)
                );
            }
        }
    }

    public class SqliteDbContextConfigurer : DbContextConfigurer
    {
        public SqliteDbContextConfigurer
        (IEntityFrameworkProviderConnectionOptions dbProviderConnectionOptions,
        string migrationsAssembly) : base(dbProviderConnectionOptions, migrationsAssembly) { }

        public override void Configure(IServiceProvider serviceProvider,
                                       DbContextOptionsBuilder builder)
        {
            if (DbProviderConnectionOptions.UseConnectionString)
            {
                builder.UseSqlite(
                    connectionString: DbProviderConnectionOptions.GetConnectionString(),
                    sqlServerDbContextOptionsBuilder =>
                    sqlServerDbContextOptionsBuilder.MigrationsAssembly(MigrationsAssembly)
                );
            }
            else
            {
                builder.UseSqlite(
                    connection: DbProviderConnectionOptions.GetConnection(),
                    sqlServerDbContextOptionsBuilder =>
                    sqlServerDbContextOptionsBuilder.MigrationsAssembly(MigrationsAssembly)
                );
            }
        }
    }

    public class InMemoryDbContextConfigurer : DbContextConfigurer
    {
        public InMemoryDbContextConfigurer(IEntityFrameworkProviderConnectionOptions
        dbProviderConnectionOptions, string migrationsAssembly) :
        base(dbProviderConnectionOptions, migrationsAssembly) { }

        public override void Configure(IServiceProvider serviceProvider,
        DbContextOptionsBuilder builder) => builder.UseInMemoryDatabase
        (DbProviderConnectionOptions.GetConnectionString());
    }
}

This factory is responsible for creating the classes that will configure DbContext by calling the GetConfigurer method, and we will get an IDbContextConfigurer instance that contains the Configure method to initialize the DbContext.

Entity Framework Provider Connection Options

To make Configure method more flexible, we have followed the Single Responsibility Principle (SRP). So we’ve created some new classes.

Image 2

The main task of this simple design is to read the configuration from appsetting.json or the current environment setting file, by Options Pattern that we talked about earlier and convert it into extensions that we can apply to the data provider, so if we want to add a new provider, we have to add another class for this provider, but don’t forget to add a new implementation to the IDbContextConfigurer interface or inherit from the DbContextConfigurer base class, for example: MySqlProviderConnectionOptions.

C#
using System.Data.Common;
using ConfigureEFDbContext.Common;
using ConfigureEFDbContext.Options;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Options;

namespace ConfigureEFDbContext
{
    public interface IEntityFrameworkProviderConnectionOptions : IDisposableObject
    {
        bool UseConnectionString { get; }
        string GetConnectionString();
        DbConnection GetConnection();
    }

    public abstract class EntityFrameworkProviderConnectionOptions : DisposableObject,
                          IEntityFrameworkProviderConnectionOptions
    {
        public abstract bool UseConnectionString { get; }

        public virtual DbConnection GetConnection() => null;
        public virtual string GetConnectionString() => null;
    }

    public class SqlServerProviderConnectionOptions : EntityFrameworkProviderConnectionOptions
    {
        private readonly ConnectionStringsOptions _options;

        public SqlServerProviderConnectionOptions
        (IOptions<ConnectionStringsOptions> options) => _options = options.Value;
        public override bool UseConnectionString => true;

        public override string GetConnectionString() =>
        $"Server={_options.ServerName};Database={_options.DatabaseName};
        User Id={_options.UserId};Password={_options.Password};MultipleActiveResultSets=True";

        // Or
        //public string GetConnectionString() {
        //    var connectionStringBuilder = new SqlConnectionStringBuilder();
        //    connectionStringBuilder.DataSource = _options.ServerName;
        //    connectionStringBuilder.DataSource = _options.DatabaseName;
        //    connectionStringBuilder.UserID = _options.UserId;
        //    connectionStringBuilder.Password = _options.Password;
        //    connectionStringBuilder.MultipleActiveResultSets = true;

        //    return connectionStringBuilder.ConnectionString;
        //}
    }

    public class SqliteProviderConnectionOptions : EntityFrameworkProviderConnectionOptions
    {
        private readonly ConnectionStringsOptions _options;

        public SqliteProviderConnectionOptions
        (IOptions<ConnectionStringsOptions> options) => _options = options.Value;
        public override bool UseConnectionString => true;

        //If _options.InMemory then Data Source=InMemorySample;Mode=Memory;Cache=Shared
        public override string GetConnectionString() =>
               $"Data Source={_options.DatabaseName};Cache=Shared;";
    }

    public class SqliteInMemoryProviderConnectionOptions :
                              EntityFrameworkProviderConnectionOptions
    {
        private readonly DbConnection _connection;

        public SqliteInMemoryProviderConnectionOptions() =>
               _connection = new SqliteConnection("Data Source=:memory:;Cache=Shared;");
        public override bool UseConnectionString => false;

        public override DbConnection GetConnection()
        {
            if (_connection.State != System.Data.ConnectionState.Open)
            {
                _connection.Open();
            }

            return _connection;
        }

        protected override void Dispose(bool disposing)
        {
            _connection.Dispose();
            base.Dispose(disposing);
        }
    }

    public class InMemoryProviderConnectionOptions : EntityFrameworkProviderConnectionOptions
    {
        private readonly ConnectionStringsOptions _options;

        public InMemoryProviderConnectionOptions
          (IOptions<ConnectionStringsOptions> options) => _options = options.Value;
        public override bool UseConnectionString => true;

        public override string GetConnectionString() => _options.DatabaseName;
    }
}

After completing this scenario, we have to inject all the classes in Dependency Injection in the Startup class. You will notice here that all the new methods that we have added to Startup class are virtual methods. This is to make this class overridable in the integration test app of MSTest unit testing, which we will add later.

C#
using ConfigureEFDbContext.Options;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace ConfigureEFDbContext
{
    public class Startup
    {
        public Startup(IConfiguration configuration, IWebHostEnvironment hostEnvironment)
        {
            Configuration = configuration;
            HostEnvironment = hostEnvironment;
        }

        public IConfiguration Configuration { get; }
        public IWebHostEnvironment HostEnvironment { get; }

        // This method gets called by the runtime. Use this method to add services
        // to the container.
        public virtual void ConfigureServices(IServiceCollection services)
        {
            this
                .AddLogging(services)
                .AddApplicationOptions(services)

                .AddDbContextConfigurerFactory(services)
                .AddEFProviderConnectionOptions(services)
                .AddDbContextConfigurer(services)

                .AddDbContext(services);

            services.AddControllers();
        }

        // This method gets called by the runtime. Use this method to configure
        // the HTTP request pipeline.
        public virtual void Configure(IApplicationBuilder app)
        {
            if (HostEnvironment.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app
                .UseHttpsRedirection()
                .UseRouting()
                .UseAuthorization()
                .UseEndpoints(endpoints => endpoints.MapControllers());
        }

        //  To use fluent setting we return same objects
        protected virtual Startup AddLogging(IServiceCollection services)
        {
            services.AddLogging
                    (
                        builder =>
                        builder.AddConfiguration(Configuration.GetSection("Logging"))
                                .AddConsole()
                                .AddDebug()
                    );

            return this;
        }

        //  To use fluent setting we return same objects
        protected virtual Startup AddApplicationOptions(IServiceCollection services)
        {
            services
                .AddOptions()
                .Configure<EntityFrameworkOptions>(Configuration.GetSection
                                                  (EntityFrameworkOptions.KEY_NAME))
                .Configure<ConnectionStringsOptions>(Configuration.GetSection
                                                    (ConnectionStringsOptions.KEY_NAME))
                ;

            return this;
        }

        protected virtual Startup AddDbContextConfigurerFactory(IServiceCollection services)
        {
            services.AddSingleton<IDbContextConfigurerFactory,
                                  CacheableDbContextConfigurerFactory>();
            return this;
        }

        protected virtual Startup AddEFProviderConnectionOptions(IServiceCollection services)
        {
            services.AddSingleton<IEntityFrameworkProviderConnectionOptions,
                                  SqlServerProviderConnectionOptions>();
            return this;
        }

        protected Startup AddDbContextConfigurer(IServiceCollection services)
        {
            services.AddSingleton(serviceProvider =>
            serviceProvider.GetService<IDbContextConfigurerFactory>().GetConfigurer());
            return this;
        }

        protected virtual Startup AddDbContext(IServiceCollection services)
        {
            AddDbContextPool<DbContext_1>(services);
            AddDbContextPool<DbContext_2>(services);
            AddDbContextPool<DbContext_3>(services);

            // Interface Segregation Principle (ISP)
            services.AddScoped<IDbContext_1>(provider => provider.GetService<DbContext_1>());
            services.AddScoped<IDbContext_2>(provider => provider.GetService<DbContext_2>());
            services.AddScoped<IDbContext_3>(provider => provider.GetService<DbContext_3>());

            return this;
        }

        private Startup AddDbContextPool<TContext>(IServiceCollection services)
                        where TContext : DbContext
        {
            services.AddDbContextPool<TContext>
            (
                (serviceProvider, dbContextOptionsBuilder) =>
                 serviceProvider.GetService<IDbContextConfigurer>().Configure
                 (serviceProvider, dbContextOptionsBuilder)
            );

            return this;
        }
    }
}

In the end, we will add the DbContexts that we used in our discussion, which are the simplest form of identification for this type of class.

C#
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;

namespace ConfigureEFDbContext
{
    //  Interface Segregation Principle (ISP) or Encapsulation
    public interface IDbContext
    {
        /// <summary>
        /// Provides access to database related information and operations for this context.
        /// </summary>
        DatabaseFacade Database { get; }
    }

    public interface IDbContext_1 : IDbContext { }
    public interface IDbContext_2 : IDbContext { }
    public interface IDbContext_3 : IDbContext { }

    public class DbContext_1 : DbContext, IDbContext_1
    {
        public DbContext_1(DbContextOptions<DbContext_1> options) : base(options) { }
    }

    public class DbContext_2 : DbContext, IDbContext_2
    {
        public DbContext_2(DbContextOptions<DbContext_2> options) : base(options) { }
    }

    public class DbContext_3 : DbContext, IDbContext_3
    {
        public DbContext_3(DbContextOptions<DbContext_3> options) : base(options) { }
    }
}

Here, we will add a controller so that we do a simple test of this scenario.

C#
using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;

namespace ConfigureEFDbContext.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class DbContextsController : ControllerBase
    {
        private readonly IDbContext_1 _dbContext_1;
        private readonly IDbContext_2 _dbContext_2;
        private readonly IDbContext_3 _dbContext_3;

        public DbContextsController(IDbContext_1 dbContext_1,
               IDbContext_2 dbContext_2, IDbContext_3 dbContext_3)
        {
            _dbContext_1 = dbContext_1;
            _dbContext_2 = dbContext_2;
            _dbContext_3 = dbContext_3;
        }

        [HttpGet]
        public IEnumerable<string> Get() => new[] {
            _dbContext_1.Database.ProviderName,
            _dbContext_2.Database.ProviderName,
            _dbContext_3.Database.ProviderName
        };
    }
}

After testing the program with Postman, we will get this result:

Image 3

In this post, we will not talk about the unit test or integration test because it is outside the scope of this topic, but we will show the necessary classes to run this test.

C#
{
  "EntityFrameworkOptions": {
    "Provider": "IN-MEMOR",
    "CanMigrate": false
    //"Provider": "SQLITE",
    //"CanMigrate": true
  },

  "ConnectionStringsOptions": {
    "ServerName": null,
    "DatabaseName": "DEV-V1.db",
    "UserId": null,
    "Password": null
  }
}

Integration Test

After inheriting from the Startup class that we created earlier and override necessary methods, IntegrationStartup will become:

C#
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace ConfigureEFDbContext.MSUnitTest
{
    public class IntegrationStartup : Startup
    {
        public override void ConfigureServices(IServiceCollection services)
        {
            base.ConfigureServices(services);
            services
               .AddMvc()
               .AddApplicationPart(typeof(Startup).Assembly);
        }

        public IntegrationStartup(IConfiguration configuration,
               IWebHostEnvironment environment) : base(configuration, environment) { }

        /*
         * ************************************************************************************
         * We have to choose the correct Provider according to the settings that we put in
         * the configuration 'integration-settings.json' file, Section 'EntityFrameworkOptions'
         * ************************************************************************************
         */

        // FOR EntityFrameworkProviders.SQLite

        //protected override Startup AddEFProviderConnectionOptions
        //                   (IServiceCollection services)
        //{
        //    services.AddSingleton<IEntityFrameworkProviderConnectionOptions,
        //    SqliteProviderConnectionOptions>();
        //    return this;
        //}

        // FOR EntityFrameworkProviders.SQLite but in memory db.
        //protected override 
        //          Startup AddEFProviderConnectionOptions(IServiceCollection services)
        //{
        //    services.AddSingleton<IEntityFrameworkProviderConnectionOptions,
        //    SqliteInMemoryProviderConnectionOptions>();
        //    return this;
        //}

        // FOR EntityFrameworkProviders.InMemor
        protected override 
                  Startup AddEFProviderConnectionOptions(IServiceCollection services)
        {
            services.AddSingleton<IEntityFrameworkProviderConnectionOptions,
                                  InMemoryProviderConnectionOptions>();
            return this;
        }
    }
}

The necessary classes to run this test.

C#
using System;
using System.IO;
using System.Reflection;

namespace ConfigureEFDbContext.MSUnitTest
{
    public static class Helper
    {
        public static string GetParentProjectPath()
        {
            var parentProjectName = typeof(Startup).GetTypeInfo().Assembly.GetName().Name;
            var parentProjectFullName = $"{parentProjectName}.csproj";
            var applicationBasePath = Directory.GetCurrentDirectory();
            var directoryInfo = new DirectoryInfo(Directory.GetCurrentDirectory());

            while (directoryInfo != null)
            {
                var projectDirectoryInfo = new DirectoryInfo(directoryInfo.FullName);
                var parentProjectPath = Path.Combine(projectDirectoryInfo.FullName,
                                        parentProjectName, parentProjectFullName);
                if (projectDirectoryInfo.Exists && new FileInfo(parentProjectPath).Exists)
                {
                    return Path.Combine(projectDirectoryInfo.FullName, parentProjectName);
                }
                directoryInfo = directoryInfo.Parent;
            }

            throw new Exception($"Th parent project {parentProjectName}
            could not be located using the current application root {applicationBasePath}.");
        }
    }
}

Created an IntegrationWebApplicationFactory independently of the test classes by inheriting from WebApplicationFactory.

C#
using System.IO;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;

namespace ConfigureEFDbContext.MSUnitTest.Fixtures
{
    public class IntegrationWebApplicationFactory : WebApplicationFactory<Startup>
    {
        protected override IWebHostBuilder CreateWebHostBuilder() =>
                  WebHost.CreateDefaultBuilder<IntegrationStartup>(null);

        protected override void ConfigureWebHost(IWebHostBuilder builder)
        {
            var contentRoot = Helper.GetParentProjectPath();

            builder
                .ConfigureAppConfiguration(config =>
                {
                    var projectDir = Directory.GetCurrentDirectory();
                    var integrationSettingsPath =
                        Path.Combine(projectDir, "integration-settings.json");
                    var integrationConfig = new ConfigurationBuilder()
                            .SetBasePath(contentRoot)
                            .AddJsonFile(integrationSettingsPath, false)
                            .Build();

                    config.AddConfiguration(integrationConfig);
                })
                .UseContentRoot(contentRoot)
                .UseEnvironment("Development")
                .UseStartup<IntegrationStartup>();

            // Here we can also write our own settings
            // this called after the 'ConfigureServices' from the Startup
            // But this is not desirable because we will hide the dependencies
            // and break the Single Responsibility Principle (SRP).
            // builder.ConfigureServices(services => {});
            // Or
            builder.ConfigureTestServices(services =>
            {
                //services
                //   .AddMvc()
                //   .AddApplicationPart(typeof(Startup).Assembly);
            });

            base.ConfigureWebHost(builder);
        }
    }
}

A class to test our controller class to make sure everything works well.

C#
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using ConfigureEFDbContext.MSUnitTest.Fixtures;
using Microsoft.Extensions.Configuration;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json;

namespace ConfigureEFDbContext.MSUnitTest
{
    [TestClass]
    public class DbContextsControllerTest
    {
        protected static IntegrationWebApplicationFactory _fixture;
        protected static HttpClient _client;
        protected readonly IConfiguration _configuration;

        /// <summary>
        /// Executes once for the test class. (Optional)
        /// </summary>
        [ClassInitialize]
        public static void TestFixtureSetup(TestContext context)
        {
            _fixture = new IntegrationWebApplicationFactory();
            _client = _fixture.CreateClient();
            _client.BaseAddress = new Uri("http://localhost:60128");
            _client.DefaultRequestHeaders.Accept.Clear();
            _client.DefaultRequestHeaders.Accept.Add
                    (new MediaTypeWithQualityHeaderValue("application/json"));
        }

        /// <summary>
        /// Runs before each test. (Optional)
        /// </summary>
        [TestInitialize]
        public void Setup() { }

        /// <summary>
        /// Runs once after all tests in this class are executed. (Optional)
        /// Not guaranteed that it executes instantly after all tests from the class.
        /// </summary>
        [ClassCleanup]
        public static void TestFixtureTearDown()
        {
            _client.Dispose();
            _fixture.Dispose();
        }

        /// <summary>
        /// Runs after each test. (Optional)
        /// </summary>
        [TestCleanup]
        public void TearDown() { }

        [TestMethod]
        public async Task DbContexts__Should_Initialized()
        {
            /*
             * ===== ===== ===== ===== ===== ===== ===== ===== ===== =====
             * Arrange
             * this is where you would typically prepare everything for the test,
             * in other words, prepare the scene for testing
             * (creating the objects and setting them up as necessary)
             * ===== ===== ===== ===== ===== ===== ===== ===== ===== =====
             */
            var requestUri = new Uri("/DbContexts", UriKind.Relative);

            /*
             * ===== ===== ===== ===== ===== ===== ===== ===== ===== =====
             * Act
             * this is where the method we are testing is executed.
             * ===== ===== ===== ===== ===== ===== ===== ===== ===== =====
             */

            var response = await _client.GetAsync(requestUri).ConfigureAwait(false);
            var responseBody = 
                await response.Content.ReadAsStringAsync().ConfigureAwait(false);

            response.EnsureSuccessStatusCode();
            var result = JsonConvert.DeserializeObject<List<string>>(responseBody);

            /*
            * ===== ===== ===== ===== ===== ===== ===== ===== ===== =====
            * Assert
            * This is the final part of the test where we compare what we expect
            * to happen with the actual result of the test method execution.
            * ===== ===== ===== ===== ===== ===== ===== ===== ===== =====
            */

            Assert.IsNotNull(result);
        }
    }
}

Conclusion

In this post, I showed a new way to configure our application with the startup class using a flexible way to implement this configuration.

I hope you liked the article. Please share your opinion in the comments section below.
You can find the source code for this demo on GitHub.

History

  • 3rd October, 2020: Initial version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer (Senior)
Syrian Arab Republic Syrian Arab Republic
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
-- There are no messages in this forum --