Entities and Model Configuration
Before you start
You need: a working DbContext (Article 01).
Time: about 50 minutes, plus the practice.
Learning objective
Configure an EF Core model that produces the schema you intended, including types, keys, indexes and constraints.
Topics
- Conventions — what EF Core infers
- Data annotations
- The Fluent API and
IEntityTypeConfiguration<T> - Keys, including composite
- Types, precision and required-ness
- Indexes and unique constraints
- Check constraints
- Enums
- Owned types and value conversions
Conventions
EF Core infers a great deal before you configure anything.
| Convention | Result |
|---|---|
DbSet<Student> Students | Table Students |
Property Id or StudentId | Primary key |
int key | IDENTITY(1,1) |
string property | NVARCHAR(MAX), nullable |
| Non-nullable value type | NOT NULL |
Nullable reference type (string?) | NULL |
Student.SchoolId + Student.School | Foreign key relationship |
Two of those defaults will hurt you if left alone.
NVARCHAR(MAX) for every string. It cannot be indexed normally, is stored off-row, and imposes no length validation. A Name column should be NVARCHAR(100).
decimal with no precision. EF Core warns, then defaults to DECIMAL(18,2). For money that happens to be right; for a percentage needing DECIMAL(5,2) it is not. Always state it.
Conventions are a starting point, not a design.
Data annotations
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
[Table("Student")]
public class Student
{
[Key]
public int Id { get; set; }
public Guid PublicId { get; set; }
[Required]
public int SchoolId { get; set; }
[Required]
[MaxLength(100)]
public string Name { get; set; } = string.Empty;
[Required]
[MaxLength(20)]
public string RollNumber { get; set; } = string.Empty;
[Required]
[MaxLength(10)]
public string ClassName { get; set; } = string.Empty;
[Required]
[MaxLength(1)]
public string Section { get; set; } = string.Empty;
[Column(TypeName = "date")]
public DateTime DateOfBirth { get; set; }
[MaxLength(250)]
public string? Address { get; set; }
public StudentStatus Status { get; set; }
[ForeignKey(nameof(SchoolId))]
public School School { get; set; } = null!;
public ICollection<ExamResult> ExamResults { get; set; } = new List<ExamResult>();
}
| Annotation | Effect |
|---|---|
[Table("Student")] | Table name |
[Key] | Primary key |
[Required] | NOT NULL |
[MaxLength(100)] | NVARCHAR(100) |
[Column(TypeName = "date")] | Exact SQL type |
[ForeignKey] | Names the FK property |
[NotMapped] | Excluded from the model |
[ConcurrencyCheck] | Include in the WHERE on update |
Annotations are convenient and limited: no composite keys, no check constraints, no unique index over several columns, and no way to configure a type you do not own.
They also put persistence concerns into the domain class. For a small project that is a fair trade; for anything larger, prefer the Fluent API.
The Fluent API
public class StudentConfiguration : IEntityTypeConfiguration<Student>
{
public void Configure(EntityTypeBuilder<Student> builder)
{
builder.ToTable("Student");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id).ValueGeneratedOnAdd();
builder.Property(s => s.PublicId)
.IsRequired()
.HasDefaultValueSql("NEWID()");
builder.Property(s => s.Name)
.IsRequired()
.HasMaxLength(100);
builder.Property(s => s.RollNumber)
.IsRequired()
.HasMaxLength(20)
.IsUnicode(false); // VARCHAR, not NVARCHAR
builder.Property(s => s.ClassName).IsRequired().HasMaxLength(10);
builder.Property(s => s.Section).IsRequired().HasMaxLength(1);
builder.Property(s => s.DateOfBirth).HasColumnType("date");
builder.Property(s => s.ParentPhone)
.IsRequired()
.HasMaxLength(15)
.IsUnicode(false);
builder.Property(s => s.Address).HasMaxLength(250);
builder.Property(s => s.Status)
.IsRequired()
.HasConversion<byte>() // TINYINT
.HasDefaultValue(StudentStatus.Active);
// Roll number is unique WITHIN a school, never globally
builder.HasIndex(s => new { s.SchoolId, s.RollNumber })
.IsUnique()
.HasDatabaseName("UQ_Student_Roll");
builder.HasIndex(s => s.PublicId)
.IsUnique()
.HasDatabaseName("UQ_Student_PublicId");
builder.HasIndex(s => new { s.SchoolId, s.ClassName, s.Section })
.HasDatabaseName("IX_Student_School_Class");
builder.HasOne(s => s.School)
.WithMany(sc => sc.Students)
.HasForeignKey(s => s.SchoolId)
.OnDelete(DeleteBehavior.Restrict);
builder.ToTable(t => t.HasCheckConstraint(
"CK_Student_Section", "[Section] IN ('A', 'B', 'C')"));
}
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(SchoolDbContext).Assembly);
}
One configuration class per entity, discovered automatically. The domain class stays free of persistence attributes, and everything about how Student is stored lives in one file.
The composite unique index is the line that matters most. HasIndex(s => s.RollNumber).IsUnique() would stop the second school using a roll number the first already has — invisible with one school of test data, and a blocker with two.
IsUnicode(false) produces VARCHAR instead of NVARCHAR. Correct for a roll number or phone number, which contain no non-Latin characters, and it halves the storage. Never use it for a name.
Keys
// Composite key — the junction table has no surrogate id
builder.HasKey(ts => new { ts.TeacherId, ts.SubjectId });
// A key that is not called Id
builder.HasKey(s => s.StudentIdentifier);
// A key the application supplies, not the database
builder.Property(s => s.Id).ValueGeneratedNever();
ValueGeneratedNever() is needed when you assign keys yourself — a data import preserving source ids, for instance. Without it EF Core assumes IDENTITY and SQL Server rejects an explicit value.
Precision and types
builder.Property(f => f.TotalFees).HasPrecision(18, 2);
builder.Property(f => f.DiscountAmount).HasPrecision(18, 2);
builder.Property(r => r.MarksObtained).HasPrecision(5, 2);
builder.Property(r => r.Percentage).HasColumnType("decimal(5,2)");
Set precision on every decimal. EF Core warns when you do not, and warnings in a build log are read by nobody.
builder.Property(e => e.ExamDate).HasColumnType("date");
builder.Property(p => p.PaidOn).HasColumnType("datetime2(3)");
builder.Property(a => a.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
SYSUTCDATETIME() rather than GETDATE(), so a server timezone change does not make historical timestamps incomparable.
Check constraints
builder.ToTable(t =>
{
t.HasCheckConstraint("CK_ExamResult_Marks",
"[MarksObtained] IS NULL OR [MarksObtained] >= 0");
t.HasCheckConstraint("CK_ExamResult_Absent",
"([IsAbsent] = 1 AND [MarksObtained] IS NULL) OR " +
"([IsAbsent] = 0 AND [MarksObtained] IS NOT NULL)");
});
EF Core has no way to express this in C#, so the SQL goes in as a string. It is worth the awkwardness: without CK_ExamResult_Absent, an absent student stored with zero marks is indistinguishable from one who genuinely scored zero, and prints as a fail.
A constraint in the database holds against every writer — your application, an import script, a support engineer in SSMS. Validation in C# holds only against the code path that runs it.
Enums
public enum StudentStatus : byte
{
Active = 0,
Inactive = 1,
Graduated = 2,
Transferred = 3
}
// Stored as TINYINT
builder.Property(s => s.Status).HasConversion<byte>();
// Stored as a string — readable in the database, more storage
builder.Property(s => s.Status)
.HasConversion<string>()
.HasMaxLength(20);
By default an enum is stored as int. HasConversion<byte>() gives TINYINT, which is what a four-member enum needs.
Neither form validates. A Status of 9 read from the database becomes an enum value matching no member, and then falls through every switch to default. Add the check constraint:
t.HasCheckConstraint("CK_Student_Status", "[Status] IN (0, 1, 2, 3)");
Storing as a string is more readable when people query the database directly, and it costs a rename risk — changing the enum member name silently orphans existing rows.
Value conversions
// DateOnly, for providers that do not support it natively
builder.Property(s => s.DateOfBirth)
.HasConversion(
d => d.ToDateTime(TimeOnly.MinValue),
d => DateOnly.FromDateTime(d))
.HasColumnType("date");
// A comma-separated list — a last resort, not a design
builder.Property(s => s.Tags)
.HasConversion(
v => string.Join(',', v),
v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList());
A converted property cannot be filtered or indexed usefully. Where(s => s.Tags.Contains("x")) becomes a LIKE over the whole column, or fails to translate at all. Use a related table when you need to query the values.
Global query filters
builder.HasQueryFilter(s => s.Status != StudentStatus.Inactive);
Every query on Student now excludes soft-deleted rows automatically — you cannot forget the filter.
Two cautions:
- It applies everywhere, including inside
Include. To bypass it:_context.Students.IgnoreQueryFilters(). - Do not put
SchoolIdin a global filter unless the context is genuinely per-tenant. A filter referencing a captured variable is baked into the model on first use, and a context reused across tenants then serves the wrong data. Filter bySchoolIdexplicitly in each query instead.
Relationships
// One-to-many
builder.HasOne(s => s.School)
.WithMany(sc => sc.Students)
.HasForeignKey(s => s.SchoolId)
.OnDelete(DeleteBehavior.Restrict);
// One-to-one
builder.HasOne(s => s.User)
.WithOne(u => u.Student)
.HasForeignKey<Student>(s => s.UserId)
.OnDelete(DeleteBehavior.SetNull);
// Many-to-many with an explicit join entity
builder.HasMany(t => t.Subjects)
.WithMany(s => s.Teachers)
.UsingEntity<TeacherSubject>(
right => right.HasOne(ts => ts.Subject)
.WithMany()
.HasForeignKey(ts => ts.SubjectId),
left => left.HasOne(ts => ts.Teacher)
.WithMany()
.HasForeignKey(ts => ts.TeacherId),
join => join.HasKey(ts => new { ts.TeacherId, ts.SubjectId }));
DeleteBehavior | On delete of the principal |
|---|---|
Restrict | Reject the delete |
Cascade | Delete dependents |
SetNull | Null the FK — must be nullable |
NoAction | Leave it to the database |
Default to Restrict. EF Core's default for a required relationship is Cascade, which means deleting one student silently destroys their exam results, attendance and payment history. The rejection is a feature — it forces the application to soft-delete instead.
Relationships are covered fully in article 6.
Reading the schema back
Confirm the model produces what you intended, before generating a migration:
In the Package Manager Console (Tools → NuGet Package Manager → Package Manager Console):
Get-DbContext
Add-Migration InitialCreate
Set the Default project dropdown to the project holding your DbContext before running either. Getting this wrong is the most common cause of "No DbContext was found".
Then read the generated migration file. It is plain C# describing exactly what will be created — column types, sizes, indexes, constraints. Every mistake in this article is visible there, before it reaches a database.
Errors you will hit
| Message | Cause | Fix |
|---|---|---|
Every string column is nvarchar(max) | No length configured | HasMaxLength on each |
decimal columns warn about precision | No precision configured | HasPrecision(18, 2) |
The entity type requires a primary key | No Id, no [Key], no configuration | Give it one |
Unable to determine the relationship | Ambiguous navigation properties | Configure it explicitly |
| A property is not persisted | No setter, or marked [NotMapped] | Add a setter |
nvarchar(max) for every string is the default and it is never what you want. It cannot be indexed usefully and it wastes space on every row.
Common mistakes
- Leaving strings as
NVARCHAR(MAX) - No precision on decimals
HasIndex(s => s.RollNumber).IsUnique()instead of the composite- Leaving
DeleteBehavior.Cascadeon rows with history - Annotations in a domain class shared with other layers
- No check constraint, so an invalid enum value can be stored
ValueGeneratedNevermissing when the application supplies keys- A global query filter capturing a tenant variable
- A value-converted property used in a
Whereclause and expected to be indexed - Never reading the generated migration
Practice
- Configure
Student,Teacher,Subject,Exam,ExamResult,FeeAccountandFeePaymentwithIEntityTypeConfiguration<T>classes. - Run
Add-Migration InitialCreatein the Package Manager Console and read the generated file. List every column that isnvarchar(max)and fix each. - Add
HasPrecision(18, 2)to every money property. Compare the migration before and after. - Add the composite unique index on
(SchoolId, RollNumber). Then change it toRollNumberalone, generate a migration, and describe what breaks when a second school is added. - Add the absent/marks check constraint. Apply it, then try to insert
IsAbsent = 1withMarksObtained = 0directly in SSMS. - Store
StudentStatusasTINYINTwithHasConversion<byte>(). Confirm the column type in the migration. - Insert
Status = 9directly in SSMS and read it through EF Core. Confirm the invalid enum value. Add the check constraint and retry. - Set
IsUnicode(false)onRollNumberandParentPhone. Confirmvarcharin the migration. - Add a global query filter for soft delete. Confirm inactive students disappear from every query, then retrieve one with
IgnoreQueryFilters(). - Change one relationship to
DeleteBehavior.Cascade, delete a student with results, and confirm the results are gone. RestoreRestrictand confirm the delete is now rejected.
Exercises 4 and 10 are the two that matter in production.
You can now
- Configure entities so the generated schema is the one you intended
- Set string lengths and decimal precision explicitly
- Choose between data annotations and the fluent API
- Configure a composite unique index
- Read a generated migration and check the column types
Review questions
- What are the two conventions most likely to produce a schema you did not want?
- Why must the unique index on
RollNumberincludeSchoolId? - Why add a check constraint when the application already validates?
- Why is
DeleteBehavior.Cascadethe wrong default for exam results?
Next: Migrations