← Open in the full interactive course (progress tracking, search & more)

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

ConventionWhat EF Core infers
A property named Id, or <ClassName>IdThe primary key
Class name ProductTable name Products (pluralized, provider-dependent)
int/long primary key propertyAn 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? DescriptionA nullable column
decimal propertyA 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

HOW EF CORE BUILDS ITS MODEL OF YOUR SCHEMA
Step 1 — Discover entity types
Step 2 — Apply conventions
Step 3 — Apply explicit configuration, which always wins over convention
Step 4 — The finished model drives everything else

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

A Smart Default, Not a Rigid Rule

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

HOW EF CORE RESOLVES CONFLICTS BETWEEN THE THREE SOURCES
1. Conventions run first, producing an initial model
2. Data annotations are applied on top, overriding any convention they touch
3. Fluent API configuration in OnModelCreating is applied last, and wins over both
4. The finished model is cached for the application's lifetime

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?

SituationPrefer
Simple, obvious rules — required, max length, a non-default key propertyData annotations, for readability right on the class
Composite keys, unique indexes, precise column types, relationship detailsFluent 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 projectEither — consistency matters more than which one you pick
Rule of thumb: Let convention do the obvious work. Reach for data annotations for simple, per-property rules; reach for the Fluent API for anything more structural — unique indexes, exact column types, composite keys, and relationships (the next lesson).

Mental Model

Convention = EF Core's smart, reasonable defaults, inferred from your class shape.
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


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.


dotnetmadeeasy.com — Learn C# and .NET, the right way.