If you've ever worked in a solution where your DbContext doesn't live in your startup project, every single EF Core command ends up looking like this:
dotnet ef migrations add InitialCreate --project Playlist.DataAccess --startup-project PlaylistApi
dotnet ef database update --project Playlist.DataAccess --startup-project PlaylistApi
dotnet ef migrations list --project Playlist.DataAccess --startup-project PlaylistApi
The next logical step is to encode them in a shell script that wraps the dotnet ef command, or in your CI scripts. EF CORE 11 finally fixes this. The dotnet ef command-line tool now loads default option values from a .config/dotnet-ef.json configuration file. You write your project layout down once, and every dotnet ef invocation picks it up automatically.
The scenario: a DbContext shared across APIs
A common architecture is to keep the EF Core model in its own class library so that multiple APIs can share the same DbContext. In this sample solution, we have two independent areas, each in its own subfolder:
-
Playlists/PlaylistApian ASP.NET Core Web API. This is the startup project. -
Playlists/Playlist.DataAccessa plain SDK class library that containsPlaylistDbContextand the entity models. This is the project that contains the DbContext. -
RecipeApia second, unrelated API with its ownRecipeDbContext.
The solution https://github.com/mikhailnefedov/EfCoreConfigFiles looks like this:
EfCoreConfigFiles/
├─ Playlists/
│ ├─ .config/
│ │ └─ dotnet-ef.json <-- defaults for the Playlist area
│ ├─ PlaylistApi/ <-- startup project (Web API)
│ └─ Playlist.DataAccess/ <-- DbContext lives here
├─ RecipeApi/
│ ├─ .config/
│ │ └─ dotnet-ef.json <-- defaults for the Recipe area
│ └─ RecipeApi.csproj
└─ EfCoreConfigFiles.slnx
PlaylistApi simply references Playlist.DataAccess and registers the context:
// Playlists/PlaylistApi/Program.cs
using Microsoft.EntityFrameworkCore;
using Playlist.DataAccess.Data;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<PlaylistDbContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("Playlist")));
var app = builder.Build();
// ...
And Playlist.DataAccess owns the context itself:
// Playlists/Playlist.DataAccess/Data/PlaylistDbContext.cs
using Microsoft.EntityFrameworkCore;
namespace Playlist.DataAccess.Data;
public class PlaylistDbContext(DbContextOptions<PlaylistDbContext> options) : DbContext(options)
{
public DbSet<Models.Playlist> Playlists => Set<Models.Playlist>();
public DbSet<Track> Tracks => Set<Track>();
}
Because the context is not in the startup project, every tooling command needs to know both where the context lives (--project) and how to build the app (--startup-project).
Scoped .config/dotnet-ef.json
Create a .config directory and drop a dotnet-ef.json inside it. Here it sits next to the Playlist projects in Playlists/:
{
"project": "Playlist.DataAccess",
"startupProject": "PlaylistApi",
"context": "Playlist.DataAccess.Data.PlaylistDbContext"
}
The context value is optional, but it is especially useful when a project contains multiple DbContext types: it tells the tool which one to use without passing --context on every command.
That's it. Now the commands shrink down to:
dotnet ef migrations add InitialCreate
dotnet ef database update
Other properties, such as framework and verbose, can also be configured. You can find more information in the EF Core 11 What's New documentation.
How discovery works
A few details worth knowing:
-
Walking up the tree. When you run
dotnet ef, the tool searches for a.config/dotnet-ef.jsonby walking up the directory tree from the current working directory. The first file it finds is used. Run the command fromPlaylists/PlaylistApi, for example, and it walks up toPlaylists/and finds the config there. -
Paths are relative to the
.configfolder's parent. TheprojectandstartupProjectvalues are resolved relative to the directory containing.config, not relative to your current working directory. In our Playlist case, that is thePlaylists/folder, which is why"Playlist.DataAccess"and"PlaylistApi"resolve correctly even when you invoke the tool fromPlaylists/PlaylistApi. - Command-line options always win. Anything you pass explicitly on the command line overrides the config file. This means you can set sane defaults in the file and override them for one-off commands:
# Uses Playlist.DataAccess from the config, but points the tool at a different output folder
dotnet ef migrations add AddRating --output-dir Migrations/Rating
-
Scoped per directory. Because discovery is folder-based, each area of the solution can carry its own defaults.
Playlists/.config/dotnet-ef.jsonandRecipeApi/.config/dotnet-ef.jsonare completely independent. Run a command from inside either area and the nearest config wins.
Summary
The new .config/dotnet-ef.json support is a small quality-of-life feature with a big payoff for any solution that separates its data layer from its startup project. Write your --project and --startup-project values once, let the walking-up discovery find them from anywhere in the tree, and override on the command line only when you actually need to.
Top comments (1)
It pairs well with local tools, since .config is also where dotnet new tool-manifest puts dotnet-tools.json. Install dotnet-ef as a local tool and the tool version and its defaults sit in the same folder, so a fresh clone only needs dotnet tool restore. It's worth checking what happens when the manifest is in a different .config higher up, though.