Write entity classes EF Core understands, learn the naming conventions that need no configuration, and use data annotations and the Fluent API to control the schema.

Bishal Biswas
Self Learner
What an entity is
An entity is a plain C# class that maps to a database table. There is no base class to inherit and no interface to implement.
public class Book
{
public int Id { get; set; }
public string Title { get; set; } = "";
public int Year { get; set; }
public decimal Price { get; set; }
public DateTime CreatedAt { get; set; }
}
Add it to the context and EF Core will create a Books table with five columns:
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Book> Books => Set<Book>();
}
Conventions do most of the work
EF Core infers a lot from names and types. Learn these and you write far less configuration.
The table name comes from the DbSet property name. DbSet<Book> Books gives a Books table.
The primary key is a property called Id, or <TypeName>Id. Both Id and BookId are found automatically.
An integer key is generated by the database. You never set it. EF Core fills it in after SaveChanges.
Column types follow the C# type:
| C# | SQL Server |
|---|---|
int |
int |
long |
bigint |
string |
nvarchar(max) |
decimal |
decimal(18,2) |
bool |
bit |
DateTime |
datetime2 |
Guid |
uniqueidentifier |
Nullability follows nullable reference types. string Title becomes NOT NULL, and string? Subtitle becomes nullable. This is a very good reason to keep <Nullable>enable</Nullable> on.
Foreign keys are inferred from a property called <Navigation>Id:
public class Book
{
public int Id { get; set; }
public int AuthorId { get; set; } // the foreign key
public Author Author { get; set; } = null!; // the navigation property
}
Two ways to configure
When a convention is wrong, you override it. There are two mechanisms.
Data annotations are attributes on the class. Short and local:
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
[Table("tbl_books")]
public class Book
{
[Key]
public int BookId { get; set; }
[Required]
[MaxLength(200)]
[Column("book_title")]
public string Title { get; set; } = "";
[Column(TypeName = "decimal(10,2)")]
public decimal Price { get; set; }
[NotMapped]
public string DisplayName => $"{Title} ({Year})";
public int Year { get; set; }
}
The Fluent API lives in OnModelCreating. More verbose, and it can express everything:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Book>(entity =>
{
entity.ToTable("tbl_books");
entity.HasKey(b => b.BookId);
entity.Property(b => b.Title)
.HasColumnName("book_title")
.HasMaxLength(200)
.IsRequired();
entity.Property(b => b.Price)
.HasPrecision(10, 2);
entity.Ignore(b => b.DisplayName);
entity.HasIndex(b => b.Isbn).IsUnique();
});
}
Which to use
Use data annotations for simple things that also matter to your API, such as [MaxLength], because they double as validation hints and Swagger reads them.
Use the Fluent API for everything to do with the database: indexes, composite keys, delete behaviour, precision, default values, relationships and query filters. None of these belong on a class that other layers use, and several are impossible as attributes.
If the two conflict, the Fluent API wins.
Splitting configuration into files
One OnModelCreating with twenty entities becomes unreadable. Put each entity’s configuration in its own class:
public class BookConfiguration : IEntityTypeConfiguration<Book>
{
public void Configure(EntityTypeBuilder<Book> builder)
{
builder.ToTable("Books");
builder.HasKey(b => b.Id);
builder.Property(b => b.Title)
.HasMaxLength(200)
.IsRequired();
builder.Property(b => b.Price)
.HasPrecision(10, 2);
builder.HasIndex(b => b.Isbn).IsUnique();
builder.HasOne(b => b.Author)
.WithMany(a => a.Books)
.HasForeignKey(b => b.AuthorId)
.OnDelete(DeleteBehavior.Restrict);
}
}
Then load them all with one line:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
This is the pattern to use in any project with more than a handful of tables.
Configuration you will actually reach for
Indexes, which make your WHERE clauses fast:
builder.HasIndex(b => b.Isbn).IsUnique();
builder.HasIndex(b => new { b.AuthorId, b.Year }); // composite
builder.HasIndex(b => b.Title).HasDatabaseName("IX_Books_Title");
A unique index is also the only reliable way to prevent duplicates. Validation checks are racy. A constraint is not.
Default values:
builder.Property(b => b.CreatedAt).HasDefaultValueSql("GETUTCDATE()");
builder.Property(b => b.IsActive).HasDefaultValue(true);
Computed columns:
builder.Property(b => b.TotalWithTax)
.HasComputedColumnSql("[Price] * 1.2", stored: true);
Concurrency tokens, so two simultaneous updates cannot silently overwrite each other:
builder.Property(b => b.RowVersion).IsRowVersion();
Value conversions, which store a C# type as something else:
// Store an enum as text instead of a number
builder.Property(b => b.Status).HasConversion<string>();
// Store a list of tags as one comma separated column
builder.Property(b => b.Tags)
.HasConversion(
v => string.Join(',', v),
v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList());
Owned types, for a value object that lives in the parent’s table:
builder.OwnsOne(o => o.ShippingAddress);
// creates ShippingAddress_Street, ShippingAddress_City and so on
Global query filters
A filter applied to every query for an entity. The classic use is soft delete:
builder.HasQueryFilter(b => !b.IsDeleted);
Now db.Books.ToList() silently excludes deleted rows, everywhere, with no chance of forgetting. When you genuinely need them:
var all = await db.Books.IgnoreQueryFilters().ToListAsync();
Multi tenant APIs use the same trick for the tenant id.
Seeding data
For reference data that should always exist:
builder.HasData(
new Author { Id = 1, Name = "Robert C. Martin" },
new Author { Id = 2, Name = "Martin Fowler" });
Seeded rows become part of a migration, so they are inserted when the migration runs. You must supply explicit keys, and changing seed data later generates a new migration that updates it.
Keep entities free of API concerns
An entity models your data, not your JSON. Two habits keep it clean.
No validation attributes for API rules. [EmailAddress] on an entity does nothing at the database level and suggests the entity is being used as a DTO.
No [JsonIgnore]. If you are hiding a field during serialization, the entity is being returned to clients. Return a DTO instead.
[MaxLength] is the useful exception, because it genuinely maps to a column size.
Quick recap
- An entity is a plain class. Conventions find the key, the table name and the column types.
- Nullable reference types decide column nullability, so keep
Nullableenabled. <Navigation>Idis recognised as a foreign key automatically.- Data annotations suit simple, shared rules. The Fluent API handles anything database specific and always wins on conflict.
- Put each entity’s configuration in an
IEntityTypeConfiguration<T>and load them withApplyConfigurationsFromAssembly. - Unique indexes, not validation, are what actually prevent duplicate rows.
HasQueryFilterapplies a condition to every query, which is how soft delete and multi tenancy are done.- Keep API concerns such as
[JsonIgnore]and[EmailAddress]out of entities.
// Entities plus configuration, organised the way a real project does it.
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
// ---------- Entities: plain classes, no base class, no API attributes
public class Author
{
public int Id { get; set; }
public string Name { get; set; } = "";
public List<Book> Books { get; set; } = new();
}
public enum BookStatus { Draft, InReview, Published }
public class Book
{
public int Id { get; set; } // convention: primary key, identity
public string Title { get; set; } = ""; // non nullable -> NOT NULL
public string? Subtitle { get; set; } // nullable -> NULL
public string Isbn { get; set; } = "";
public int Year { get; set; }
public decimal Price { get; set; }
public BookStatus Status { get; set; }
public List<string> Tags { get; set; } = new(); // stored as one column via a converter
public bool IsDeleted { get; set; } // soft delete flag
public DateTime CreatedAt { get; set; }
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
public int AuthorId { get; set; } // convention: foreign key
public Author Author { get; set; } = null!; // navigation property
// Not a column: computed in C# only
public string DisplayName => $"{Title} ({Year})";
}
// ---------- One configuration class per entity keeps OnModelCreating empty
public class BookConfiguration : IEntityTypeConfiguration<Book>
{
public void Configure(EntityTypeBuilder<Book> builder)
{
builder.ToTable("Books");
builder.HasKey(b => b.Id);
builder.Property(b => b.Title).HasMaxLength(200).IsRequired();
builder.Property(b => b.Subtitle).HasMaxLength(200);
builder.Property(b => b.Isbn).HasMaxLength(13).IsFixedLength();
builder.Property(b => b.Price).HasPrecision(10, 2);
// A unique index is the only reliable duplicate guard. Validation is racy.
builder.HasIndex(b => b.Isbn).IsUnique();
builder.HasIndex(b => new { b.AuthorId, b.Year });
// Store the enum as readable text rather than an int
builder.Property(b => b.Status).HasConversion<string>().HasMaxLength(20);
// Store List<string> as one comma separated column
builder.Property(b => b.Tags)
.HasConversion(
v => string.Join(',', v),
v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList())
.HasMaxLength(500);
builder.Property(b => b.CreatedAt).HasDefaultValueSql("GETUTCDATE()");
// Two simultaneous updates now conflict instead of silently overwriting
builder.Property(b => b.RowVersion).IsRowVersion();
builder.Ignore(b => b.DisplayName); // computed in C#, not a column
builder.HasOne(b => b.Author)
.WithMany(a => a.Books)
.HasForeignKey(b => b.AuthorId)
.OnDelete(DeleteBehavior.Restrict); // do not delete books with the author
// Every query for Book gets this automatically. Bypass with IgnoreQueryFilters().
builder.HasQueryFilter(b => !b.IsDeleted);
}
}
public class AuthorConfiguration : IEntityTypeConfiguration<Author>
{
public void Configure(EntityTypeBuilder<Author> builder)
{
builder.Property(a => a.Name).HasMaxLength(120).IsRequired();
builder.HasIndex(a => a.Name);
// Reference data that ships with the migration
builder.HasData(
new Author { Id = 1, Name = "Robert C. Martin" },
new Author { Id = 2, Name = "Martin Fowler" });
}
}
// ---------- The context stays tiny
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Book> Books => Set<Book>();
public DbSet<Author> Authors => Set<Author>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Finds and applies every IEntityTypeConfiguration in this assembly
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
}
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready