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:
bash1dotnet new sln -n ParcelTracking2mkdir src3dotnet new classlib -n ParcelTracking.Domain -o src/ParcelTracking.Domain4dotnet new classlib -n ParcelTracking.Application -o src/ParcelTracking.Application5dotnet new classlib -n ParcelTracking.Infrastructure -o src/ParcelTracking.Infrastructure6dotnet new webapi -n ParcelTracking.Api -o src/ParcelTracking.Api7dotnet sln add src/ParcelTracking.Domain src/ParcelTracking.Application src/ParcelTracking.Infrastructure src/ParcelTracking.Api89dotnet add src/ParcelTracking.Application reference src/ParcelTracking.Domain10dotnet add src/ParcelTracking.Infrastructure reference src/ParcelTracking.Application11dotnet add src/ParcelTracking.Api reference src/ParcelTracking.Infrastructure12dotnet add src/ParcelTracking.Api reference src/ParcelTracking.Application1314dotnet add src/ParcelTracking.Infrastructure package Npgsql.EntityFrameworkCore.PostgreSQL15dotnet 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:
bash1docker 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:
bash1dotnet 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.
csharp1using Microsoft.EntityFrameworkCore;2using ParcelTracking.Domain.Entities;34namespace ParcelTracking.Infrastructure.Data;56public class ParcelTrackingDbContext : DbContext7{8 public ParcelTrackingDbContext(DbContextOptions<ParcelTrackingDbContext> options)9 : base(options)10 {11 }1213 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>();1920 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
DbContextOptionsparameter allows ASP.NET Core's DI container to configure the provider and connection string. ApplyConfigurationsFromAssembly: This scans the assembly for all classes implementingIEntityTypeConfiguration<T>and applies them. It keepsOnModelCreatingclean as the number of entities grows.
Registering the DbContext
In Program.cs, register the DbContext with the PostgreSQL provider:
csharp1var builder = WebApplication.CreateBuilder(args);23builder.Services.AddDbContext<ParcelTrackingDbContext>(options =>4 options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
And in appsettings.Development.json:
json1{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
csharp1using Microsoft.EntityFrameworkCore;2using Microsoft.EntityFrameworkCore.Metadata.Builders;3using ParcelTracking.Domain.Entities;45namespace ParcelTracking.Infrastructure.Data.Configurations;67public class AddressConfiguration : IEntityTypeConfiguration<Address>8{9 public void Configure(EntityTypeBuilder<Address> builder)10 {11 builder.HasKey(a => a.Id);1213 builder.Property(a => a.Id)14 .ValueGeneratedOnAdd();1516 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:
csharp1using Microsoft.EntityFrameworkCore;2using Microsoft.EntityFrameworkCore.Metadata.Builders;3using ParcelTracking.Domain.Entities;45namespace ParcelTracking.Infrastructure.Data.Configurations;67public class ParcelConfiguration : IEntityTypeConfiguration<Parcel>8{9 public void Configure(EntityTypeBuilder<Parcel> builder)10 {11 builder.HasKey(p => p.Id);1213 builder.Property(p => p.Id)14 .ValueGeneratedOnAdd();1516 // Unique tracking number17 builder.HasIndex(p => p.TrackingNumber)18 .IsUnique();1920 // Shipper address (many-to-one)21 builder.HasOne(p => p.ShipperAddress)22 .WithMany()23 .HasForeignKey(p => p.ShipperAddressId)24 .OnDelete(DeleteBehavior.Restrict);2526 // Recipient address (many-to-one)27 builder.HasOne(p => p.RecipientAddress)28 .WithMany()29 .HasForeignKey(p => p.RecipientAddressId)30 .OnDelete(DeleteBehavior.Restrict);3132 // 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);3738 // 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);4344 // 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);4950 // Parcel watchers (many-to-many)51 builder.HasMany(p => p.Watchers)52 .WithMany(w => w.Parcels);5354 // Indexes for common queries55 builder.HasIndex(p => p.Status);56 builder.HasIndex(p => p.ServiceType);57 builder.HasIndex(p => p.CreatedAt);5859 // Decimal precision60 builder.Property(p => p.Weight)61 .HasPrecision(10, 2);6263 builder.Property(p => p.Length)64 .HasPrecision(10, 2);6566 builder.Property(p => p.Width)67 .HasPrecision(10, 2);6869 builder.Property(p => p.Height)70 .HasPrecision(10, 2);7172 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.
csharp1// Without explicit configuration, EF Core throws:2// "Unable to determine the relationship represented by navigation3// 'Parcel.ShipperAddress' of type 'Address'."45builder.HasOne(p => p.ShipperAddress)6 .WithMany() // No inverse navigation on Address7 .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
csharp1builder.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
csharp1builder.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:
csharp1// Unique index: no two parcels can share a tracking number2builder.HasIndex(p => p.TrackingNumber)3 .IsUnique();45// Regular indexes for filtering and sorting6builder.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:
csharp1// If you frequently query: WHERE Status = x AND ServiceType = y2builder.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:
csharp1builder.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
csharp1public class ParcelContentItemConfiguration : IEntityTypeConfiguration<ParcelContentItem>2{3 public void Configure(EntityTypeBuilder<ParcelContentItem> builder)4 {5 builder.HasKey(ci => ci.Id);67 builder.Property(ci => ci.Id)8 .ValueGeneratedOnAdd();910 builder.HasIndex(ci => ci.ParcelId);11 builder.HasIndex(ci => ci.HsCode);1213 builder.Property(ci => ci.UnitValue)14 .HasPrecision(12, 2);1516 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
csharp1public class TrackingEventConfiguration : IEntityTypeConfiguration<TrackingEvent>2{3 public void Configure(EntityTypeBuilder<TrackingEvent> builder)4 {5 builder.HasKey(te => te.Id);67 builder.Property(te => te.Id)8 .ValueGeneratedOnAdd();910 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
csharp1public class DeliveryConfirmationConfiguration : IEntityTypeConfiguration<DeliveryConfirmation>2{3 public void Configure(EntityTypeBuilder<DeliveryConfirmation> builder)4 {5 builder.HasKey(dc => dc.Id);67 builder.Property(dc => dc.Id)8 .ValueGeneratedOnAdd();910 // Ensure one confirmation per parcel at the database level11 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
csharp1public class ParcelWatcherConfiguration : IEntityTypeConfiguration<ParcelWatcher>2{3 public void Configure(EntityTypeBuilder<ParcelWatcher> builder)4 {5 builder.HasKey(w => w.Id);67 builder.Property(w => w.Id)8 .ValueGeneratedOnAdd();910 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:
csharp1// Store as string instead of integer2builder.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:
csharp1protected override void OnModelCreating(ModelBuilder modelBuilder)2{3 modelBuilder.ApplyConfigurationsFromAssembly(typeof(ParcelTrackingDbContext).Assembly);45 // Seed is optional; useful for development/testing6}
If your design uses a ServiceType lookup table instead of an enum, you would seed it like this:
csharp1builder.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:
bash1dotnet 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:
- Builds the solution
- Creates the DbContext using the registered configuration from the startup project
- Compares the current model to the database (which does not exist yet)
- 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 metadata5 └── ParcelTrackingDbContextModelSnapshot.cs # Current model state
The Migration File
The Up method creates tables:
csharp1protected override void Up(MigrationBuilder migrationBuilder)2{3 migrationBuilder.CreateTable(4 name: "Addresses",5 columns: table => new6 {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 columns12 },13 constraints: table =>14 {15 table.PrimaryKey("PK_Addresses", x => x.Id);16 });1718 migrationBuilder.CreateTable(19 name: "Parcels",20 columns: table => new21 {22 Id = table.Column<Guid>(type: "uuid", nullable: false),23 TrackingNumber = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),24 // ... more columns25 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 });3637 // ... TrackingEvents, DeliveryConfirmations, ParcelWatchers,38 // and ParcelParcelWatcher (join table) tables39}
The Down method reverses the migration by dropping the tables in reverse dependency order.
Applying the Migration
Apply the migration to create the database:
bash1dotnet 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:
bash1docker exec -it parceltracking-db psql -U parcel -d parceltracking \2 -c "\dt"
Or within C# during development:
csharp1app.MapGet("/debug/schema", async (ParcelTrackingDbContext db) =>2{3 var tables = await db.Database4 .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:
- Change the model: Add a property, modify a relationship, add an index
- Create a migration:
dotnet ef migrations add DescriptiveName --project src/ParcelTracking.Infrastructure --startup-project src/ParcelTracking.Api - Review the migration: Check the generated
UpandDownmethods - 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:
bash1dotnet 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 efCLI
In the next topic, we will build the Address Management API endpoints using this foundation.