This is Part 2 in a 3 part series:
- Add .NET Core DI and Config Goodness to AWS Lambda Functions
- IDesignTimeDbContextFactory and Dependency Injection: A Love Story (this post)
- 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 |
If you’re on Mac, here’s how to do it:
# Set environment | |
export ASPNETCORE_ENVIRONMENT=Development | |
# View environment | |
echo ASPNETCORE_ENVIRONMENT |
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
.
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!
Thank you for sharing, Tony.
`.AddEnvironmentVariables()` is not needed
Tony, thank you for this thoughtful and well written post. It saved my butt.