Chapter 03 · Model Building: Conventions, Data Annotations, Fluent API, and Metadata

How EF Builds the Model: Conventions, Discovery, Finalization, and Model Metadata

Trace EF Core model construction from discovery and conventions through configuration, finalization, metadata inspection, and the model-cache contract.

Intermediate90–110 minutesmodel metadata + cache-behavior labEF Core 10.0.11 · .NET 10SQLite provider 10.0.11 baselineLast reviewed: August 2026

Learning outcomes

ServiceHub now has a stable context lifetime and a repeatable SQLite lab. The next source of surprises is the EF Core model: the metadata graph that tells EF which CLR types are entities, which properties persist, how keys and relationships are interpreted, and how those concepts map to a relational store. If the team treats model building as magic, migrations and generated SQL will appear to change “by themselves.” This lesson makes the construction pipeline observable.

01

Trace entity and property discovery from DbSet/modelBuilder/navigation reachability through conventions, explicit configuration, validation, and model finalization.

02

Inspect the finalized IModel with context.Model, entity/property metadata, and ToDebugString instead of guessing what EF inferred.

03

Explain the normal model-cache assumption: contexts of the same type share one model unless the cache key is deliberately changed.

04

Demonstrate why runtime values such as tenant identifiers or per-request table names must not be naively embedded in OnModelCreating.

05

Distinguish the EF runtime model from the database schema and from a migrations model snapshot.

Chapter 01–02 continuity

Keep ServiceHubEfCourse, ServiceHub.EfLab, ServiceHubContext, the existing WorkOrder entity, deterministic seed data, SQLite provider 10.0.11, migrations-based reset, and external configuration. Chapter 03 changes model configuration—not context ownership or the database provider.

1. The model is EF Core's executable mapping contract

A CLR class is not automatically a database table, and a table is not automatically a CLR class. EF Core builds an IModel that connects the object side to the provider/database side. Entity types, properties, keys, navigations, indexes, value generation, conversions, table/column mappings, and relational annotations all live in this metadata graph.

The model is used by query translation, materialization, change tracking, update command generation, and migrations. That is why a small configuration choice can affect several later behaviors. For example, changing Summary from optional to required is not merely a C# validation preference: it can change model nullability, generated DDL, materialization assumptions, and update failures.

Artifact What it represents What it does not prove
context.Model The finalized runtime EF metadata for this context That the live database schema actually matches it
Migration model snapshot The model state EF migrations last recorded in source That the database applied every migration successfully
Live database schema The provider/database objects that currently exist That EF currently maps them as intended
Generated SQL A provider translation for a concrete query/update The complete runtime execution plan or performance result

2. Discovery starts before OnModelCreating finishes the job

EF can discover entity types from DbSet<T> properties, explicit modelBuilder.Entity<T>() calls, and types reached through discovered navigations. Built-in conventions then infer common patterns such as Id/{TypeName}Id keys, scalar properties, requiredness from nullable reference-type metadata, relationship candidates, and store-generation patterns.

csharp · existing ServiceHub model with a convention-discovered key
public sealed class WorkOrder{    public int Id { get; set; }    public string CustomerName { get; set; } = string.Empty;    public string Summary { get; set; } = string.Empty;    public WorkOrderPriority Priority { get; set; } = WorkOrderPriority.Normal;    public DateTimeOffset OpenedUtc { get; set; }}public sealed class ServiceHubContext(DbContextOptions<ServiceHubContext> options)    : DbContext(options){    public DbSet<WorkOrder> WorkOrders => Set<WorkOrder>();}

WorkOrder enters the model through the DbSet. Id matches the primary-key convention. The remaining public scalar properties are mapped by convention. This is a useful starting point, not a reason to stop thinking: database names, length/precision, provider-specific facets, indexes, relationships, and invariants often need explicit decisions.

3. Configuration sources form a precedence pipeline

Built-in conventions provide defaults. Mapping attributes/data annotations can override many convention results. Fluent API calls in OnModelCreating can override both conventions and data annotations. EF then finalizes and validates the model before normal runtime use.

csharp · explicit configuration layered over conventions
protected override void OnModelCreating(ModelBuilder modelBuilder){    var workOrder = modelBuilder.Entity<WorkOrder>();    workOrder.ToTable("WorkOrders");    workOrder.HasKey(x => x.Id);    workOrder.Property(x => x.CustomerName)        .HasMaxLength(120)        .IsRequired();    workOrder.Property(x => x.Summary)        .HasMaxLength(400)        .IsRequired();}

The explicit HasKey duplicates what the key convention already inferred, but it can be useful when teaching or making an important contract visible. In production code, avoid mechanically configuring every convention; explicitness has maintenance cost. Configure where the domain/database contract would otherwise be ambiguous or fragile.

4. Inspect the finalized model instead of reading intent from source

csharp · runtime metadata probe
await using var db = new ServiceHubContext(options);foreach (var entityType in db.Model.GetEntityTypes()){    Console.WriteLine($"Entity: {entityType.ClrType.Name}");    Console.WriteLine($"Table:  {entityType.GetTableName()}");    Console.WriteLine($"Schema: {entityType.GetSchema() ?? "<default>"}");    foreach (var property in entityType.GetProperties())    {        Console.WriteLine(            $"  {property.Name,-16} nullable={property.IsNullable,-5} " +            $"max={property.GetMaxLength()?.ToString() ?? "-"}");    }}

This probe asks the model what EF finalized. It is more reliable than saying “the attribute probably won” or “SQLite probably made this nullable.” For deeper diagnostics, EF exposes a debug string.

csharp · long model debug view
using Microsoft.EntityFrameworkCore.Infrastructure;Console.WriteLine(    db.Model.ToDebugString(MetadataDebugStringOptions.LongDefault));

Debug output is diagnostic text, not a stable machine-readable contract. Do not write brittle production logic that parses its exact formatting across patches. Use metadata APIs for automated assertions.

5. Finalized model metadata is normally cached

Building a model has non-trivial cost. EF therefore normally builds it once for a context type and reuses the result. The default model-cache assumption is crucial: all instances of a given context type are expected to use the same model. Connection string, request identity, user locale, and tenant id are runtime state; they should not casually change table mappings inside OnModelCreating.

csharp · intentionally broken runtime-dependent table mapping
public sealed class VariantContext(    DbContextOptions<VariantContext> options,    string suffix) : DbContext(options){    private readonly string _suffix = suffix;    public DbSet<WorkOrder> WorkOrders => Set<WorkOrder>();    protected override void OnModelCreating(ModelBuilder modelBuilder)        => modelBuilder.Entity<WorkOrder>()            .ToTable($"WorkOrders_{_suffix}");}

If one VariantContext instance builds the model with suffix A, another instance of the same context type can reuse that cached model even if constructed with suffix B. The second runtime value does not automatically create a second model. That can route operations to the wrong relational object.

csharp · observe the cache assumption
var variantOptions = new DbContextOptionsBuilder<VariantContext>()    .UseSqlite("Data Source=servicehub-model-cache.db")    .Options;using var a = new VariantContext(variantOptions, "A");using var b = new VariantContext(variantOptions, "B");Console.WriteLine(a.Model.FindEntityType(typeof(WorkOrder))!.GetTableName());Console.WriteLine(b.Model.FindEntityType(typeof(WorkOrder))!.GetTableName());// With the default cache-key assumption, do not expect A then B.// The first finalized model can be reused for both instances.

6. Repair the broken design at the right boundary

The preferred repair is architectural: keep a stable model and represent runtime tenant/customer/region state as data, query predicates, database security policy, connection choice, or another explicit runtime mechanism. Chapter 22 treats multi-tenancy in depth.

EF exposes IModelCacheKeyFactory for the rarer case where one context type genuinely needs multiple model shapes. That is an advanced contract: every value that changes the model must participate in the cache key, and the design has memory/startup, migrations, tooling, compiled-model, testing, and pooling consequences. Do not introduce a custom cache key merely to make per-request table-name interpolation “work.”

Production judgment

A model that varies by request is a high-complexity design. Prefer a stable model unless there is a strong schema-level requirement and a complete deployment/tooling/test strategy. Model caching is an optimization built on a correctness assumption, not a place to hide mutable application state.

7. Runtime model, migrations snapshot, and live database can drift independently

Chapter 01 created migrations and a snapshot. A runtime model change does not rewrite an existing database by itself. A new migration is produced by comparing the current design-time model to the prior snapshot; applying that migration changes the database. If someone manually edits the database, the runtime model and snapshot do not automatically learn about that edit.

text · safe drift-evidence workflow
dotnet tool run dotnet-ef -- migrations list --project src/ServiceHub.EfLabdotnet tool run dotnet-ef -- migrations add Chapter03ModelProbe --project src/ServiceHub.EfLabdotnet tool run dotnet-ef -- migrations script --project src/ServiceHub.EfLab# Inspect generated migration operations and SQL before applying anything.# Remove the probe migration if it was created only for inspection:dotnet tool run dotnet-ef -- migrations remove --project src/ServiceHub.EfLab

8. Hands-on lab: reconstruct the model from evidence

  1. Reset the disposable ServiceHub SQLite database through the existing migrations lifecycle.
  2. Print every entity type, table name, key, property nullability, max length, and CLR type from context.Model.
  3. Print ToDebugString(MetadataDebugStringOptions.LongDefault) and locate the WorkOrder mapping.
  4. Comment out one explicit max-length configuration, rebuild, and compare the runtime metadata; then restore it.
  5. Create the disposable VariantContext example and show that a runtime suffix is not a safe default model-variation strategy.
  6. Generate a throwaway migration and inspect the operations/script; do not apply it to unrelated data.
  7. Restore the repository to the stable ServiceHub mapping.

Verification checklist

  • You can name at least three ways an entity type can enter the model.
  • You can distinguish convention inference from explicit Fluent configuration.
  • You can inspect table/property metadata without parsing debug text.
  • You can explain why runtime request state should not normally change OnModelCreating.
  • You can distinguish runtime model, migrations snapshot, and live database schema.

Check your understanding

  1. What is the purpose of context.Model?
  2. Does a DbSet property mean EF has already created a database table?
  3. Which configuration source normally overrides conventions and data annotations?
  4. Why can per-instance table names fail with the default model cache?
  5. Does adding a migration automatically change the live database?
  6. When should IModelCacheKeyFactory be considered?
Review the answers

context.Model exposes the finalized EF metadata used for mapping/query/update behavior.

No. It contributes model discovery; database schema changes require creation/migrations or external DDL.

Fluent API configuration has the highest normal precedence among those sources.

The default model cache assumes one model per context type, so a model built for one runtime value can be reused for another.

No. A migration is source metadata/code until it is applied or its SQL is executed.

Only when the model genuinely must vary and every cache/tooling/deployment consequence is understood; a stable model is preferable for ordinary request/tenant state.

9. Summary and bridge

EF Core model building is a pipeline, not a reflection trick: types are discovered, conventions infer metadata, explicit configuration refines it, the model is validated/finalized, and the result is normally cached. The database schema is related but separate. Once you can interrogate IModel, later mapping problems become evidence-driven.

Lesson 2 uses that mental model to make relational mappings explicit: tables, schemas, columns, nullability, lengths, precision/scale, Unicode, comments, and the important distinction between CLR semantics and provider storage semantics.

Authoritative references

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