Reverse-Engineering an Existing Database
Objective
When the database exists before the C# code, dotnet ef dbcontext scaffold reads
the schema and writes a DbContext and one class per table. This concept runs the
scaffolder on EF Core 10 against a small legacy SQLite database and reads what it
produced: names, keys, defaults, join tables, views, tables without a primary key.
Then it covers the options that matter, how to re-run the command without losing
your own code, and how to change the generated code with T4 templates.
Use Cases
- Starting an API on top of a database that another system owns.
- Producing a first model for a large schema, then keeping it by hand.
- Re-scaffolding on every release because the database team, not your code, is the source of truth for the schema.
- Auditing a legacy schema: what the scaffolder cannot express tells you where the database and the object model disagree.
Deep Dive
Setup and the legacy schema
The project needs the provider package and Microsoft.EntityFrameworkCore.Design
(mark it PrivateAssets="all" so it does not flow to consumers), plus the
dotnet-ef tool (dotnet tool install --global dotnet-ef). The legacy schema
uses upper-case snake names on purpose:
plaintextCREATE TABLE CUSTOMER (cust_id INTEGER PRIMARY KEY, cust_name TEXT NOT NULL, email TEXT, status TEXT NOT NULL DEFAULT 'A', created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP); CREATE UNIQUE INDEX UX_CUSTOMER_EMAIL ON CUSTOMER (email); CREATE TABLE ORDER_HDR (ord_id INTEGER PRIMARY KEY, cust_id INTEGER NOT NULL REFERENCES CUSTOMER, total NUMERIC NOT NULL, net NUMERIC GENERATED ALWAYS AS (total * 0.9) STORED); CREATE TABLE ORDER_LINE (ord_id INTEGER NOT NULL REFERENCES ORDER_HDR, line_no INTEGER NOT NULL, sku TEXT NOT NULL, qty INTEGER NOT NULL, PRIMARY KEY (ord_id, line_no)); CREATE TABLE TAG (tag_id INTEGER PRIMARY KEY, label TEXT NOT NULL); CREATE TABLE ORDER_TAG (ord_id INTEGER NOT NULL REFERENCES ORDER_HDR, tag_id INTEGER NOT NULL REFERENCES TAG, PRIMARY KEY (ord_id, tag_id)); CREATE TABLE AUDIT_LOG (at TEXT, what TEXT); CREATE VIEW V_ORDER_TOTALS AS SELECT c.cust_id, c.cust_name, SUM(o.total) AS total_spent FROM CUSTOMER c JOIN ORDER_HDR o ON o.cust_id = c.cust_id GROUP BY c.cust_id, c.cust_name;
plaintextdotnet ef dbcontext scaffold "Data Source=legacy.db" Microsoft.EntityFrameworkCore.Sqlite \ --output-dir Models --context LegacyDb
The two required arguments are the connection string and the provider package name. The command compiles the project first, so it must build.
What comes out
Names are cleaned up to .NET style and the original names are kept in the mapping:
CUSTOMER becomes Customer, cust_id becomes CustId, ORDER_HDR becomes
OrderHdr.
csharpmodelBuilder.Entity<Customer>(entity =>
{
entity.HasKey(e => e.CustId);
entity.ToTable("CUSTOMER");
entity.HasIndex(e => e.Email, "UX_CUSTOMER_EMAIL").IsUnique();
entity.Property(e => e.CustId).ValueGeneratedNever().HasColumnName("cust_id");
entity.Property(e => e.Created).HasDefaultValueSql("CURRENT_TIMESTAMP").HasColumnName("created");
entity.Property(e => e.Status).HasDefaultValue("A").HasColumnName("status");
});Things worth noticing, each one a decision the scaffolder made for you:
- The composite key of
ORDER_LINEbecameHasKey(e => new { e.OrdId, e.LineNo }). - The join table
ORDER_TAGhas no class. It became a many-to-many withUsingEntity<Dictionary<string, object>>, andOrderHdr.TagsandTag.Ordsskip navigations. This is only done for plain join tables: two foreign keys and nothing else. - Every foreign key got
OnDelete(DeleteBehavior.ClientSetNull), because the database declared noON DELETEaction. That is the right reading of the schema, and rarely what you want for an order line. AUDIT_LOGhas no primary key, so it became a keyless entity:HasNoKey().ToTable("AUDIT_LOG"). It can be queried but not updated.- The view became a keyless entity too,
HasNoKey().ToView("V_ORDER_TOTALS"). - The computed column
netcame out asHasComputedColumnSql()with no expression, since the SQLite provider did not recover the formula. Do not feed that model back into migrations. INTEGER PRIMARY KEYcame out asValueGeneratedNever(). SQLite makes such a column an alias of the row id, but the schema never saidAUTOINCREMENT, so EF Core believes the application supplies the key. Adding two customers without an id failed with "another instance with the same key value for {'CustId'} is already being tracked". A column declaredINTEGER PRIMARY KEY AUTOINCREMENTscaffolded withoutValueGeneratedNever, so what you get depends on how the key was declared and on the provider; check every key.
The view shows a limit of reading a schema. SUM(...) has no declared type in
SQLite, the scaffolder saw an empty type and generated public byte[]? TotalSpent;
reading it printed System.Byte[]. After changing the view to
CAST(SUM(o.total) AS REAL) the property became double?. Review the types of
every view column, and prefer fixing the view, or a SqlQuery<T> with your own
type, over trusting the guess.
Options that matter
--table CUSTOMER --table ORDER_HDRlimits the output;--schemaselects whole schemas. By default every table and view is scaffolded.--data-annotationsmoves what attributes can express into the class ([Table("CUSTOMER")],[Column("cust_id")],[Key],[Index(...)],[ForeignKey],[InverseProperty]). The Fluent API stays for the rest: theValueGeneratedNever, the computed column, the delete behavior and the many-to-many are still inOnModelCreating. No attribute exists for them, so the flag cannot remove the Fluent API.--use-database-namesand--no-pluralizekeep names as they are in the database:CUSTOMER.cs,public int cust_id,DbSet<CUSTOMER> CUSTOMER.--context-dir,--output-dir,--namespaceand--context-namespaceplace the files.--no-onconfiguringremoves theOnConfiguringmethod. Without it the connection string, here an absolute path, is written into the source with a#warning. The docs suggestName=ConnectionStrings:Legacyinstead, but it only resolves when the project builds a host withIConfiguration; in this plain console project the command failed with "named connection string ... was not found in the application's configuration". Prefer--no-onconfiguringand register the context withAddDbContext.- Without
--forcea second run refuses to touch existing files ("The following file(s) already exist ... Use the Force flag to overwrite these files").
Re-running without losing your code
--force overwrites the generated files, so never edit them. Two seams exist:
every entity and the context are partial, and the context calls
OnModelCreatingPartial at the end of OnModelCreating. Fluent calls made there
override the generated ones:
csharppublic partial class LegacyDb
{
partial void OnModelCreatingPartial(ModelBuilder modelBuilder)
=> modelBuilder.Entity<OrderLine>()
.HasOne(l => l.Ord).WithMany(o => o.OrderLines)
.HasForeignKey(l => l.OrdId)
.OnDelete(DeleteBehavior.Cascade);
}
public partial class Customer { public string Display => $"{CustName} ({Status})"; }After ALTER TABLE CUSTOMER ADD COLUMN phone TEXT and a --force re-scaffold,
Customer.Phone appeared, Display still compiled, and the delete behavior of
OrderLine printed Cascade. The one thing a partial class cannot do is change a
generated property, such as the type of TotalSpent; that needs a template or a
database change.
Templates
Since EF Core 7 the generated code comes from T4 templates you can own. The docs
add them with dotnet new install Microsoft.EntityFrameworkCore.Templates followed
by dotnet new ef-templates, which creates CodeTemplates/EFCore/DbContext.t4 and
EntityType.t4 in the project. I took the same two files from the NuGet package
without installing a global template, and added one line before the class:
plaintext/// <summary>Scaffolded from <#= EntityType.GetTableName() ?? EntityType.GetViewName() #>.</summary> public partial class <#= EntityType.Name #>
The next scaffold --force printed /// <summary>Scaffolded from CUSTOMER.</summary>
above Customer and V_ORDER_TOTALS above VOrderTotal. A third template,
EntityTypeConfiguration.t4, produces one IEntityTypeConfiguration<T> class per
entity, and commenting out the IsSimpleManyToManyJoinEntityType() check makes the
join tables into classes. After a package upgrade, diff your copies against the new
templates.
Trade-offs
- Scaffold once or scaffold forever. Once means the generated code becomes yours and EF migrations take over the schema; forever means the database is the source of truth and your changes live only in partial classes and templates. Mixing them leads to migrations that fight a database you do not own.
- Legacy names leak into the model.
OrderHdrandCustIdare legible but not your domain language. A partial class cannot rename a generated property, so rename through a template or accept the names. - Guesses on the edge of the schema. Views, computed columns and database
specific types depend on the provider. Check them; the
byte[]above compiled fine and failed only in use. - What the schema cannot say is never scaffolded. Inheritance, owned types, table splitting and most concurrency tokens do not exist in a database, so they do not come out. The inheritance concept shows how to add the first by hand.
- Generated keys.
ValueGeneratedNeveron every integer key is safe for existing rows and wrong for new ones when the database really does generate the value. Fix it inOnModelCreatingPartialwithmodelBuilder.Entity<Customer>().Property(c => c.CustId).ValueGeneratedOnAdd(); with that line, the two inserts that failed before succeeded.