IDesignTimeDbContextFactory and Dependency Injection: A Love Story

This is Part 2 in a 3 part series:

  1. Add .NET Core DI and Config Goodness to AWS Lambda Functions
  2. IDesignTimeDbContextFactory and Dependency Injection: A Love Story (this post)
  3. Use EF Core with AWS Lambda Functions

Whenever I set out to create an application or service, I might start out with everything in a single project, but before long I find myself chopping things up into multiple projects. This is in line with the Single Responsibility and Interface Segregation principles of SOLID software development. A corollary of this approach is separating out development-time code from runtime code. You can see an example of this in the NPM world with separate dev dependencies in a package.json file.  Similarly, .NET Core has adopted this concept with .NET Core CLI tools, which can also be installed globally.

Note: You can download or clone the code for this post here: https://github.com/tonysneed/ef-design-di

Entity Framework Core provides the IDesignTimeDbContextFactory interface so that you can separate the EF code needed for generating database tables at design-time (what is commonly referred to as a code-first approach) from EF code used by your application at runtime.  A typical implementation of IDesignTimeDbContextFactory might look like this. Note that using the MigrationAssembly method is also required for generating code-first migrations.

public class ProductsDbContextFactory : IDesignTimeDbContextFactory<ProductsDbContext>
{
public ProductsDbContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<ProductsDbContext>();
var connectionString = "Data Source=(localdb)\\MsSqlLocalDb;initial catalog=ProductsDbDev;Integrated Security=True; MultipleActiveResultSets=True";
optionsBuilder.UseSqlServer(connectionString, b => b.MigrationsAssembly("EfDesignDemo.EF.Design"));
return new ProductsDbContext(optionsBuilder.Options);
}
}

The code smell that stands out here is that the connection string is hard-coded.  To remedy this you can build an IConfiguration in which you set the base path to the main project directory.

public class ProductsDbContextFactory : IDesignTimeDbContextFactory<ProductsDbContext>
{
public ProductsDbContext CreateDbContext(string[] args)
{
// Build config
IConfiguration config = new ConfigurationBuilder()
.SetBasePath(Path.Combine(Directory.GetCurrentDirectory(), "../EfDesignDemo"))
.AddJsonFile("appsettings.json")
.Build();
// Get connection string
var optionsBuilder = new DbContextOptionsBuilder<ProductsDbContext>();
var connectionString = config.GetConnectionString(nameof(ProductsDbContext));
optionsBuilder.UseSqlServer(connectionString, b => b.MigrationsAssembly("EfDesignDemo.EF.Design"));
return new ProductsDbContext(optionsBuilder.Options);
}
}

While this is better than including the hard-coded connection string, we can still do better. For example, we might want to select a different appsettings.*.json file depending on the environment we’re in (Development, Staging, Production, etc).  In ASP.NET Core, this is determined by an special environment variable, ASPNETCORE_ENVIRONMENT.  We’re also going to want to plug in environment variables, so that the connection string and other settings can be overriden when the application is deployed.

public class ProductsDbContextFactory : IDesignTimeDbContextFactory<ProductsDbContext>
{
public ProductsDbContext CreateDbContext(string[] args)
{
// Get environment
string environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
// Build config
IConfiguration config = new ConfigurationBuilder()
.SetBasePath(Path.Combine(Directory.GetCurrentDirectory(), "../EfDesignDemo.Web"))
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.AddJsonFile($"appsettings.{environment}.json", optional: true)
.AddEnvironmentVariables()
.Build();
// Get connection string
var optionsBuilder = new DbContextOptionsBuilder<ProductsDbContext>();
var connectionString = config.GetConnectionString(nameof(ProductsDbContext));
optionsBuilder.UseSqlServer(connectionString, b => b.MigrationsAssembly("EfDesignDemo.EF.Design"));
return new ProductsDbContext(optionsBuilder.Options);
}
}

See It In Action

The beauty of adding config to your design-time DbContext factory is that it will pick up the connection string from the configuration system, selecting the appropriate connection string for the environment you specify. How do you specify an environment (Development, Staging, Production, etc)? Simply by setting that special ASPNETCORE_ENVIRONMENT environment variable.

If you’re on Windows, you can set and view it like so:

rem Set environment
set ASPNETCORE_ENVIRONMENT=Development
rem View environment
set ASPNETCORE_ENVIRONMENT
view raw set-env-win.cmd hosted with ❤ by GitHub

If you’re on Mac, here’s how to do it:

# Set environment
export ASPNETCORE_ENVIRONMENT=Development
# View environment
echo ASPNETCORE_ENVIRONMENT
view raw set-env-win.sh hosted with ❤ by GitHub

With the environment set, you can switch to the directory where the DbContext factory is located and run commands to add EF code migrations and apply them to the database specified in the appsettings.*.json file for your selected environment.

rem Create EF code migration
dotnet ef migrations add initial
rem Apply migration to database
dotnet ef database update

Show Me Some DI Love

This solution works, but further improvements are possible. One problem is that it violates the Dependency Inversion principle of SOLID design, because we are newing up the DbContext in the design-time factory. It might be cleaner to use DI to resolve dependencies and provide the DbContext.

geeks-falling-in-love

To remedy this we can factor out the configuration bits into an IConfigurationService that builds an IConfiguration, and this service will depend on an IEnvironmentService to supply the environment name.

public interface IEnvironmentService
{
string EnvironmentName { get; set; }
}
public interface IConfigurationService
{
IConfiguration GetConfiguration();
}

The implementations for these interfaces can go into a .NET Standard class library that exists to support configuration.

public class EnvironmentService : IEnvironmentService
{
public EnvironmentService()
{
EnvironmentName = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")
?? "Production";
}
public string EnvironmentName { get; set; }
}
public class ConfigurationService : IConfigurationService
{
public IEnvironmentService EnvService { get; }
public string CurrentDirectory { get; set; }
public ConfigurationService(IEnvironmentService envService)
{
EnvService = envService;
}
public IConfiguration GetConfiguration()
{
CurrentDirectory = CurrentDirectory ?? Directory.GetCurrentDirectory();
return new ConfigurationBuilder()
.SetBasePath(CurrentDirectory)
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.AddJsonFile($"appsettings.{EnvService.EnvironmentName}.json", optional: true)
.AddEnvironmentVariables()
.Build();
}
}

Now we just need to create DependencyResolver class that uses an IServiceCollection to register dependencies.

public class DependencyResolver
{
public IServiceProvider ServiceProvider { get; }
public string CurrentDirectory { get; set; }
public DependencyResolver()
{
// Set up Dependency Injection
IServiceCollection services = new ServiceCollection();
ConfigureServices(services);
ServiceProvider = services.BuildServiceProvider();
}
private void ConfigureServices(IServiceCollection services)
{
// Register env and config services
services.AddTransient<IEnvironmentService, EnvironmentService>();
services.AddTransient<IConfigurationService, ConfigurationService>
(provider => new ConfigurationService(provider.GetService<IEnvironmentService>())
{
CurrentDirectory = CurrentDirectory
});
// Register DbContext class
services.AddTransient(provider =>
{
var configService = provider.GetService<IConfigurationService>();
var connectionString = configService.GetConfiguration().GetConnectionString(nameof(ProductsDbContext));
var optionsBuilder = new DbContextOptionsBuilder<ProductsDbContext>();
optionsBuilder.UseSqlServer(connectionString, builder => builder.MigrationsAssembly("EfDesignDemo.EF.Design"));
return new ProductsDbContext(optionsBuilder.Options);
});
}
}

This class exposes an IServiceProvider that we can use to get an instance that has been created by the DI container.  This allows us to refactor the ProductsDbContextFactory class to use DependencyResolver to create the DbContext.

public class ProductsDbContextFactory : IDesignTimeDbContextFactory<ProductsDbContext>
{
public ProductsDbContext CreateDbContext(string[] args)
{
// Get DbContext from DI system
var resolver = new DependencyResolver
{
CurrentDirectory = Path.Combine(Directory.GetCurrentDirectory(), "../EfDesignDemo.Web")
};
return resolver.ServiceProvider.GetService(typeof(ProductsDbContext)) as ProductsDbContext;
}
}

Pick Your Potion

Adding DI to the mix may feel like overkill, because the DbContext factory is only being used at design-time by EF Core tooling.  In that case, it would be more straightforward to stick with building an IConfiguration right within the CreateDbContext of your factory class, as shown in the ProductsDbContextFactory3 code snippet.

However, there is a case where the DI-based approach would be worth the effort, which is when you need to set up DI for the application entry point, for example, when using EF Core with AWS Lamda Functions.  More on that in my next blog post. 🤓 Enjoy!

About Tony Sneed

Sr. Software Solutions Architect, Hilti Global Application Software
This entry was posted in Technical and tagged , . Bookmark the permalink.

2 Responses to IDesignTimeDbContextFactory and Dependency Injection: A Love Story

  1. Anonymous says:

    Thank you for sharing, Tony.

  2. Saibamen says:

    `.AddEnvironmentVariables()` is not needed

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.