EF Core Migrations without Hard-coding a Connection String using IDbContextFactory

Posted · Add Comment

In my last post, I showed you how to set up an ASP.NET Core and Entity Framework Core solution so that you can use EF Core Migrations for database updates.  That code sample used a hard-coded database connection string in the OnConfiguring() method of my DbContext class…and that stinks.  It’s terrible for deployment and maintenance and it limits your flexibility and it’s just not a good idea    (Circle slash hard-coded connection strings.)

[TL;DR — here’s the sample code]

Don’t hard-code your DbContext connection strings!

The problem is that if you get rid of the OnConfiguring() method, you’ll get an error when you try to run your EF Core database migration.  Try it.  Remove that method, and run “dotnet ef database update”.  Boom!

No parameterless constructor was found on ‘MyDbContext’. Either add a parameterless constructor to ‘MyDbContext’ or add an implementation of ‘IDbContextFactory<MyDbContext>’ in the same assembly as ‘MyDbContext’.

What is IDbContextFactory<T>?

Ok.  Well, you can’t remove the constructor and still have this work with ASP.NET Core.  So what’s this IDbContextFactory thing?

IDbContextFactory<T> allows you to put the logic of creating instances of your DbContext into a type-safe class that follows a pattern that is known and usable by your code and the tooling of “dotnet ef”, Visual Studio, and Visual Studio Code.  It gives you a nice, clean way to separate the persistence details of your DbContext from the construction and configuration details for your DbContext.  This pattern fits nicely with the SOLID principles and Separation of Concerns and keeps your code organized.

In my implementation of IDbContextFactory<T>, I wanted to be able to read my connection strings from appsettings.json and from environment variables.  This would mirror the same kind of configuration and initialization that happens in ASP.NET Core’s Startup.cs.

var builder = new Microsoft.Extensions.Configuration.ConfigurationBuilder()
 .SetBasePath(basePath)
 .AddJsonFile("appsettings.json")
 .AddJsonFile($"appsettings.{environmentName}.json", true)
 .AddEnvironmentVariables();

var config = builder.Build();

var connstr = config.GetConnectionString("(default)");

To implement this in my Benday.EfCore.Api project, I added a class called MyDbContextFactory that implemented IDbContextFactory<MyDbContext> and then I added an appsettings.json to the project and moved the connection string out of MyDbContext and into appsettings.json.

Move the database connection string to appsettings.json.

Trouble with “dotnet ef”, appsettings.json & Unit Tests

I ran into issues with “dotnet ef” not being able to find the appsettings.json file.  It turns out that when you run your dotnet ef commands against this project from the command line, it sets the working directory to be the ./bin/debug/netappcore1.* directory and attempts to find the appsettings.json in that directory.

Ok.  Not a problem.  I’ll just mark appsettings.json so that it gets copied to the OutputDir.  I recompiled and re-ran my dotnet ef database migration update commands and it worked.

To get appsettings.json into your bin\Debug directory, set Copy to Output Directory to Copy always

Fast-forward to a few hours later and I’m trying to write a unit test (technically an integration test) that runs against MyDbContextFactory and MyDbContext.  My unit test project *did not* have an appsettings.json in the folder and the unit tests were still passing.  That might sound like a good thing but at this point in development, these tests were supposed to be failing.  (Remember, when writing unit tests, always start with a failing test.)

The “Copy always” on the appsettings.json in the Benday.EfCore.Api project was causing that appsettings.json to get copied into the run directory for the unit tests.  That was ok for right now but this was going to cause problems because my Benday.EfCore.Tests project would forever be fighting the Benday.EfCore.Api project for configuration supremacy.  Not good.

Clearly the “Copy always” on the API project couldn’t stay.  I needed to have separate configs for the API, Web, and Test projects.

The Solution: A Smarter IDbContextFactory

The essence of the problem is that IDbContextFactory was going to be called from multiple environments with multiple configurations and my basic implementation didn’t know how to handle that.  (NOTE: by default, your ASP.NET Core project isn’t going to use this IDbContextFactory and that’s fine.)

I ended up with two public methods on my IDbContextFactory implementation.

A smarter implementation of IDbContextFactory

Create() would let me easily create instances of MyDbContext by reading from appsettings.json files in the same directory as the DLLs.  This is helpful for unit tests.

public MyDbContext Create()
{
 var environmentName = 
 Environment.GetEnvironmentVariable(
 "Hosting:Environment");

var basePath = AppContext.BaseDirectory;

return Create(basePath, environmentName);
}

Create(DbContextFactoryOptions options) is the method that gets called by the “dotnet ef database update” command when you deploy your migrations.  It assumes that your appsettings.json file will be near the code and the csproj file.  The important thing here is that this allows me to AVOID marking appsettings.json as Copy Always in the Benday.EfCore.Api project.  This is the key item that keeps this API project from messing up the runtime appsettings.json in all the other projects.

public MyDbContext Create(DbContextFactoryOptions options)
{
 return Create(
 options.ContentRootPath, 
 options.EnvironmentName);
}

The two private methods understand 1) how to create instances of ConfigurationBuilder() with the correct references to appsettings.json and environment variables and 2) how to grab a connection string and create an instance of your DbContext class.

private MyDbContext Create(string basePath, string environmentName)
{
 var builder = new Microsoft.Extensions.Configuration.ConfigurationBuilder()
 .SetBasePath(basePath)
 .AddJsonFile("appsettings.json")
 .AddJsonFile($"appsettings.{environmentName}.json", true)
 .AddEnvironmentVariables();

var config = builder.Build();

var connstr = config.GetConnectionString("(default)");

if (String.IsNullOrWhiteSpace(connstr) == true)
 {
 throw new InvalidOperationException(
 "Could not find a connection string named '(default)'.");
 }
 else
 {
 return Create(connstr);
 }
}
private MyDbContext Create(string connectionString)
{
 if (string.IsNullOrEmpty(connectionString))
 throw new ArgumentException(
 $"{nameof(connectionString)} is null or empty.",
 nameof(connectionString));

var optionsBuilder =
 new DbContextOptionsBuilder<MyDbContext>();

optionsBuilder.UseSqlServer(connectionString);

return new MyDbContext(optionsBuilder.Options);
}

Summary

Adding a class to your project that implements IDbContextFactory<T> helps you to handle the initialization details of your DbContext class but be sure to keep in mind that it will be called from various environments.

Click here to download the source code.

-Ben

 

— Need help wrapping your head around ASP.NET Core and EF Core?  Scrum process problems got you down?  Not sure what this “DevOps” thing is?  We can help.  Drop us a line at info@benday.com