Chapter 16 · Reverse Engineering and Database-First Workflows
Map Stored Procedures, Views, Keyless Types, Legacy Constraints, and Imperfect Schemas Pragmatically
Map legacy views, keyless query types, stored-procedure boundaries, awkward names/types, and tables without stable keys honestly—using EF where its identity/update model fits and raw SQL or another path where it does not.
Learning outcomes
Map database views and tables without keys as keyless read models while respecting their non-tracked, non-updateable EF semantics.
Distinguish a stable entity key from a convenient column that only looks unique in sample data.
Use FromSql/SqlQuery or
provider-supported stored-procedure mappings without
pretending reverse engineering generates a full procedure
API.
Handle legacy names/types through explicit mapping or conversion while preserving the true database contract.
Identify when raw SQL, ADO.NET, Dapper, or another dedicated path is more honest than forcing EF entity semantics.
Build a free/local SQLite lab with a view and keyless table, then verify query-only behavior and failure boundaries.
1. The practical problem: legacy databases contain more than clean tables with primary keys
The final ServiceHub legacy slice contains a reporting view, an import table with no primary key, awkward historical names, and a procedure contract in the production SQL Server deployment. An ORM works best when each updateable entity has stable identity and the database exposes relational constraints that match it. Database-first engineering must not erase the places where that assumption is false.
Course baseline: .NET 10 runtime 10.0.11, SDK 10.0.400, EF Core/dotnet-ef/Microsoft.EntityFrameworkCore.Design/Microsoft.EntityFrameworkCore.Sqlite 10.0.11. SQLite is the mandatory free/local provider. EF Core 11 preview APIs are out of scope unless clearly labeled.
2. Keyless entity types are query shapes, not tracked entities
EF Core keyless entity types use HasNoKey() or
[Keyless]. They are never tracked for changes and
are never targets of normal insert/update/delete persistence.
They are appropriate for views, query projections, and legacy
tables with no declared key when you only need a read model.
CREATE VIEW work_order_queue_view ASSELECT w.work_order_id, w.work_order_number, w.customer_name, w.priority, t.display_name AS technician_nameFROM work_orders AS wLEFT JOIN technicians AS t ON t.technician_id = w.assigned_technician_id;
[Keyless]public sealed class WorkOrderQueueRow{ public long WorkOrderId { get; init; } public string WorkOrderNumber { get; init; } = null!; public string CustomerName { get; init; } = null!; public long Priority { get; init; } public string? TechnicianName { get; init; }}modelBuilder.Entity<WorkOrderQueueRow>(builder =>{ builder.HasNoKey(); builder.ToView("work_order_queue_view");});
ToView tells EF to treat the mapped object as a
read-only query source; EF does not create the view merely
because the mapping exists.
3. Reverse engineering can scaffold views and no-key tables—but that does not create identity
Modern EF reverse engineering can generate keyless types for views and tables without keys. The absence of a key is a hard semantic boundary. A repeated row from a view is not identity-resolved like a normal entity, and EF will not track it for updates.
CREATE TABLE legacy_import_rows ( import_batch TEXT NOT NULL, source_line INTEGER NOT NULL, customer_text TEXT NULL, payload_text TEXT NOT NULL -- deliberately no PRIMARY KEY);
If (import_batch, source_line) is truly
guaranteed unique, add and enforce that key in the database
contract. If it is not guaranteed, pretending it is an EF key
can merge identity, mis-track rows, or update/delete the wrong
data. A read-only keyless mapping is more honest.
4. Query keyless/view data and verify tracker behavior
var rows = await db.Set<WorkOrderQueueRow>() .Where(x => x.Priority >= 3) .OrderByDescending(x => x.Priority) .ThenBy(x => x.WorkOrderId) .ToListAsync(cancellationToken);Console.WriteLine($"Tracked entries: {db.ChangeTracker.Entries<WorkOrderQueueRow>().Count()}");// Expected: 0. Keyless entity types are not tracked for changes.
Generated SQL still matters. Inspect it with
ToQueryString() and the database plan when
performance matters. “Read-only” does not mean “cheap.”
5. Stored procedures: separate querying, CUD mapping, and scaffolding
Three ideas are commonly conflated. First, EF can query entity/result shapes from handwritten SQL/procedure calls using raw-SQL APIs, subject to provider composability rules. Second, EF Core supports mapping insert/update/delete operations to stored procedures where the provider/database supports the required behavior. Third, reverse engineering does not turn an arbitrary database procedure catalog into a complete typed application service layer. Procedure contracts need deliberate code.
| Use case | EF mechanism | Database-first guidance |
|---|---|---|
| Procedure returns entity-shaped rows | FromSql on an entity query root |
Parameterize values; respect provider composition rules |
| Procedure returns unmapped scalar/DTO shape |
Database.SqlQuery<T> where supported
|
Treat column names/types as an explicit contract |
| Entity insert/update/delete via stored procedures |
InsertUsingStoredProcedure,
UpdateUsingStoredProcedure,
DeleteUsingStoredProcedure
|
Provider/version/database contract must be verified; not inferred magically |
| Complex legacy procedure orchestration | Raw ADO.NET/Dapper/service boundary may be clearer | Do not force an EF entity mapping when procedure semantics dominate |
6. SQL Server optional example: query a procedure safely
SQLite has no stored procedures, so this is an optional SQL Server-style illustration. The mandatory lab remains SQLite and uses a view/raw SQL query.
var minimumPriority = 3;var rows = await db.WorkOrders .FromSql($"EXEC dbo.GetOpenWorkOrders @MinimumPriority={minimumPriority}") .AsNoTracking() .ToListAsync(cancellationToken);
Interpolated EF raw-SQL APIs parameterize values. They do not make dynamic table/procedure/column identifiers safe; identifiers are query shape and must come from trusted, allow-listed code. Also, SQL Server stored-procedure calls generally are not composable as arbitrary subqueries—materialize or design the procedure contract accordingly.
7. Stored procedure write mapping is explicit application model configuration
modelBuilder.Entity<LegacyContact>() .InsertUsingStoredProcedure("LegacyContact_Insert", sp => { sp.HasParameter(x => x.Name); sp.HasResultColumn(x => x.Id); }) .UpdateUsingStoredProcedure("LegacyContact_Update", sp => { sp.HasOriginalValueParameter(x => x.Id); sp.HasParameter(x => x.Name); sp.HasRowsAffectedResultColumn(); });
This capability exists in EF Core, but procedure definition, result/rows-affected contract, provider support, triggers, transactions, concurrency, and schema ownership must be tested against the actual engine. The SQLite lab cannot reproduce SQL Server procedure semantics and should not pretend to.
8. Legacy names and types: adapt deliberately, do not falsify the store
A database-first adapter may expose friendlier C# names while mapping exact historical column names. Type conversions can make application code easier to use, but only if they are reversible and consistent with queries/writes. Unsupported provider types may not scaffold at all.
modelBuilder.Entity<LegacyCustomer>(builder =>{ builder.ToTable("T_CUST_1998"); builder.HasKey(x => x.Id); builder.Property(x => x.Id).HasColumnName("CUST_NO"); builder.Property(x => x.DisplayName).HasColumnName("CUST_NM"); builder.Property(x => x.ActiveFlag).HasColumnName("ACT_FLG");});
If the database stores ambiguous sentinel values, overloaded columns, or denormalized text payloads, document that debt. A converter can change the CLR representation; it cannot create missing constraints or repair corrupted data semantics.
9. Failure case: force an updateable entity onto a no-key table
A developer chooses source_line as the key because
it is unique in one import batch. Two batches both contain line
42. EF identity resolution now treats those rows as the same
entity key inside one context, or update predicates target the
wrong logical row if the database is modified. The repair is to
model the real key in the database if one exists, use a
composite key only if the database guarantees it, or keep the
object keyless/read-only.
A primary key is not “the column I can use to make EF stop complaining.” It is the stable identity contract that underpins tracking, relationships, updates, deletes, and concurrency.
10. When a separate data-access path is more honest
| Legacy shape | EF entity? | Better boundary when needed |
|---|---|---|
| Normal table with stable PK/FKs | Usually yes | EF tracking/query/write pipeline |
| Read-only reporting view | Keyless EF type often fits | Projection/SqlQuery if a full entity model adds no value |
| No-key staging/import table | Keyless read model; avoid tracked updates | Provider SQL/ADO.NET for controlled staging operations |
| Procedure-centric transaction API | Sometimes raw SQL through EF | ADO.NET/Dapper/service adapter when procedure contracts dominate |
| Bulk loader/import protocol | Not ordinary tracked entity persistence | Provider-native bulk path with explicit transaction/error semantics |
Using more than one data-access tool is not architectural failure. The important boundary is explicit correctness, observability, transactions, security, and testability.
11. Lab: view + keyless table + safe raw SQL
-
Add
work_order_queue_viewandlegacy_import_rowsto the disposable SQLite database. - Re-scaffold the database into scratch and inspect whether the provider generates keyless mappings.
- Query the view and verify zero tracked entries.
-
Insert duplicate
source_linevalues across different batches and explain whysource_lineis not a safe key. -
Create a handwritten keyless
LegacyImportRowmapping if the generated shape needs clearer naming. -
Use parameterized
SqlQuery<T>or a keyless query to read a bounded projection; inspect SQL. - Attempt no tracked update of the keyless type; instead use a deliberately reviewed raw SQL command only if the lab needs to mutate staging data.
- Reset the database from DDL.
public sealed class LegacyImportProjection{ public string ImportBatch { get; init; } = null!; public long SourceLine { get; init; } public string? CustomerText { get; init; }}public static Task<List<LegacyImportProjection>> LoadImportsAsync( ServiceHubLegacyContext db, string batch, CancellationToken cancellationToken) => db.Database.SqlQuery<LegacyImportProjection>($""" SELECT import_batch AS ImportBatch, source_line AS SourceLine, customer_text AS CustomerText FROM legacy_import_rows WHERE import_batch = {batch} ORDER BY source_line """) .ToListAsync(cancellationToken);
Confirm the parameter is sent as a parameter in EF command logs. Do not concatenate untrusted values into SQL text.
12. Production judgment and bridge to performance engineering
Database-first succeeds when the adapter tells the truth about the store. Views and no-key tables are not automatically updateable entities. Stored procedures are explicit contracts with provider-specific semantics. Legacy naming/types can be mapped, but missing identity and constraints must not be invented in application code. Test the generated/read-write behavior against the actual production-like provider.
Chapter 17 now asks the next operational question: once the model—generated or hand-built—is correct, how much does it cost? The course moves into projection strategy, compiled queries/models, pooling, and evidence from real database execution plans.
Check your understanding
- Why are keyless entity types never normal update targets?
- Can a view mapping make EF create the view?
- Is a column that happens to be unique in sample data a safe EF key?
- Does reverse engineering generate a complete typed API for arbitrary stored procedures?
- Why is the SQL Server procedure example optional?
- When is another data-access tool appropriate?
Review the answers
1. They have no EF identity key and are not tracked for changes.
2. No. ToView assumes the
database object already exists.
3. No. Identity must be a stable enforced invariant, not an observation from one dataset.
4. No. Procedure querying/write mapping requires deliberate code and provider/database contract verification.
5. SQLite has no stored procedures; mandatory labs remain free/local SQLite and provider differences are explicit.
6. When procedure-centric, staging, bulk, or unusual legacy semantics are clearer and safer outside normal tracked EF entity persistence.
Authoritative references
Reverse engineering is provider- and version-sensitive. Re-check these primary sources when regenerating the chapter or adapting it to another database engine.
- Keyless Entity Types - EF Core — keyless tracking/update limitations and view/query scenarios
- Reverse Engineering - EF Core — view/no-key scaffolding and inference limits
- SQL Queries - EF Core — FromSql, SqlQuery, parameterization and composability
- Stored procedure mapping - EF Core 7+ — insert/update/delete stored procedure mapping concepts
- Entity Types - EF Core — table/view mapping semantics
- SQLite provider — mandatory local provider context