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.
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.
Trace entity and property discovery from DbSet/modelBuilder/navigation reachability through conventions, explicit configuration, validation, and model finalization.
Inspect the finalized IModel with context.Model, entity/property metadata, and ToDebugString instead of guessing what EF inferred.
Explain the normal model-cache assumption: contexts of the same type share one model unless the cache key is deliberately changed.
Demonstrate why runtime values such as tenant identifiers or per-request table names must not be naively embedded in OnModelCreating.
Distinguish the EF runtime model from the database schema and from a migrations model snapshot.
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.
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.
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
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.
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.
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.
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.”
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.
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
- Reset the disposable ServiceHub SQLite database through the existing migrations lifecycle.
-
Print every entity type, table name, key, property
nullability, max length, and CLR type from
context.Model. -
Print
ToDebugString(MetadataDebugStringOptions.LongDefault)and locate theWorkOrdermapping. - Comment out one explicit max-length configuration, rebuild, and compare the runtime metadata; then restore it.
-
Create the disposable
VariantContextexample and show that a runtime suffix is not a safe default model-variation strategy. - Generate a throwaway migration and inspect the operations/script; do not apply it to unrelated data.
- 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
- What is the purpose of context.Model?
- Does a DbSet property mean EF has already created a database table?
- Which configuration source normally overrides conventions and data annotations?
- Why can per-instance table names fail with the default model cache?
- Does adding a migration automatically change the live database?
- 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
- Creating and Configuring a Model — model conventions, data annotations, Fluent API, configuration grouping, and debug view
- ModelBuilder class — EF Core 10 model-construction API surface
- IModel — finalized runtime metadata contract
- IModelCacheKeyFactory — advanced model cache-key customization
- Managing Migrations — migration model/snapshot workflow and generated migration review
- Microsoft.EntityFrameworkCore 10.0.11 — current stable EF Core package checkpoint