Mert Özen Further With Every Line
Database Entity Framework Core — #04

Data Annotations or Fluent API in EF Core? OnModelCreating and IEntityTypeConfiguration

Mert Özen Nov 5, 2026 9 min 3 views
Data Annotations or Fluent API in EF Core? OnModelCreating and IEntityTypeConfiguration

We put the two ways of overriding conventions side by side: data annotations and the Fluent API in OnModelCreating. We split configuration with IEntityTypeConfiguration and ApplyConfigurationsFromAssembly, test unique indexes, check constraints and default values against real SQLite errors, and see the precedence order and the bool default trap.

Version note: The code in this post is written for EF Core 6 and runs unchanged on EF Core 10.

Guess First

What happens if we give one property two different rules from two different places? For Author.Name the class has these attributes:

[Required]
[MaxLength(100)]
public string Name { get; set; } = "";

And the context's OnModelCreating method has this line:

modelBuilder.Entity<Author>()
    .Property(a => a.Name)
    .HasMaxLength(150);

Does Name end up with a length of 100 or 150 in the model? Or does EF Core notice the conflict and throw? And think about this too: if you try to write a 300-character name into this column on SQLite, who stops you?

Configuring with Data Annotations

In the previous post we read the model built by conventions. There are two ways to override what a convention says. The first is data annotations: writing the rule as attributes directly on the class.

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;

namespace Bookstore;

[Table("Authors")]
[Index(nameof(Email), IsUnique = true)]
public class Author
{
    public int Id { get; set; }

    [Required]
    [MaxLength(100)]
    public string Name { get; set; } = "";

    [MaxLength(200)]
    [Column("EmailAddress")]
    public string Email { get; set; } = "";

    [NotMapped]
    public string Initials => string.Concat(Name.Split(' ').Select(p => p[0]));
}

[Table] changes the table name and [Column] the column name. [MaxLength] sets the length, and [NotMapped] removes a property from the model. [Required] is actually redundant here; with NRT on, string Name is already required. But it is useful in a project with NRT off, or when you want to make the rule explicit to the reader.

Most of these attributes come from the System.ComponentModel.DataAnnotations namespace. That namespace doesn't belong to EF Core alone; ASP.NET Core reads the same [Required] and [MaxLength] attributes for model validation. [Index], on the other hand, is specific to EF Core and lives in the Microsoft.EntityFrameworkCore namespace; it arrived with EF Core 5.

The Fluent API and OnModelCreating

The second way is the Fluent API: writing the rules as chained method calls in the context's OnModelCreating method, without touching the entity class.

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace Bookstore;

public class BookstoreContext : DbContext
{
    public DbSet<Author> Authors => Set<Author>();
    public DbSet<Book> Books => Set<Book>();
    public DbSet<Tag> Tags => Set<Tag>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder
            .UseSqlite("Data Source=bookstore.db")
            .LogTo(Console.WriteLine, new[] { DbLoggerCategory.Database.Command.Name }, LogLevel.Information);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(BookstoreContext).Assembly);

        modelBuilder.Entity<Author>()
            .Property(a => a.Name)
            .HasMaxLength(150);
    }
}

OnModelCreating is the method EF Core calls while building the model, after applying the conventions. modelBuilder.Entity<Author>() gives you an entity's builder, and every call after it changes a part of the model. The Fluent API can do everything data annotations can; the reverse is not true. Settings like the delete behavior of relationships, composite keys, default values and check constraints have no attribute equivalent in EF Core 6.

The Fluent API has another advantage: it keeps the entity class free of database details. If you keep your domain classes in a separate project and don't want that project to reference EF Core, the Fluent API is your only option.

Splitting It Up with IEntityTypeConfiguration

Once you write dozens of lines of Fluent API per entity, OnModelCreating quickly grows to hundreds of lines. The fix is to move each entity's configuration into its own class:

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace Bookstore;

public class BookConfiguration : IEntityTypeConfiguration<Book>
{
    public void Configure(EntityTypeBuilder<Book> builder)
    {
        builder.ToTable("Books");

        builder.Property(b => b.Title)
            .IsRequired()
            .HasMaxLength(200);

        builder.Property(b => b.Isbn)
            .HasMaxLength(13)
            .IsFixedLength();

        builder.HasIndex(b => b.Isbn)
            .IsUnique();

        builder.Property(b => b.Price)
            .HasPrecision(10, 2);

        builder.Property(b => b.CreatedAt)
            .HasDefaultValueSql("CURRENT_TIMESTAMP");

        builder.HasCheckConstraint("CK_Books_Stock", "\"Stock\" >= 0");
    }
}

public class TagConfiguration : IEntityTypeConfiguration<Tag>
{
    public void Configure(EntityTypeBuilder<Tag> builder)
    {
        builder.Property(t => t.Name).HasMaxLength(50);
        builder.HasIndex(t => t.Name).IsUnique();
    }
}

The Book class itself stays completely plain, without a single attribute:

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public string Isbn { get; set; } = "";
    public decimal Price { get; set; }
    public int Stock { get; set; }
    public DateTime CreatedAt { get; set; }
}

The ApplyConfigurationsFromAssembly line in the context scans the assembly you give it, finds every class implementing IEntityTypeConfiguration<T> and applies it. When you add a new entity you don't need to touch the context; writing its configuration class is enough. If you don't want scanning, you can add them one by one with modelBuilder.ApplyConfiguration(new BookConfiguration()).

EF Core 6 brought one more alternative: pointing to the configuration class with an attribute on the entity.

[EntityTypeConfiguration(typeof(TagConfiguration))]
public class Tag
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
}

In that case EF Core applies TagConfiguration itself as soon as Tag is part of the context. In our project ApplyConfigurationsFromAssembly already finds the same class, so the attribute isn't needed; picking one of the two and sticking with it makes a project easier to read.

The Resulting Schema

This is the SQL in the log after EnsureCreated (identical on EF Core 6 and 10):

CREATE TABLE "Authors" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Authors" PRIMARY KEY AUTOINCREMENT,
    "Name" TEXT NOT NULL,
    "EmailAddress" TEXT NOT NULL
);
CREATE TABLE "Books" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Books" PRIMARY KEY AUTOINCREMENT,
    "Title" TEXT NOT NULL,
    "Isbn" TEXT NOT NULL,
    "Price" TEXT NOT NULL,
    "Stock" INTEGER NOT NULL,
    "CreatedAt" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
    CONSTRAINT "CK_Books_Stock" CHECK ("Stock" >= 0)
);
CREATE TABLE "Tags" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Tags" PRIMARY KEY AUTOINCREMENT,
    "Name" TEXT NOT NULL
);
CREATE UNIQUE INDEX "IX_Authors_EmailAddress" ON "Authors" ("EmailAddress");
CREATE UNIQUE INDEX "IX_Books_Isbn" ON "Books" ("Isbn");
CREATE UNIQUE INDEX "IX_Tags_Name" ON "Tags" ("Name");

Table and column names, unique indexes, the check constraint and the default value are all there. The lengths and HasPrecision(10, 2) don't show up, because SQLite doesn't store them. When we generate the same model for SQL Server with GenerateCreateScript, we see the difference:

CREATE TABLE [Books] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(200) NOT NULL,
    [Isbn] nchar(13) NOT NULL,
    [Price] decimal(10,2) NOT NULL,
    [Stock] int NOT NULL,
    [CreatedAt] datetime2 NOT NULL DEFAULT (CURRENT_TIMESTAMP),
    CONSTRAINT [PK_Books] PRIMARY KEY ([Id]),
    CONSTRAINT [CK_Books_Stock] CHECK ("Stock" >= 0)
);

With IsFixedLength, Isbn became a fixed-length nchar(13), and Price got the precision we asked for. The decimal warning from the previous post no longer appears either.

Testing the Index and the Unique Constraint

Let's try adding a second book with the same ISBN:

db.Books.Add(new Book { Title = "Refactoring (copy)", Isbn = "9780134757599", Price = 40m });
try
{
    db.SaveChanges();
}
catch (DbUpdateException ex)
{
    Console.WriteLine(ex.Message);
    Console.WriteLine(ex.InnerException?.Message);
}
An error occurred while saving the entity changes. See the inner exception for details.
SQLite Error 19: 'UNIQUE constraint failed: Books.Isbn'.

EF Core doesn't check uniqueness before saving; it sends the INSERT, the database rejects it and EF Core wraps the error in a DbUpdateException. The real information is always in InnerException. In an API, catching this error and turning it into a meaningful response like 409 Conflict is your job.

If you want an index that is not unique and only speeds up queries, you leave out IsUnique(). For an index over multiple columns you write builder.HasIndex(b => new { b.Title, b.Isbn }). We will come back to which columns deserve an index in the performance post.

Check Constraints and Default Values

A check constraint writes the rule into the database itself. A book with a stock of -1:

SQLite Error 19: 'CHECK constraint failed: CK_Books_Stock'.

The second parameter of HasCheckConstraint is plain SQL, and EF Core puts it into the schema as is. We quoted the column name ourselves, because the provider's quoting rules are not applied to this expression. This line compiles and runs on EF Core 10, but produces a warning; with EF Core 7 the API moved inside ToTable:

warning CS0618: 'RelationalEntityTypeBuilderExtensions.HasCheckConstraint<TEntity>(EntityTypeBuilder<TEntity>, string, string?)' is obsolete: 'Configure this using ToTable(t => t.HasCheckConstraint()) instead.'

We will see the new form in the EF Core 7 post; on EF Core 6, writing ToTable(t => t.HasCheckConstraint(...)) doesn't compile.

HasDefaultValueSql("CURRENT_TIMESTAMP") generates the value in the database. As long as you don't give this property a value, EF Core leaves it out of the INSERT and reads it back from the database after saving. When we save the first book and read it again:

Stock=0 CreatedAt=2026-11-03 21:52:07

On the machine running the program it was 00:52. SQLite's CURRENT_TIMESTAMP returns UTC; converting it to local time on the application side is your responsibility. For a constant value you use HasDefaultValue(...); we will look at one of its traps shortly.

Which One When?

The answer to the guessing question: the Fluent API wins.

Name max length: 150

EF Core builds the model in layers: conventions first, then data annotations, and the Fluent API last. Each layer overrides the one before it, and a conflict is not treated as an error. That is a trap in itself: someone who sees [MaxLength(100)] on the class assumes the model says 100.

A split that works well in practice: simple rules about the entity itself that other layers of the application should know too (requiredness, length) can stay as annotations. Everything database-specific (table names, indexes, precision, default values, relationship behavior) goes to the Fluent API, preferably into IEntityTypeConfiguration classes. Most important of all, don't write the same rule in two places.

Common Pitfalls

Assuming MaxLength is enforced. EF Core keeps the length in the model and writes it into the schema, but doesn't check it when saving. SQLite doesn't enforce column length either. A 300-character title was saved without any error:

Saved title length: 300

On SQL Server the same INSERT gets an error from the database. Validating input is not EF Core's job but the application's; the model validation post in the ASP.NET Core Web API series is exactly about that.

Giving a bool property HasDefaultValue(true). We did that for IsAvailable and saved a book with IsAvailable = false. While building the model, EF Core 6 first printed this warning:

warn: 11/4/2026 00:51:55.223 RelationalEventId.BoolWithDefaultWarning[20601] (Microsoft.EntityFrameworkCore.Model.Validation)
      The 'bool' property 'IsAvailable' on entity type 'Book' is configured with a database-generated default. This default will always be used for inserts when the property has the value 'false', since this is the CLR default for the 'bool' type. Consider using the nullable 'bool?' type instead, so that the default will only be used for inserts when the property value is 'null'.

Then the book was written to the database as True. The reason: when a property holds the CLR default (false for bool), EF Core treats it as "the user gave no value" and leaves the column out of the INSERT, so the database writes its own default. The same code saved False on EF Core 10, because since EF Core 8 this decision for bools is based on the database default. This is a behavior difference; on EF Core 6, use bool? or set the default on the C# side. We will look at the details in the EF Core 8 post.

Forgetting provider differences in a check constraint expression. Since the expression is plain SQL, a function that works on SQLite may not exist on SQL Server. If you target more than one provider, keep the expression in its simplest form that works on both.

A Small Experiment

★☆☆ Keep [MaxLength(100)] on Author.Name and delete the HasMaxLength(150) line in OnModelCreating. What does the model say now? Then remove the annotation too and look at the value the convention gives.

★★☆ Move the whole configuration of Author into an AuthorConfiguration class: the table name, the column name, the lengths and the unique email index. Remove the annotations and confirm that the GenerateCreateScript output doesn't change.

★★★ Add a composite index on Title and Isbn to Book, and write a check constraint for the rule Price > 0. Try saving a book with a price of 0 to SQLite. Is the result what you expected? If not, look for the reason in how decimal is stored in SQLite, as we saw in the previous post.

So far we have configured entities on their own. In a real bookstore, though, a book has an author and an order has line items. In the next post we will look at the first kind of relationship: one-to-many, foreign keys, navigation properties, required and optional relationships, and the cascade delete behaviors that decide what happens to an author's books when you delete the author.