To keep EF Core migrations in a separate assembly, move your DbContext and migrations into a class library, tell EF which assembly owns the migrations with optionsBuilder.UseSqlServer(cs, b => b.MigrationsAssembly("YourData")), and add an IDesignTimeDbContextFactory<T> so dotnet ef can build the context at design time. Then run the dotnet ef commands with --project pointing at the data library and --startup-project pointing at the app. This guide shows the full layout with EF Core 9 on .NET 9, the exact commands, and the pitfalls that trip people up.
Why put migrations in a separate assembly?
By default, dotnet ef migrations add drops a Migrations folder into whatever project holds your DbContext — usually the same project you run. That is fine for a demo, but it breaks down fast in real solutions. Keeping migrations in a dedicated data assembly pays off when you have:
- Clean architecture — your domain and persistence code should not depend on a web or console host. The data layer owns the schema; hosts just consume it.
- Multiple startup projects — an API, a background worker, and an admin tool can all share one
DbContextand one migration history instead of each carrying its own copy. - A reusable data layer — package the library once and reference it from several solutions without dragging migrations around by hand.
The goal is a data library that fully owns the schema, and thin startup projects that just point at it. In older ASP.NET and .NET Framework projects this used to be awkward, but EF Core 9 makes it a clean, first-class pattern once you know the two moving parts: the MigrationsAssembly setting and a design-time factory. Everything else is standard code-first workflow.
The solution layout
A minimal version of this pattern is two projects:
MyApp.sln
├── src/YourData/ class library (net9.0)
│ ├── AppDbContext.cs
│ ├── Entities/Product.cs
│ ├── DesignTimeDbContextFactory.cs
│ └── Migrations/ generated here
└── src/YourApp/ console or ASP.NET host (net9.0)
└── Program.cs
YourData references Microsoft.EntityFrameworkCore.SqlServer and Microsoft.EntityFrameworkCore.Design. YourApp references YourData. Note the assembly name here is YourData — remember it, because it must match the MigrationsAssembly string exactly.
The dependency direction is deliberate: the host depends on the data library, never the reverse. Your persistence code has no idea whether it is being driven by a web API, a console app, or a test harness. That independence is exactly what makes the data layer reusable, and it is why the migrations belong beside the DbContext in YourData rather than in any particular host.
Configure the DbContext and MigrationsAssembly
The single most important line is the MigrationsAssembly call. It tells EF Core which assembly the migration classes live in. Without it, EF assumes migrations belong to the startup project's assembly and refuses to find them.
using Microsoft.EntityFrameworkCore;
namespace YourData;
public class AppDbContext(DbContextOptions<AppDbContext> options)
: DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>(e =>
{
e.Property(p => p.Name).HasMaxLength(200).IsRequired();
e.Property(p => p.Price).HasPrecision(18, 2);
});
}
}
When the host wires up the context, it passes the MigrationsAssembly in the options:
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(
builder.Configuration.GetConnectionString("Default"),
sql => sql.MigrationsAssembly("YourData")));
That name is a string, not a project reference, so a typo compiles fine and fails only at runtime. Match it to the data library's assembly name. If you rename the project or set a custom AssemblyName in the .csproj, update this string to match — the compiler will not catch the mismatch for you.
The Product entity referenced above is an ordinary POCO with no EF attributes; all its mapping lives in OnModelCreating. Keeping configuration in the context (or in IEntityTypeConfiguration<T> classes) rather than in data annotations keeps your domain types clean and framework-agnostic, which fits the separate-assembly goal.
Add a design-time factory so dotnet ef works
Here is the piece most tutorials skip. When you run dotnet ef against a class library, EF Core has no Program.cs and no host to build the app's DI container, so it cannot construct your DbContext. The clean fix is to implement IDesignTimeDbContextFactory<AppDbContext> in the data library. EF discovers it automatically and uses it only at design time — it never runs in production.

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace YourData;
public class DesignTimeDbContextFactory
: IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args)
{
var connectionString =
"Server=localhost;Database=YourAppDb;" +
"Trusted_Connection=True;TrustServerCertificate=True";
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(connectionString,
sql => sql.MigrationsAssembly("YourData"))
.Options;
return new AppDbContext(options);
}
}
With this factory present, EF no longer needs to reach into the startup project's service container to build the context. You can still keep a startup project (recommended), but the factory guarantees dotnet ef works even when run directly against the library.
Run the migration commands
Install the tool once if you have not already, then add a migration and update the database. The two flags that make this work are --project (where the migrations get written) and --startup-project (the app EF builds to load configuration and the host).
dotnet tool install --global dotnet-ef
dotnet ef migrations add InitialCreate \
--project src/YourData \
--startup-project src/YourApp
dotnet ef database update \
--project src/YourData \
--startup-project src/YourApp
The migrations add command generates the Migrations folder inside src/YourData, exactly where you want it. The database update command applies pending migrations to the database resolved from the startup project (or the design-time factory). Run every EF command from the solution root so the relative paths line up.
If you prefer the Package Manager Console in Visual Studio, the equivalent is Add-Migration InitialCreate with the Default Project set to YourData and the startup project set to YourApp in the solution. The Default Project dropdown maps to --project, and the solution's startup project maps to --startup-project, so the same two-target rule applies.
For deployments, you rarely want the app to apply migrations on startup in production. A common approach is to generate an idempotent SQL script with dotnet ef migrations script --idempotent --project src/YourData --startup-project src/YourApp and run it as part of your release pipeline. Because the migrations live in a single shared assembly, that script is identical no matter which host triggered it, which keeps every environment on exactly the same schema.
Common pitfalls
Most failures with this setup come from one of a handful of predictable mistakes. Work through them in order and the error messages usually point straight at the fix.
Your target project doesn't match your migrations assembly— you forgot theMigrationsAssembly("YourData")call, or the string does not match the library's assembly name.Unable to create a DbContext— no design-time factory and no reachable host configuration. Add theIDesignTimeDbContextFactoryshown above.- Missing
Microsoft.EntityFrameworkCore.Design— this package must be referenced by the project EF runs against (--projector--startup-project), ordotnet efcannot load. - Wrong flags — swapping
--projectand--startup-projectwrites migrations into the app or fails to find configuration. The data library is--project; the runnable app is--startup-project. - Connection string drift — the design-time factory and the host can point at different databases. Keep them in sync, or read both from the same configuration source.
Key takeaways
- Move
DbContext, entities, and migrations into a dedicated class library so hosts stay thin and the schema has one owner. - The linchpin is
optionsBuilder.UseSqlServer(cs, b => b.MigrationsAssembly("YourData"))— it names the assembly that holds migrations. - Implement
IDesignTimeDbContextFactory<T>in the library sodotnet efcan build the context without a running host. - Run
dotnet ef migrations add/database updatewith--project(data library) and--startup-project(app). - The
MigrationsAssemblyvalue is a string; a typo fails at runtime, so match it to the assembly name exactly. - This pattern scales cleanly to multiple startup projects sharing one migration history on EF Core 9 and .NET 9.
Frequently asked questions
Do I still need a startup project if I have a design-time factory?
For dotnet ef alone, the factory is enough. But a real app still needs a host to run, and it is good practice to pass --startup-project so EF loads the same configuration your app uses. The factory is a safety net, not a replacement for the host.
Why does EF Core look in the wrong assembly for migrations?
Because by default EF assumes migrations live in the startup project's assembly. When your context and migrations are in a separate library, you must override that with MigrationsAssembly, otherwise EF searches the host project and finds nothing.
Which projects need the EF Core Design package?
The project EF Core runs against at design time — typically your startup project, and the data library if you run commands directly against it. Microsoft.EntityFrameworkCore.Design is a design-time-only dependency and is not shipped in your production output.
Can multiple applications share one migrations assembly?
Yes. That is the main reason to use this pattern. An API, a worker, and a CLI tool can all reference the same data library and apply the same migrations, keeping a single source of truth for the schema.
Comments (0)
No comments yet — be the first to share your thoughts.