Entities and Configuration
By the end of this lesson
Map classes to tables using conventions and explicit configuration.
An entity is an ordinary C# class that EF Core maps to a table. There is no base class to inherit and no attribute you must apply. A class becomes an entity because a DbSet exposes it or because something else in the model references it.
Most of the mapping is decided for you by conventions: rules EF Core applies when you have not said otherwise. Learn the conventions first. Configuration is for the cases where the convention is wrong or the database needs more detail than a C# type can express.
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string? Description { get; set; }
public decimal UnitPrice { get; set; }
public bool IsDiscontinued { get; set; }
}
public class Customer
{
public int Id { get; set; }
public string CompanyName { get; set; } = "";
public string? ContactEmail { get; set; }
public DateOnly RegisteredOn { get; set; }
}- No attributes, no base class, nothing EF-specific. These classes would compile and behave identically in a project with no database at all, which is a property worth protecting.
- Public properties with both a getter and a setter become columns. A read-only computed property is left out of the mapping, because EF Core has no way to write to it.
- The ? on Description and ContactEmail is doing real work. With nullable reference types enabled, EF Core reads it as a nullable column; Name and CompanyName become NOT NULL.
- The = "" initialisers exist to satisfy the C# compiler, not EF Core. EF Core sets the property from the database when it materialises a row.
The conventions that produced the schema for those two classes:
- A property named Id, or ProductId on a class named Product, becomes the primary key
- An integer primary key becomes an identity column, so the database generates values
- The table takes its name from the DbSet property, so DbSet<Product> Products maps to a table called Products
- Each column takes the property's name, and its type comes from the property's type
- A non-nullable property becomes a NOT NULL column; a nullable one allows nulls
- A string with no length specified becomes the provider's largest text type, which on SQL Server is nvarchar(max)
- A navigation property together with a matching foreign key property wires up a relationship, which the next lesson covers
Two ways to override a convention. Both are supported, both work, and they differ in where the instruction lives:
| Data annotations | Fluent API | |
|---|---|---|
| Where it is written | As attributes on the entity class itself | In OnModelCreating, or a configuration class per entity |
| Effect on the domain class | The class now references EF and database concerns | The class stays free of persistence detail |
| Discoverability | High — the rule sits beside the property | Lower — you look in a second file |
| What it can express | A useful subset: lengths, required, keys, column names | Everything, including composite keys, indexes, delete behaviour and value conversions |
| Same class, two databases | Difficult — one set of attributes for both | Straightforward — a different configuration per context |
| Reuse elsewhere | Some annotations also drive ASP.NET model validation | No effect outside EF Core |
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.ToTable("Products");
builder.Property(p => p.Name)
.HasMaxLength(120)
.IsRequired();
builder.Property(p => p.Description)
.HasMaxLength(1000);
builder.Property(p => p.UnitPrice)
.HasPrecision(18, 2);
builder.Property(p => p.IsDiscontinued)
.HasDefaultValue(false);
builder.HasIndex(p => p.Name)
.IsUnique();
}
}- One class per entity, found automatically by the ApplyConfigurationsFromAssembly call in OnModelCreating from the DbContext lesson. OnModelCreating itself stays short however many entities you add.
- HasMaxLength(120) turns nvarchar(max) into nvarchar(120). This matters more than it looks: a max-length column cannot be indexed on SQL Server, and it tells a reader nothing about what the data is.
- HasPrecision(18, 2) fixes the decimal. Without it the provider picks a default scale and your prices silently round — 24.995 stored as 24.99 or 25.00 depending on the type chosen.
- HasIndex creates a database index. Uniqueness enforced here holds for every writer, including a script run by hand, which is not true of a check written in C#.
- IsRequired is redundant when nullable reference types are on and the property is not nullable. Writing it anyway is a defensible choice — it states the intent where someone reviewing the mapping will see it.
Summary
- An entity is a plain class; conventions map it to a table without any attributes
- Conventions cover the key, the table name, the column names and nullability
- Data annotations and the Fluent API both override conventions; the Fluent API can express more
- Prefer the Fluent API to keep database detail out of domain classes, accepting that the rules then live in two files
- Always set a maximum length on strings and a precision on decimals that hold money
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Configure the Employee entity
Write an Employee class with Id, Name, EmailAddress, JobTitle, AnnualSalary and StartDate, then write an IEntityTypeConfiguration for it.
Decide a sensible maximum length for each string, set the precision on the salary, and make the email address unique. Nothing in the entity class itself should reference EF Core.
Show solution
The lengths are your decision and there is no single right answer. Aim for a size that fits real data comfortably and would look wrong if it were exceeded. An arbitrary 50 for an email address is too tight; nvarchar(max) is no constraint at all.
HasPrecision(18, 2) on the salary is the part that would cause a real defect if omitted. Currency without an explicit scale is a rounding bug waiting for the first awkward number.
The unique index on the email address is enforced by the database, so it holds even when a row is inserted by something other than your application. A uniqueness check written in C# only covers the code paths you remembered.
Keeping the attributes out of Employee is what makes the class reusable. If you later add a second context for reporting, it can map the same class with different rules.
public class EmployeeConfiguration : IEntityTypeConfiguration<Employee>
{
public void Configure(EntityTypeBuilder<Employee> builder)
{
builder.Property(e => e.Name).HasMaxLength(150).IsRequired();
builder.Property(e => e.EmailAddress).HasMaxLength(256).IsRequired();
builder.Property(e => e.JobTitle).HasMaxLength(100);
builder.Property(e => e.AnnualSalary).HasPrecision(18, 2);
builder.HasIndex(e => e.EmailAddress).IsUnique();
}
}Think about it
Where does the rule belong?
A requirement says a product name must be between three and 120 characters, and must be unique.
Which parts of that belong in EF Core configuration, which belong elsewhere, and why is the answer not the same for both halves?
Show solution
The maximum length and the uniqueness belong in the mapping, because both are things the database can enforce. A column of nvarchar(120) and a unique index hold for every writer, forever.
The minimum of three characters has no equivalent in a column definition. A database check constraint could express it, and EF Core can create one, but the natural home is your validation logic where you can return a useful message rather than a constraint violation.
The split follows a general principle: use the database for rules that must never be broken by anyone, and use application code for rules that need explanation or context. Encoding a rule in both places is not wasteful — the database is the guarantee and the application is the good error message.
Saved in this browser only.