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.
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.
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.
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.
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.
| 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 |
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.
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; }
}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.
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.
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.
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.
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).
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.
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.
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.
| 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 |
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?
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?
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?
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.