20 minlesson

EF Core Configuration & Migrations

EF Core Configuration & Migrations

In this presentation, we configure the DbContext, define entity relationships using the Fluent API, set up indexes, seed initial data, and create the first database migration. This is where the domain model meets the database.

Setting Up the Project

Before configuring EF Core, you need the right NuGet packages. For a new ASP.NET Core Web API project with PostgreSQL:

bash
1dotnet new sln -n ParcelTracking
2mkdir src
3dotnet new classlib -n ParcelTracking.Domain -o src/ParcelTracking.Domain
4dotnet new classlib -n ParcelTracking.Application -o src/ParcelTracking.Application
5dotnet new classlib -n ParcelTracking.Infrastructure -o src/ParcelTracking.Infrastructure
6dotnet new webapi -n ParcelTracking.Api -o src/ParcelTracking.Api
7dotnet sln add src/ParcelTracking.Domain src/ParcelTracking.Application src/ParcelTracking.Infrastructure src/ParcelTracking.Api
8
9dotnet add src/ParcelTracking.Application reference src/ParcelTracking.Domain
10dotnet add src/ParcelTracking.Infrastructure reference src/ParcelTracking.Application
11dotnet add src/ParcelTracking.Api reference src/ParcelTracking.Infrastructure
12dotnet add src/ParcelTracking.Api reference src/ParcelTracking.Application
13
14dotnet add src/ParcelTracking.Infrastructure package Npgsql.EntityFrameworkCore.PostgreSQL
15dotnet add src/ParcelTracking.Infrastructure package Microsoft.EntityFrameworkCore.Design

The solution has four projects. Domain and Application are plain class libraries with no infrastructure dependencies. The Npgsql.EntityFrameworkCore.PostgreSQL package goes on the Infrastructure project because that is where the DbContext lives. The Design package is also on Infrastructure because dotnet ef needs it to build the migrations project.

You also need a PostgreSQL instance. The easiest way is to run one in Docker:

bash
1docker run -d --name parceltracking-db \
2 -e POSTGRES_USER=parcel \
3 -e POSTGRES_PASSWORD=parcel123 \
4 -e POSTGRES_DB=parceltracking \
5 -p 5432:5432 \
6 postgres:17

This starts a PostgreSQL 17 container with a parceltracking database ready to use.

You also need the EF Core tools installed globally:

bash
1dotnet tool install --global dotnet-ef

Creating the DbContext

The DbContext is your gateway to the database. It defines which entities are mapped to tables and how they are configured.

csharp
1using Microsoft.EntityFrameworkCore;
2using ParcelTracking.Domain.Entities;
3
4namespace ParcelTracking.Infrastructure.Data;
5
6public class ParcelTrackingDbContext : DbContext
7{
8 public ParcelTrackingDbContext(DbContextOptions<ParcelTrackingDbContext> options)
9 : base(options)
10 {
11 }
12
13 public DbSet<Parcel> Parcels => Set<Parcel>();
14 public DbSet<Address> Addresses => Set<Address>();
15 public DbSet<TrackingEvent> TrackingEvents => Set<TrackingEvent>();
16 public DbSet<ParcelContentItem> ParcelContentItems => Set<ParcelContentItem>();
17 public DbSet<DeliveryConfirmation> DeliveryConfirmations => Set<DeliveryConfirmation>();
18 public DbSet<ParcelWatcher> ParcelWatchers => Set<ParcelWatcher>();
19
20 protected override void OnModelCreating(ModelBuilder modelBuilder)
21 {
22 modelBuilder.ApplyConfigurationsFromAssembly(typeof(ParcelTrackingDbContext).Assembly);
23 }
24}

Key Points

  • DbSet<T> properties: Each one represents a table. Using the => Set<T>() syntax avoids nullable warnings.
  • Constructor injection: The DbContextOptions parameter allows ASP.NET Core's DI container to configure the provider and connection string.
  • ApplyConfigurationsFromAssembly: This scans the assembly for all classes implementing IEntityTypeConfiguration<T> and applies them. It keeps OnModelCreating clean as the number of entities grows.

Registering the DbContext

In Program.cs, register the DbContext with the PostgreSQL provider:

csharp
1var builder = WebApplication.CreateBuilder(args);
2
3builder.Services.AddDbContext<ParcelTrackingDbContext>(options =>
4 options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));

And in appsettings.Development.json:

json
1{
2 "ConnectionStrings": {
3 "DefaultConnection": "Host=localhost;Port=5432;Database=parceltracking;Username=parcel;Password=parcel123"
4 }
5}

The connection string points to the PostgreSQL Docker container. UseNpgsql replaces UseSqlite and provides native support for PostgreSQL types like uuid, timestamptz, and numeric.

Entity Configuration with IEntityTypeConfiguration

Instead of putting all configuration in OnModelCreating, use separate configuration classes. This follows the Single Responsibility Principle and keeps each entity's configuration isolated.

Address Configuration

csharp
1using Microsoft.EntityFrameworkCore;
2using Microsoft.EntityFrameworkCore.Metadata.Builders;
3using ParcelTracking.Domain.Entities;
4
5namespace ParcelTracking.Infrastructure.Data.Configurations;
6
7public class AddressConfiguration : IEntityTypeConfiguration<Address>
8{
9 public void Configure(EntityTypeBuilder<Address> builder)
10 {
11 builder.HasKey(a => a.Id);
12
13 builder.Property(a => a.Id)
14 .ValueGeneratedOnAdd();
15
16 builder.HasIndex(a => a.PostalCode);
17 }
18}

The Address entity is straightforward. Data annotations on the class handle Required and MaxLength. The Fluent API adds an index on PostalCode for efficient address lookups.

Parcel Configuration

The Parcel entity has the most complex configuration because of its multiple relationships:

csharp
1using Microsoft.EntityFrameworkCore;
2using Microsoft.EntityFrameworkCore.Metadata.Builders;
3using ParcelTracking.Domain.Entities;
4
5namespace ParcelTracking.Infrastructure.Data.Configurations;
6
7public class ParcelConfiguration : IEntityTypeConfiguration<Parcel>
8{
9 public void Configure(EntityTypeBuilder<Parcel> builder)
10 {
11 builder.HasKey(p => p.Id);
12
13 builder.Property(p => p.Id)
14 .ValueGeneratedOnAdd();
15
16 // Unique tracking number
17 builder.HasIndex(p => p.TrackingNumber)
18 .IsUnique();
19
20 // Shipper address (many-to-one)
21 builder.HasOne(p => p.ShipperAddress)
22 .WithMany()
23 .HasForeignKey(p => p.ShipperAddressId)
24 .OnDelete(DeleteBehavior.Restrict);
25
26 // Recipient address (many-to-one)
27 builder.HasOne(p => p.RecipientAddress)
28 .WithMany()
29 .HasForeignKey(p => p.RecipientAddressId)
30 .OnDelete(DeleteBehavior.Restrict);
31
32 // Tracking events (one-to-many)
33 builder.HasMany(p => p.TrackingEvents)
34 .WithOne(te => te.Parcel)
35 .HasForeignKey(te => te.ParcelId)
36 .OnDelete(DeleteBehavior.Cascade);
37
38 // Content items (one-to-many)
39 builder.HasMany(p => p.ContentItems)
40 .WithOne(ci => ci.Parcel)
41 .HasForeignKey(ci => ci.ParcelId)
42 .OnDelete(DeleteBehavior.Cascade);
43
44 // Delivery confirmation (one-to-one optional)
45 builder.HasOne(p => p.DeliveryConfirmation)
46 .WithOne(dc => dc.Parcel)
47 .HasForeignKey<DeliveryConfirmation>(dc => dc.ParcelId)
48 .OnDelete(DeleteBehavior.Cascade);
49
50 // Parcel watchers (many-to-many)
51 builder.HasMany(p => p.Watchers)
52 .WithMany(w => w.Parcels);
53
54 // Indexes for common queries
55 builder.HasIndex(p => p.Status);
56 builder.HasIndex(p => p.ServiceType);
57 builder.HasIndex(p => p.CreatedAt);
58
59 // Decimal precision
60 builder.Property(p => p.Weight)
61 .HasPrecision(10, 2);
62
63 builder.Property(p => p.Length)
64 .HasPrecision(10, 2);
65
66 builder.Property(p => p.Width)
67 .HasPrecision(10, 2);
68
69 builder.Property(p => p.Height)
70 .HasPrecision(10, 2);
71
72 builder.Property(p => p.DeclaredValue)
73 .HasPrecision(12, 2);
74 }
75}

This is the most important configuration class. Let's examine each section.

Understanding the Relationship Configurations

Multiple Relationships to the Same Entity

A Parcel has two foreign keys pointing to the Address table: ShipperAddressId and RecipientAddressId. EF Core cannot automatically determine which navigation property maps to which foreign key when there are multiple relationships to the same entity type. You must configure them explicitly.

csharp
1// Without explicit configuration, EF Core throws:
2// "Unable to determine the relationship represented by navigation
3// 'Parcel.ShipperAddress' of type 'Address'."
4
5builder.HasOne(p => p.ShipperAddress)
6 .WithMany() // No inverse navigation on Address
7 .HasForeignKey(p => p.ShipperAddressId)
8 .OnDelete(DeleteBehavior.Restrict);

The .WithMany() call with no argument means Address does not have a navigation property back to Parcel. This is intentional: we do not need to find all parcels for a given address through navigation.

One-to-Many: Parcel to TrackingEvents

csharp
1builder.HasMany(p => p.TrackingEvents)
2 .WithOne(te => te.Parcel)
3 .HasForeignKey(te => te.ParcelId)
4 .OnDelete(DeleteBehavior.Cascade);

When a parcel is deleted, all its tracking events are deleted too. This makes sense because tracking events have no meaning without their parcel.

One-to-One Optional: Parcel to DeliveryConfirmation

csharp
1builder.HasOne(p => p.DeliveryConfirmation)
2 .WithOne(dc => dc.Parcel)
3 .HasForeignKey<DeliveryConfirmation>(dc => dc.ParcelId)
4 .OnDelete(DeleteBehavior.Cascade);

Note the generic type parameter <DeliveryConfirmation> on HasForeignKey. In a one-to-one relationship, you must specify which entity holds the foreign key because it is ambiguous. Here, DeliveryConfirmation has the ParcelId column.

The relationship is "optional" because Parcel.DeliveryConfirmation is nullable (DeliveryConfirmation?). A newly created parcel has no delivery confirmation.

Configuring Indexes

Indexes improve query performance for frequently filtered columns:

csharp
1// Unique index: no two parcels can share a tracking number
2builder.HasIndex(p => p.TrackingNumber)
3 .IsUnique();
4
5// Regular indexes for filtering and sorting
6builder.HasIndex(p => p.Status);
7builder.HasIndex(p => p.ServiceType);
8builder.HasIndex(p => p.CreatedAt);

The unique index on TrackingNumber is both a performance optimization and a data integrity constraint. It guarantees that TrackingNumber values are unique at the database level, even if application code has a bug.

Composite Indexes

For queries that filter on multiple columns, composite indexes are more efficient:

csharp
1// If you frequently query: WHERE Status = x AND ServiceType = y
2builder.HasIndex(p => new { p.Status, p.ServiceType });

We will add composite indexes later as query patterns emerge.

Configuring Decimal Precision

PostgreSQL uses the native numeric type with configurable precision and scale. Configuring precision in EF Core maps directly to the PostgreSQL column definition:

csharp
1builder.Property(p => p.DeclaredValue)
2 .HasPrecision(12, 2); // 12 total digits, 2 after decimal

HasPrecision(12, 2) means values up to 9,999,999,999.99. This is more than enough for parcel declared values.

ParcelContentItem Configuration

csharp
1public class ParcelContentItemConfiguration : IEntityTypeConfiguration<ParcelContentItem>
2{
3 public void Configure(EntityTypeBuilder<ParcelContentItem> builder)
4 {
5 builder.HasKey(ci => ci.Id);
6
7 builder.Property(ci => ci.Id)
8 .ValueGeneratedOnAdd();
9
10 builder.HasIndex(ci => ci.ParcelId);
11 builder.HasIndex(ci => ci.HsCode);
12
13 builder.Property(ci => ci.UnitValue)
14 .HasPrecision(12, 2);
15
16 builder.Property(ci => ci.Weight)
17 .HasPrecision(10, 2);
18 }
19}

The index on HsCode supports queries that filter or aggregate by commodity classification — for example, finding all parcels containing items in a specific HS code range. The index on ParcelId speeds up the most common query: loading all content items for a given parcel.

TrackingEvent Configuration

csharp
1public class TrackingEventConfiguration : IEntityTypeConfiguration<TrackingEvent>
2{
3 public void Configure(EntityTypeBuilder<TrackingEvent> builder)
4 {
5 builder.HasKey(te => te.Id);
6
7 builder.Property(te => te.Id)
8 .ValueGeneratedOnAdd();
9
10 builder.HasIndex(te => te.ParcelId);
11 builder.HasIndex(te => te.Timestamp);
12 builder.HasIndex(te => te.EventType);
13 }
14}

The index on ParcelId speeds up the most common query: "get all tracking events for a parcel." The index on Timestamp supports chronological ordering.

DeliveryConfirmation Configuration

csharp
1public class DeliveryConfirmationConfiguration : IEntityTypeConfiguration<DeliveryConfirmation>
2{
3 public void Configure(EntityTypeBuilder<DeliveryConfirmation> builder)
4 {
5 builder.HasKey(dc => dc.Id);
6
7 builder.Property(dc => dc.Id)
8 .ValueGeneratedOnAdd();
9
10 // Ensure one confirmation per parcel at the database level
11 builder.HasIndex(dc => dc.ParcelId)
12 .IsUnique();
13 }
14}

The unique index on ParcelId enforces the one-to-one relationship at the database level. Even if EF Core's navigation configuration allows it, the database will reject a second DeliveryConfirmation for the same ParcelId.

ParcelWatcher Configuration

csharp
1public class ParcelWatcherConfiguration : IEntityTypeConfiguration<ParcelWatcher>
2{
3 public void Configure(EntityTypeBuilder<ParcelWatcher> builder)
4 {
5 builder.HasKey(w => w.Id);
6
7 builder.Property(w => w.Id)
8 .ValueGeneratedOnAdd();
9
10 builder.HasIndex(w => w.Email);
11 }
12}

The index on Email supports lookups like "find all parcels watched by this email address." The many-to-many relationship is configured on the Parcel side with HasMany(p => p.Watchers).WithMany(w => w.Parcels), which tells EF Core to auto-generate a join table.

Enum Storage Strategy

By default, EF Core stores enums as their underlying integer value. This is efficient but means the database contains numbers like 0, 1, 2 instead of "LabelCreated", "PickedUp", "InTransit".

If you want human-readable values in the database, you can convert enums to strings:

csharp
1// Store as string instead of integer
2builder.Property(p => p.Status)
3 .HasConversion<string>()
4 .HasMaxLength(20);

When to use string storage:

  • When DBAs or support staff query the database directly
  • When readability is more important than storage size
  • When using a schema-less database alongside the relational one

When to keep integer storage (our choice):

  • When performance is a priority (integers are smaller and faster to compare)
  • When the database is only accessed through the application
  • When you have good enum documentation

For this project, we use the default integer storage.

Seeding Lookup Data

EF Core supports data seeding through HasData in the model configuration. While our enums do not need a lookup table (they are stored as integers), you might want to seed sample data for development:

csharp
1protected override void OnModelCreating(ModelBuilder modelBuilder)
2{
3 modelBuilder.ApplyConfigurationsFromAssembly(typeof(ParcelTrackingDbContext).Assembly);
4
5 // Seed is optional; useful for development/testing
6}

If your design uses a ServiceType lookup table instead of an enum, you would seed it like this:

csharp
1builder.HasData(
2 new ServiceTypeLookup { Id = 1, Name = "Economy", DeliveryDays = 7 },
3 new ServiceTypeLookup { Id = 2, Name = "Standard", DeliveryDays = 5 },
4 new ServiceTypeLookup { Id = 3, Name = "Express", DeliveryDays = 2 },
5 new ServiceTypeLookup { Id = 4, Name = "Overnight", DeliveryDays = 1 }
6);

Seeded data is included in migrations and applied when the migration runs. It is idempotent: running the migration again does not create duplicates.

Creating the Initial Migration

With the DbContext and entity configurations in place, create the first migration:

bash
1dotnet ef migrations add InitialCreate \
2 --project src/ParcelTracking.Infrastructure \
3 --startup-project src/ParcelTracking.Api

The --project flag tells dotnet ef which project contains the DbContext and where to generate migration files. The --startup-project flag points to the Web API project, which has the Program.cs that registers the DbContext with its connection string. Without both flags, dotnet ef cannot find the DbContext or its configuration.

This command:

  1. Builds the solution
  2. Creates the DbContext using the registered configuration from the startup project
  3. Compares the current model to the database (which does not exist yet)
  4. Generates migration files in the Infrastructure project's Migrations/ folder

The generated files:

1src/ParcelTracking.Infrastructure/
2└── Migrations/
3 ├── 20250215000000_InitialCreate.cs # The migration (Up/Down methods)
4 ├── 20250215000000_InitialCreate.Designer.cs # Snapshot metadata
5 └── ParcelTrackingDbContextModelSnapshot.cs # Current model state

The Migration File

The Up method creates tables:

csharp
1protected override void Up(MigrationBuilder migrationBuilder)
2{
3 migrationBuilder.CreateTable(
4 name: "Addresses",
5 columns: table => new
6 {
7 Id = table.Column<Guid>(type: "uuid", nullable: false),
8 Street1 = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
9 Street2 = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
10 City = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
11 // ... more columns
12 },
13 constraints: table =>
14 {
15 table.PrimaryKey("PK_Addresses", x => x.Id);
16 });
17
18 migrationBuilder.CreateTable(
19 name: "Parcels",
20 columns: table => new
21 {
22 Id = table.Column<Guid>(type: "uuid", nullable: false),
23 TrackingNumber = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
24 // ... more columns
25 ShipperAddressId = table.Column<Guid>(type: "uuid", nullable: false),
26 RecipientAddressId = table.Column<Guid>(type: "uuid", nullable: false),
27 },
28 constraints: table =>
29 {
30 table.PrimaryKey("PK_Parcels", x => x.Id);
31 table.ForeignKey("FK_Parcels_Addresses_ShipperAddressId", x => x.ShipperAddressId,
32 principalTable: "Addresses", principalColumn: "Id", onDelete: ReferentialAction.Restrict);
33 table.ForeignKey("FK_Parcels_Addresses_RecipientAddressId", x => x.RecipientAddressId,
34 principalTable: "Addresses", principalColumn: "Id", onDelete: ReferentialAction.Restrict);
35 });
36
37 // ... TrackingEvents, DeliveryConfirmations, ParcelWatchers,
38 // and ParcelParcelWatcher (join table) tables
39}

The Down method reverses the migration by dropping the tables in reverse dependency order.

Applying the Migration

Apply the migration to create the database:

bash
1dotnet ef database update \
2 --project src/ParcelTracking.Infrastructure \
3 --startup-project src/ParcelTracking.Api

This applies the migration to the PostgreSQL database, creating all tables, indexes, and constraints.

Verifying the Schema

You can inspect the created database with the PostgreSQL CLI via Docker:

bash
1docker exec -it parceltracking-db psql -U parcel -d parceltracking \
2 -c "\dt"

Or within C# during development:

csharp
1app.MapGet("/debug/schema", async (ParcelTrackingDbContext db) =>
2{
3 var tables = await db.Database
4 .SqlQueryRaw<string>(
5 "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'")
6 .ToListAsync();
7 return tables;
8});

You should see tables: Addresses, Parcels, TrackingEvents, ParcelContentItems, DeliveryConfirmations, ParcelWatchers, ParcelParcelWatcher, and __EFMigrationsHistory.

Common Migration Workflow

As you develop the API, you will evolve the model and create new migrations:

  1. Change the model: Add a property, modify a relationship, add an index
  2. Create a migration: dotnet ef migrations add DescriptiveName --project src/ParcelTracking.Infrastructure --startup-project src/ParcelTracking.Api
  3. Review the migration: Check the generated Up and Down methods
  4. Apply the migration: dotnet ef database update --project src/ParcelTracking.Infrastructure --startup-project src/ParcelTracking.Api

If a migration looks wrong, remove it before applying:

bash
1dotnet ef migrations remove \
2 --project src/ParcelTracking.Infrastructure \
3 --startup-project src/ParcelTracking.Api

This deletes the last unapplied migration. You cannot remove applied migrations without rolling them back first.

Summary

In this presentation, you learned how to:

  • Set up an ASP.NET Core project with EF Core and PostgreSQL
  • Create a DbContext with DbSet properties and assembly-scanned configurations
  • Configure entity relationships using IEntityTypeConfiguration<T>
  • Handle multiple relationships to the same entity type (Parcel to Address)
  • Configure one-to-many (TrackingEvents, ContentItems), one-to-one optional (DeliveryConfirmation), and many-to-many (ParcelWatchers) relationships
  • Set delete behaviors (Restrict vs. Cascade) based on domain rules
  • Create unique and regular indexes for query performance and data integrity
  • Configure decimal precision for monetary and measurement values
  • Choose between integer and string enum storage
  • Create and apply database migrations with the dotnet ef CLI

In the next topic, we will build the Address Management API endpoints using this foundation.

EF Core Configuration & Migrations - Anko Academy