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.

Advanced150–190 minuteslegacy objects + keyless/view labEF Core 10.0.11 · .NET 10.0.11SQLite provider 10.0.11 mandatory baselinedotnet-ef 10.0.11 · SDK 10.0.400Last reviewed: August 2026

Learning outcomes

01

Map database views and tables without keys as keyless read models while respecting their non-tracked, non-updateable EF semantics.

02

Distinguish a stable entity key from a convenient column that only looks unique in sample data.

03

Use FromSql/SqlQuery or provider-supported stored-procedure mappings without pretending reverse engineering generates a full procedure API.

04

Handle legacy names/types through explicit mapping or conversion while preserving the true database contract.

05

Identify when raw SQL, ADO.NET, Dapper, or another dedicated path is more honest than forcing EF entity semantics.

06

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.

Frozen lab baseline

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.

sql · reporting view
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;
csharp · keyless mapping
[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.

sql · no-key legacy import table
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);
Do not invent a primary key because the sample data looks unique

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

csharp · query the read model
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.

csharp · parameterized procedure 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

csharp · provider-supported CUD procedure mapping sketch
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.

csharp · explicit legacy column names
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.

Identity must be a database/application invariant

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

  1. Add work_order_queue_view and legacy_import_rows to the disposable SQLite database.
  2. Re-scaffold the database into scratch and inspect whether the provider generates keyless mappings.
  3. Query the view and verify zero tracked entries.
  4. Insert duplicate source_line values across different batches and explain why source_line is not a safe key.
  5. Create a handwritten keyless LegacyImportRow mapping if the generated shape needs clearer naming.
  6. Use parameterized SqlQuery<T> or a keyless query to read a bounded projection; inspect SQL.
  7. 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.
  8. Reset the database from DDL.
csharp · keyless query projection
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

  1. Why are keyless entity types never normal update targets?
  2. Can a view mapping make EF create the view?
  3. Is a column that happens to be unique in sample data a safe EF key?
  4. Does reverse engineering generate a complete typed API for arbitrary stored procedures?
  5. Why is the SQL Server procedure example optional?
  6. 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.

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.

\n