Skip to main content
ANVISoftware Solutions
Lesson 5 of 17Intermediate19 min

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.

Two entities relying entirely on conventions
C#
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 annotationsFluent API
Where it is writtenAs attributes on the entity class itselfIn OnModelCreating, or a configuration class per entity
Effect on the domain classThe class now references EF and database concernsThe class stays free of persistence detail
DiscoverabilityHigh — the rule sits beside the propertyLower — you look in a second file
What it can expressA useful subset: lengths, required, keys, column namesEverything, including composite keys, indexes, delete behaviour and value conversions
Same class, two databasesDifficult — one set of attributes for bothStraightforward — a different configuration per context
Reuse elsewhereSome annotations also drive ASP.NET model validationNo effect outside EF Core
ProductConfiguration.cs — the same rules, kept out of the entity
C#
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.

C#
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.