Entities and Conventions

Predict the table EF Core builds from a class: keys, nullability, column types and names, and when to override conventions.

Syntaxpublic sealed class Customer { public int Id { get; set; } // PK, identity public required string Name { get; set; } // NOT NULL public string? Phone { get; set; } // NULL }

EF Core builds a model the first time a context type is used: it starts from the DbSet<T> properties, follows every navigation it can reach, applies conventions, then data annotations, then OnModelCreating. Later sources win. The model is cached per context type for the life of the process, which is part of why the first query after startup is slow.

Keys

A property called Id or <TypeName>Id becomes the primary key. An int or long key is database-generated: IDENTITY on SQL Server, GENERATED BY DEFAULT AS IDENTITY on PostgreSQL. A Guid key is generated by EF on the client when the entity is added, so the id is known before SaveChanges. Composite keys have no convention: use [PrimaryKey(nameof(OrderId), nameof(LineNo))] or HasKey. A type with no key must be marked [Keyless] and can only be queried.

Nullability decides NOT NULL

With <Nullable>enable</Nullable> (the template default) a string property is NOT NULL and string? is NULL; value types follow int versus int?. Turning nullable reference types on in an older project therefore changes the model: the next migration alters every non-annotated string column to NOT NULL, and it fails in production on the first row that holds a null. Initialise required strings with the C# required modifier rather than = null!, so object creation in your own code is checked by the compiler.

Default column types

CLR typeSQL ServerPostgreSQL (Npgsql)
stringnvarchar(max)text
string + max length 200nvarchar(200)character varying(200)
decimaldecimal(18,2), with a warningnumeric (no scale)
DateTimedatetime2timestamp with time zone (UTC only)
boolbitboolean

Two of these bite in production. nvarchar(max) cannot be an index key on SQL Server, so an unbounded Email cannot get a unique index. A default decimal(18,2) silently rounds a unit price of 0.125 to 0.13. Set lengths and precision explicitly.

What is mapped

Public properties with a getter and a setter (any accessibility, so private set works) are mapped; a get-only computed property such as Total => Lines.Sum(...) is not. EF can also bind constructor parameters whose names match properties, which keeps entities immutable from outside. Table names come from the DbSet property name, or the class name when there is none. On PostgreSQL the PascalCase names are quoted identifiers, so hand-written SQL must quote them too; teams that want snake_case set names explicitly or add a naming-convention plugin.

Annotations or fluent API

Everything an annotation does has a fluent equivalent: [MaxLength(200)] is .HasMaxLength(200), [Precision(12, 2)] is .HasPrecision(12, 2). Filtered indexes, value conversions and most relationship configuration (for example, a relationship with no navigations) exist only in the fluent API; [ForeignKey], [InverseProperty] and [DeleteBehavior] cover the simple cases. Most teams keep entities free of persistence attributes and put mapping in configuration classes, which is the next lesson.

Example

Example · csharp
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;

public sealed class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
    public DbSet<Customer> Customers => Set<Customer>();
    public DbSet<Invoice> Invoices => Set<Invoice>();
}

[Index(nameof(Email), IsUnique = true)]
public sealed class Customer
{
    public int Id { get; set; }                       // PK by convention, database-generated

    [MaxLength(200)]
    public required string Name { get; set; }         // NOT NULL varchar(200)

    [MaxLength(320)]
    public required string Email { get; set; }        // bounded, so it can carry a unique index

    public string? Phone { get; set; }                // nullable reference type: NULL column

    public DateTime CreatedAt { get; private set; } = DateTime.UtcNow; // private setter is still mapped

    public List<Invoice> Invoices { get; } = [];      // collection navigation, no column
}

public sealed class Invoice
{
    public Guid Id { get; set; }                      // Guid key: EF generates it on Add

    [MaxLength(30)]
    public required string Number { get; set; }       // e.g. INV-2026-00042

    public int CustomerId { get; set; }               // FK by convention: <Navigation>Id, NOT NULL
    public Customer Customer { get; set; } = null!;

    [Precision(12, 2)]
    public decimal Amount { get; set; }

    public DateOnly DueOn { get; set; }
    public DateTime? PaidAt { get; set; }             // int?, DateTime? and friends map to NULL

    public bool IsOverdue => PaidAt is null && DueOn < DateOnly.FromDateTime(DateTime.UtcNow); // get-only: not mapped
}

When to use it

  • Give Customer.Email a 320-character limit and a unique index so duplicate sign-ups fail in the database, not only in code
  • Store invoice amounts as numeric(12,2) instead of relying on SQL Server's default decimal(18,2) and its rounding
  • Review the AlterColumn changes a migration proposes after nullable reference types are enabled in a legacy project

More examples

The same mapping with the fluent API

Each fluent call is annotated with the attribute it replaces, so the two forms can be read side by side. Fluent configuration wins when both are present. Explicit <code>snake_case</code> table and column names mean hand-written SQL and reporting tools on PostgreSQL do not need double-quoted PascalCase identifiers.

Example · csharp
using Microsoft.EntityFrameworkCore;

public sealed class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
    public DbSet<Customer> Customers => Set<Customer>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Customer>(customer =>
        {
            customer.ToTable("customers");
            customer.HasKey(c => c.Id);
            customer.Property(c => c.Id).HasColumnName("id");
            customer.Property(c => c.Name).HasColumnName("name").HasMaxLength(200);   // [MaxLength(200)]
            customer.Property(c => c.Email).HasColumnName("email").HasMaxLength(320);
            customer.HasIndex(c => c.Email).IsUnique();                              // [Index(..., IsUnique = true)]
            customer.Property(c => c.CreditLimit).HasColumnName("credit_limit").HasPrecision(12, 2); // [Precision(12, 2)]
            customer.Ignore(c => c.DisplayName);                                     // [NotMapped]
        });
    }
}

// A persistence-ignorant entity: no attributes, nothing EF-specific.
public sealed class Customer
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required string Email { get; set; }
    public decimal CreditLimit { get; set; }
    public string DisplayName { get; set; } = "";
}

The DDL the conventions produce

Read the generated DDL whenever you add an entity. Points to notice: <code>Phone</code> is unbounded <code>text</code> because it had no length, the <code>uuid</code> key has no database default because EF generates it, <code>IsOverdue</code> is absent because it is get-only, the required foreign key gets <code>ON DELETE CASCADE</code>, and EF added an index on <code>CustomerId</code> by convention. That default cascade is wrong for invoices: deleting a customer would delete their financial history, so configure <code>OnDelete(DeleteBehavior.Restrict)</code> as the Relationships lesson shows.

Example · sql
-- dotnet ef migrations script, Npgsql provider, for the main example
CREATE TABLE "Customers" (
    "Id" integer GENERATED BY DEFAULT AS IDENTITY,
    "Name" character varying(200) NOT NULL,
    "Email" character varying(320) NOT NULL,
    "Phone" text,
    "CreatedAt" timestamp with time zone NOT NULL,
    CONSTRAINT "PK_Customers" PRIMARY KEY ("Id")
);

CREATE TABLE "Invoices" (
    "Id" uuid NOT NULL,
    "Number" character varying(30) NOT NULL,
    "CustomerId" integer NOT NULL,
    "Amount" numeric(12,2) NOT NULL,
    "DueOn" date NOT NULL,
    "PaidAt" timestamp with time zone,
    CONSTRAINT "PK_Invoices" PRIMARY KEY ("Id"),
    CONSTRAINT "FK_Invoices_Customers_CustomerId" FOREIGN KEY ("CustomerId")
        REFERENCES "Customers" ("Id") ON DELETE CASCADE
);

CREATE UNIQUE INDEX "IX_Customers_Email" ON "Customers" ("Email");
CREATE INDEX "IX_Invoices_CustomerId" ON "Invoices" ("CustomerId");

Asserting the model in a test

The model is metadata you can inspect without a database: <code>db.Model</code> builds it from the conventions and configuration. A test like this catches the slip where someone turns a string into <code>string?</code> or drops a length, which would otherwise surface only as an unexpected <code>AlterColumn</code> in the next migration.

Example · csharp
using Microsoft.EntityFrameworkCore;
using Xunit;

public sealed class CustomerMappingTests
{
    [Fact]
    public void Email_is_bounded_required_and_unique()
    {
        var options = new DbContextOptionsBuilder<ShopDb>()
            .UseNpgsql("Host=unused") // building the model needs no connection
            .Options;
        using var db = new ShopDb(options);

        var customer = db.Model.FindEntityType(typeof(Customer))!;
        var email = customer.FindProperty(nameof(Customer.Email))!;

        Assert.False(email.IsNullable);
        Assert.Equal(320, email.GetMaxLength());
        Assert.Contains(customer.GetIndexes(), i => i.IsUnique && i.Properties.Single() == email);
        Assert.True(customer.FindProperty(nameof(Customer.Phone))!.IsNullable);
    }
}

public sealed class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
    public DbSet<Customer> Customers => Set<Customer>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Customer>().Property(c => c.Email).HasMaxLength(320);
        modelBuilder.Entity<Customer>().HasIndex(c => c.Email).IsUnique();
    }
}

public sealed class Customer
{
    public int Id { get; set; }
    public required string Email { get; set; }
    public string? Phone { get; set; }
}

Discussion

  • Be the first to comment on this lesson.