Introduction to EF Core and Domain Modeling — Part 4: Seed Data and Putting It All Together
Series Overview
This is a 4-part series on Entity Framework Core and domain modeling in .NET:
- Setup and Your First DbContext — What is EF Core, project setup, DbContext, dependency injection, migrations
- One-to-One and One-to-Many Relationships — Modeling relationships between entities with the Fluent API
- Many-to-Many Relationships — Join tables, implicit and explicit approaches, unidirectional navigation
- Seed Data and Putting It All Together (this article) — Populating your database, complete model example, next steps
Introduction
Over the previous three parts, we’ve built up a domain model with entities and relationships. But a schema without data is just structure. In this final part, we’ll cover how to populate the database with initial data using EF Core’s seed data feature, then bring the entire series together in a complete model example.
Seed Data with HasData()
EF Core lets you associate seed data with entity types as part of the model configuration. When you add or update a migration, EF Core automatically computes the required insert, update, or delete operations to bring the data in line with your configuration.
The simplest example — seeding lookup data:
1
2
3
4
5
6
7
8
modelBuilder.Entity<Country>(b =>
{
b.Property(x => x.Name).IsRequired();
b.HasData(
new Country { CountryId = 1, Name = "USA" },
new Country { CountryId = 2, Name = "Canada" },
new Country { CountryId = 3, Name = "Mexico" });
});
Key rules for HasData():
- Primary keys must be specified explicitly — even if they’re auto-generated in normal use. EF Core uses the PK to determine whether a row should be inserted, updated, or deleted in future migrations.
- Navigation properties are ignored — you cannot set
post.Blog = myBlog. Instead, set foreign key values directly. - Seed data is tied to migrations — it’s part of the schema management, not runtime logic. For large datasets or dynamic data, use a separate seeding service at application startup.
Seeding Related Entities
When your entities have relationships, seed them by setting foreign key values — not navigation properties:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Seed blogs
modelBuilder.Entity<Blog>().HasData(
new Blog { Id = 1, Name = "Tech Blog", Url = "https://techblog.example.com" },
new Blog { Id = 2, Name = "Travel Blog", Url = "https://travelblog.example.com" }
);
// Seed blog headers (one-to-one with Blog)
modelBuilder.Entity<BlogHeader>().HasData(
new BlogHeader { Id = 1, BlogId = 1, Title = "Welcome to Tech Blog", Subtitle = "All things code" },
new BlogHeader { Id = 2, BlogId = 2, Title = "Adventures Await", Subtitle = "Stories from the road" }
);
// Seed posts (one-to-many with Blog)
modelBuilder.Entity<Post>().HasData(
new Post { Id = 1, BlogId = 1, Title = "Getting Started with EF Core" },
new Post { Id = 2, BlogId = 1, Title = "Domain Modeling Tips" },
new Post { Id = 3, BlogId = 2, Title = "Hiking in Norway" }
);
// Seed tags
modelBuilder.Entity<Tag>().HasData(
new Tag { Id = 1, Name = "efcore" },
new Tag { Id = 2, Name = "dotnet" },
new Tag { Id = 3, Name = "travel" }
);
}
Seeding Many-to-Many Join Data
For implicit many-to-many relationships (where EF Core manages the join table), you seed the join table using the anonymous type approach:
1
2
3
4
5
6
7
8
9
10
11
// Seed the PostTag join table
modelBuilder.Entity<Post>()
.HasMany(e => e.Tags)
.WithMany(e => e.Posts)
.UsingEntity(j => j.HasData(
new { PostsId = 1, TagsId = 1 }, // "Getting Started with EF Core" + "efcore"
new { PostsId = 1, TagsId = 2 }, // "Getting Started with EF Core" + "dotnet"
new { PostsId = 2, TagsId = 1 }, // "Domain Modeling Tips" + "efcore"
new { PostsId = 2, TagsId = 2 }, // "Domain Modeling Tips" + "dotnet"
new { PostsId = 3, TagsId = 3 } // "Hiking in Norway" + "travel"
));
The property names (PostsId, TagsId) must match the shadow property names EF Core generates for the join table. These are derived from the collection navigation property names.
The Complete Domain Model
Let’s bring together everything from this series into a single, complete example. This model includes all relationship types we’ve covered.
Entities
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class Blog
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
// One-to-one: Blog has one BlogHeader
public BlogHeader? Header { get; set; }
// One-to-many: Blog has many Posts
public ICollection<Post> Posts { get; } = new List<Post>();
}
public class BlogHeader
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Subtitle { get; set; } = string.Empty;
// One-to-one: BlogHeader belongs to one Blog
public int BlogId { get; set; }
public Blog Blog { get; set; } = null!;
}
public class Post
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
// One-to-many: Post belongs to one Blog
public int BlogId { get; set; }
public Blog Blog { get; set; } = null!;
// Many-to-many: Post has many Tags
public List<Tag> Tags { get; } = [];
}
public class Tag
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
// Many-to-many: Tag has many Posts
public List<Post> Posts { get; } = [];
}
DbContext
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
public DbSet<Blog> Blogs => Set<Blog>();
public DbSet<Post> Posts => Set<Post>();
public DbSet<Tag> Tags => Set<Tag>();
public DbSet<BlogHeader> BlogHeaders => Set<BlogHeader>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// One-to-one: Blog -> BlogHeader
modelBuilder.Entity<Blog>()
.HasOne(e => e.Header)
.WithOne(e => e.Blog)
.HasForeignKey<BlogHeader>(e => e.BlogId)
.IsRequired();
// One-to-many: Blog -> Posts
modelBuilder.Entity<Blog>()
.HasMany(e => e.Posts)
.WithOne(e => e.Blog)
.HasForeignKey(e => e.BlogId)
.IsRequired();
// Many-to-many: Post <-> Tag (EF Core manages the join table)
modelBuilder.Entity<Post>()
.HasMany(e => e.Tags)
.WithMany(e => e.Posts);
// Seed data
modelBuilder.Entity<Blog>().HasData(
new Blog { Id = 1, Name = "Tech Blog", Url = "https://techblog.example.com" }
);
modelBuilder.Entity<BlogHeader>().HasData(
new BlogHeader { Id = 1, BlogId = 1, Title = "Welcome", Subtitle = "All things code" }
);
modelBuilder.Entity<Post>().HasData(
new Post { Id = 1, BlogId = 1, Title = "Getting Started with EF Core" },
new Post { Id = 2, BlogId = 1, Title = "Domain Modeling Tips" }
);
modelBuilder.Entity<Tag>().HasData(
new Tag { Id = 1, Name = "efcore" },
new Tag { Id = 2, Name = "dotnet" }
);
modelBuilder.Entity<Post>()
.HasMany(e => e.Tags)
.WithMany(e => e.Posts)
.UsingEntity(j => j.HasData(
new { PostsId = 1, TagsId = 1 },
new { PostsId = 1, TagsId = 2 },
new { PostsId = 2, TagsId = 1 }
));
}
}
Generating the Migration
1
2
dotnet ef migrations add CompleteModel
dotnet ef database update
Review the generated migration to confirm EF Core created:
- A
Blogstable withId,Name,Url - A
BlogHeaderstable with a required FK toBlogs - A
Poststable with a required FK toBlogs - A
Tagstable - A
PostTagjoin table with a composite PK and two FKs - Insert statements for all seed data
What to Explore Next
This series covered the fundamentals of EF Core and domain modeling. Here are some topics to explore as your models grow more sophisticated:
- Value objects and owned types — model complex properties (e.g.
Address) as part of an entity without giving them their own table - Table-per-hierarchy (TPH) inheritance — map an inheritance hierarchy to a single table with a discriminator column
- Global query filters — automatically apply
WHEREclauses (e.g. soft-delete, multi-tenancy) to every query - Compiled queries — pre-compile frequently used LINQ queries for better performance
- Raw SQL and SQL queries — drop to raw SQL when LINQ isn’t enough
- Interceptors — hook into EF Core’s pipeline for auditing, logging, or modifying commands
Conclusion
Over four articles, we’ve gone from an empty project to a complete domain model with one-to-one, one-to-many, and many-to-many relationships, all configured through the Fluent API and populated with seed data. EF Core handles the translation between your C# domain and the relational database — letting you focus on modelling the problem rather than writing SQL.
The key takeaways:
- Start with conventions — EF Core discovers most relationships automatically
- Use the Fluent API when conventions aren’t enough — it gives you full control
- Choose the right relationship type for your domain, not your database
- Seed data keeps your development database consistent and your migrations reproducible