EF Core Entities: Conventions, Data Annotations, Fluent API
A plain C# class becomes a database table not by magic, but by a set of well-defined conventions — and you can always override them when the defaults guess wrong.
Here's a class with nothing special about it at all:
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public int Stock { get; set; }
}Add a DbSet<Product> Products to your DbContext, run a migration, and EF Core creates a Products table with an Id column as the primary key, an auto-incrementing identity, a Name column that can't be null, a decimal Price column, and an int Stock column — all without you writing a single line of schema-mapping configuration. That's not luck. It's a deliberate, learnable set of rules called conventions.
In this lesson, you'll learn how to define entity classes, the conventions EF Core uses to infer your schema from them, and the two ways to override those conventions when they guess wrong: data annotations and the Fluent API.
What Is It?
The Simple Explanation
An entity is a plain C# class that EF Core maps to a database table. Each instance of the class represents one row; each public property (with a getter and setter) becomes a column. There's no special base class to inherit from and no interface to implement — that's intentional, and it's what "plain" means here.
The Technical Definition
An entity type is any class exposed through a DbSet<T> on your DbContext (or reachable through a navigation property from one that is — the subject of the next lesson). EF Core builds a model describing every entity type, its properties, its key, and its relationships — partly by inspecting your classes through convention, and partly from any explicit configuration you supply.
Why Does It Exist?
The Problem — Every Class Needs a Table, But Configuring Each One By Hand Is Tedious
Some ORMs in other ecosystems require verbose, explicit mapping configuration for every single property of every single class — a wall of setup code before you can run your first query. Most of that configuration, most of the time, is entirely predictable: a property named Id is almost always the primary key; a string property almost always maps to a text column; a class named Product almost always maps to a table named (something like) Products.
The Solution — "Convention Over Configuration"
EF Core follows a design philosophy called convention over configuration: it applies sensible, predictable default mapping rules automatically, so you only have to write explicit configuration for the cases where the defaults are wrong or insufficient for your specific schema. This keeps everyday entity classes clean and readable, while still giving you full control — via data annotations or the Fluent API — whenever you need it.
Big Picture
| Convention | What EF Core infers |
|---|---|
| A property named Id, or <ClassName>Id | The primary key |
| Class name Product | Table name Products (pluralized, provider-dependent) |
| int/long primary key property | An auto-incrementing identity column |
| Non-nullable reference type, e.g. string Name (not string?) | A NOT NULL column, thanks to nullable reference type annotations |
| Nullable reference type, e.g. string? Description | A nullable column |
| decimal property | A decimal column, default precision (usually needs an explicit precision/scale for money — see Common Mistakes) |
| A property with no getter/setter, or marked [NotMapped] | Excluded from the table entirely |
How It Works
- Every type exposed as a DbSet<T> on your context, plus any type reachable from one via a navigation property.
- Infer the primary key, column types/nullability, and table name from each class's shape, as in the table above.
- Data annotations on the class, and/or Fluent API rules in OnModelCreating — both covered below.
- Migrations (generating CREATE TABLE statements), query SQL generation, and change tracking all read from this one model.
Simple Example
Here's a slightly more realistic Product entity, using current C# nullable reference type annotations to communicate which columns should allow NULL:
public class Product
{
public int Id { get; set; } // convention: primary key, identity
public required string Name { get; set; } // non-nullable → NOT NULL column
public string? Description { get; set; } // nullable → nullable column
public decimal Price { get; set; }
public int Stock { get; set; }
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
}Nullable reference types aren't just a compiler feature here — EF Core's SQL Server provider reads that same nullability information to decide whether a column allows NULL. A string property is mapped NOT NULL; a string? property is mapped nullable. This is a genuine, practical reason to keep nullable reference types enabled in an EF Core project.
Overriding conventions: data annotations
When a default guess isn't what you want, attributes on the entity class are the quickest fix:
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
public class Product
{
[Key] // explicit, in case the property isn't named Id
public int ProductId { get; set; }
[Required]
[MaxLength(100)]
public required string Name { get; set; }
[Column(TypeName = "decimal(10,2)")] // explicit precision/scale for money
public decimal Price { get; set; }
[NotMapped] // computed in C#, not stored as a column
public bool IsLowStock => Stock < 10;
public int Stock { get; set; }
}Overriding conventions: the Fluent API
The Fluent API configures the same things from inside OnModelCreating, on the DbContext, instead of on the entity class itself:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>(entity =>
{
entity.HasKey(p => p.ProductId);
entity.Property(p => p.Name).IsRequired().HasMaxLength(100);
entity.Property(p => p.Price).HasColumnType("decimal(10,2)");
entity.Ignore(p => p.IsLowStock);
});
}Both approaches configure exactly the same model — the difference is where the configuration lives.
Real-World Example
A blog/CMS platform typically has a Post entity where a few real-world constraints genuinely need explicit configuration rather than relying purely on convention: a title with a maximum length enforced by the database (not just validated in C#), a slug that must be unique for URL routing, and a large body of text that shouldn't be capped like a short column:
public class Post
{
public int Id { get; set; }
public required string Title { get; set; }
public required string Slug { get; set; }
public required string Body { get; set; }
public DateTime PublishedAtUtc { get; set; }
}
// In OnModelCreating:
modelBuilder.Entity<Post>(entity =>
{
entity.Property(p => p.Title).HasMaxLength(200);
entity.Property(p => p.Slug).HasMaxLength(200);
entity.HasIndex(p => p.Slug).IsUnique(); // enforce uniqueness at the database level
entity.Property(p => p.Body).HasColumnType("nvarchar(max)");
});Convention alone gets you a working table quickly, but a real schema almost always needs a handful of these explicit rules — a unique index here, an exact column type there — layered on top.
Analogy
Think of conventions like a well-designed form that pre-fills reasonable defaults based on what you've typed elsewhere — your shipping address gets suggested as your billing address, your country field defaults based on your locale. Most of the time, the guess is exactly right and you move on. When it's wrong for your specific case, you simply overwrite that one field — you don't have to fill out the entire form manually just because one default didn't fit.
Data annotations and the Fluent API are that "overwrite this one field" step — targeted overrides for the specific places convention guessed wrong, layered on top of everything convention already got right for free.
Under the Hood
- This makes the Fluent API the most powerful and most precise of the three — and the only place some configuration (like composite keys spanning multiple properties) can be expressed at all.
- Building the model has real cost, so EF Core builds it once per context type and reuses it — another reason a context type's model shouldn't change dynamically at runtime.
Common Confusion
1. "Data annotations and the Fluent API are two entirely different systems"
They're two different syntaxes for configuring the same underlying model — most everyday rules (required, max length, key) are expressible either way. The Fluent API can express things annotations simply can't (composite keys, some relationship configuration), which is why many real projects use annotations for simple, obvious rules and the Fluent API for anything more involved — not because they're incompatible, but because the Fluent API is strictly more capable.
2. "An entity class needs a parameterless constructor and virtual properties like older ORMs required"
Modern EF Core is considerably more flexible than that older stereotype — it can materialize entities using constructors with parameters, and doesn't require properties to be virtual unless you're specifically opting into lazy-loading proxies (a feature this intermediate module doesn't cover, and which most modern EF Core codebases avoid in favor of explicit, eager loading).
Common Mistakes
Mistake 1 — Leaving decimal properties without an explicit precision/scale
A decimal Price property with no configuration at all can trigger a provider warning and default to a precision that silently truncates values you didn't expect it to. Always specify precision and scale explicitly for money-like values — [Column(TypeName = "decimal(10,2)")] or the equivalent Fluent API call.
Mistake 2 — Assuming a property is excluded just because you don't reference it in queries
Adding a computed helper property (a full-name concatenation, a derived flag) and expecting EF Core to skip it automatically because "it's not really data." Any public property with a getter and setter is mapped by default, whether you query it directly or not. Use [NotMapped] (or the Fluent API's .Ignore(...)) for anything computed in C# that shouldn't become a real column — or use an expression-bodied property (getter only, no setter) which conventions exclude automatically.
Mistake 3 — Fighting convention instead of renaming to match it
Writing extensive Fluent API configuration to force a property named ProdId to be recognized as a primary key, when simply renaming it to Id or ProductId would let convention handle it automatically. When it's easy to do, name things so convention just works — reach for explicit configuration for the cases where convention genuinely can't guess what you need, not as a substitute for a sensible name.
When Should I Use It?
| Situation | Prefer |
|---|---|
| Simple, obvious rules — required, max length, a non-default key property | Data annotations, for readability right on the class |
| Composite keys, unique indexes, precise column types, relationship details | Fluent API — more expressive, and some of this can't be done with annotations at all |
| Keeping entity classes free of any EF-specific attributes (e.g. to reuse them outside an EF context) | Fluent API exclusively — annotations couple the class itself to EF Core |
| A quick prototype or learning project | Either — consistency matters more than which one you pick |
Mental Model
Data annotations = quick, per-property overrides written on the entity class itself.
Fluent API = the most powerful, most explicit configuration, written in OnModelCreating — always wins if there's a conflict.
Remember: you're not configuring three separate things — you're building one model, from three layered sources.
Key Takeaway
- An entity is a plain C# class exposed via a DbSet<T> — EF Core maps its instances to rows and its properties to columns.
- EF Core uses convention over configuration — sensible defaults for keys, column types, and table names, inferred from your class shape and nullable reference type annotations.
- Data annotations override convention on the class itself; the Fluent API overrides both, in OnModelCreating, and is the only place some configuration can be expressed.
- Always specify explicit precision/scale for decimal properties representing money.
- Use [NotMapped] / .Ignore(...) for computed properties that shouldn't become real columns.
Check Your Understanding
You've seen how EF Core infers a schema from your classes, and how to step in when it guesses wrong. Let's check it clicked.
1. By convention, what does EF Core do with an int property named Id on an entity class?
Show answer
Correct: B
Why B is correct: A property named Id (or <ClassName>Id) is one of EF Core's core key-detection conventions — an int or long typed key property is additionally mapped as an auto-incrementing identity column by default.
Why A is incorrect: Id is specifically recognized by EF Core's key-discovery convention — it's one of the most fundamental conventions in the framework.
Why C is incorrect: Id would be mapped to an integer column matching its .NET type, not a text column — and its special meaning is being the primary key, not being a "required unique text" field.
Why D is incorrect: EF Core infers foreign keys from navigation properties and naming patterns like <NavigationProperty>Id on a *different* entity, not from a plain Id property on the entity itself.
Reinforcement: Naming a key property Id (or ClassNameId) is one of the most reliable ways to let convention handle your primary key with zero configuration.
2. An entity has public string? Description { get; set; }. What column nullability does EF Core infer by convention?
Show answer
Correct: B
Why B is correct: EF Core reads nullable reference type annotations to decide column nullability by convention — a string? property maps to a nullable column, while a plain string property maps to a NOT NULL column.
Why A is incorrect: This would be true for a non-nullable string property, but Description is explicitly declared nullable with the ? annotation.
Why C is incorrect: String properties are one of the most commonly mapped types — no explicit configuration is required for a basic string column.
Why D is incorrect: Nullability is determined by the declared type (the ? annotation), not by whatever value the property happens to hold when an instance is created.
Reinforcement: Nullable reference types aren't just a compile-time C# feature in an EF Core project — they directly drive real database column nullability by convention.
3. A developer has both a data annotation ([MaxLength(50)]) and a Fluent API rule (.HasMaxLength(100)) configuring the same property's max length. Which one takes effect?
Show answer
Correct: B
Why B is correct: EF Core builds its model in layers: conventions first, then data annotations, then Fluent API configuration in OnModelCreating — with each later layer able to override the ones before it. The Fluent API's 100 wins.
Why A is incorrect: Data annotations are overridden by Fluent API configuration when both target the same property — being "on the class" doesn't give it priority.
Why C is incorrect: This isn't treated as an unresolvable conflict — EF Core has a defined precedence order (Fluent API wins), so it resolves silently rather than throwing.
Why D is incorrect: EF Core doesn't merge or pick the more restrictive value automatically — Fluent API configuration simply overrides the annotation entirely.
Reinforcement: When both configuration styles target the same thing, remember the order: convention, then data annotations, then Fluent API — each layer overriding the last.
You now know how to define entities and configure them when convention isn't enough. Next up: connecting entities to each other with relationships.