Introduction to EF Core Relationships Modeling

Introduction

This article explains the necessary terms used while designing a data model and introduces you to different ways to configure relationships in EF Core.

Furthermore, the article distinguishes the three methods for configuring EF Core relationships: via convention, data annotations, and FluentAPI.

Defining some relationship terms

This section refers to the various parts of a relationship and will explain all the necessary terms you need to know before start designing your database.

To ensure that all the terms are clear, here are the detailed descriptions:

  • Principal Key - This refers to either the primary key or the alternative key which has a unique value per row and isn't the primary key. Principal Key uniquely identifies a given database record.

  • Principal Entity - The entity that has a principal key, which is unique for each entity stored in the database, and the dependent relationship refers to via a foreign key(s).

  • Foreign Key - Holds the principal key value(s) of the database row it's linked to.

  • Dependent Entity - The entity that contains the foreign key properties that refer to the principal entity.

  • Navigational Property - The property that contains a single entity class, or a collection of entity classes, that EF Core uses to link entity classes.

  • Required Relationship - A relationship in which the foreign key is non-nullable (and principal entity must exist).

  • Optional Relationship - A relationship in which the foreign key is nullable (and the principal entity can be missing).

As a side note, a principal key and a foreign key can consist of more than one property/column. These keys are called composite keys.

All of these new terms will be illustrated in the following sections.

Explaining the data model

Below there's our sample data model that will be modeled by using EF Core's code first approach.

This represents a possible starting point database for a school/university.

Tables and relationships are presented below:

  • A "StudentProfile" (dependent entity) is associated with one "Student" (principal entity). This is an illustration of a required "One-To-One" relationship. This relationship's dependant entity will contain the foreign key ("StudentId") that connects the two tables.

  • A "Student" must take numerous "Courses," whereas a "Course" may have multiple "Students" enrolled in it. To do this, we will have an additional table ("CourseStudent") that will hold pointers to the two tables' primary keys: "StudentId" and "CourseId". These two together represent a composite primary key for the "CourseStudent" table. This is an illustration of a required "Many-To-Many" relationship.

  • A "TeacherProfile" (dependent entity) is associated with one "Teacher" (principal entity). This is yet another instance of a required "One-To-One" relationship. This relationship's dependant entity will contain the foreign key ("TeacherId") that connects the two tables.

  • A "Teacher" can teach numerous "Courses," whereas a "Course" only has one responsible teacher. This is an illustration of a required "One-to-Many" relationship.

Configuring Relationships by Convention

When it comes to configuring relationships, the By Convention technique saves a lot of time.

Conventions are a set of hard-coded rules that regulate how the model is mapped to a database schema in Entity Framework Core.

Below, we're going to exemplify the EF Core conventions that were used for modeling the database entities.

public class Student
{
    public Guid Id { get; set; }

    public StudentProfile StudentProfile { get; set; } = default!;

    public ICollection<Course> Courses { get; set; } = default!;
}

"Id" is the primary key for the "Student" entity. EF Core looks for properties with the name "Id" or "class_name>Id" to identify the primary key for a particular entity.

In this case, "StudentId" is another suitable primary key name for the "Student".

If EF Core could not find a valid primary key, an exception would be raised.

Because it is not a scalar value (e.g. int, string, etc.), "StudentProfile" is a navigational property. Same for the "Courses" property.

public class StudentProfile
{
    public Guid Id { get; set; }

    public string FirstName { get; set; } = string.Empty;

    public string LastName { get; set; } = string.Empty;

    public DateTime BirthDay { get; set; } = DateTime.MinValue;

    public Guid StudentId { get; set; }

    public Student Student { get; set; } = default!;

    public string ParentContactNumber { get; set; } = string.Empty;

    public string EmergencyContactNumber { get; set; } = string.Empty;

    public double GPA { get; set; }
}

The primary key for "StudentProfile" is "Id" as it follows the same conventions that were described in the previous entity example.

The properties "StudentId" and "Student" denote a foreign key and a navigation property, respectively, that are used to establish a link between the "Student" and its corresponding "StudentProfile."

The rest of the properties will correspond to the columns with the same name in the resulting table.

public class Teacher
{
    public Guid Id { get; set; }

    public TeacherProfile TeacherProfile { get; set; } = default!;

    public ICollection<Course> Courses { get; set; } = default!;
}

Similar to the "Student" example, "Teacher" will have "Id" as the primary key, and "TeacherProfile" and "Courses" are considered navigational properties.

public class TeacherProfile
{
    public Guid Id { get; set; }

    public string FirstName { get; set; } = string.Empty;

    public string LastName { get; set; } = string.Empty;

    public DateTime BirthDay { get; set; } = DateTime.MinValue;

    public Guid TeacherId { get; set; }

    public Teacher Teacher { get; set; } = default!;

    public string Department { get; set; } = string.Empty;

    public string AcademicTitle { get; set; } = string.Empty;

    public decimal Salary { get; set; }
}

Like the "StudentProfile" example, "TeacherProfile" will have "Id" as the primary key, "TeacherId" represents the foreign key that links "Teacher" to its corresponding "TeacherProfile", and the remaining properties will correspond to the columns with the same name in the generated table except for "Teacher" property that represents a navigational property.

public class Course
{
    public Guid Id { get; set; }

    public string Name { get; set; } = string.Empty;

    public Guid TeacherId { get; set; }

    public Teacher Teacher { get; set; } = default!;

    public ICollection<Student> Students { get; set; } = default!;
}

"Course" will have "Id" as its corresponding primary key. "Name" will correspond to the column with the same name from the resulting table.

Also, for linking the teacher to their corresponding courses, there is defined a foreign key ("TeacherId") and a navigation property ("Teacher").

Navigation property is also "Students" that links "students" with the "courses" they are enrolled in.

Using "by convention" means we can't establish the intermediate entity that links "Student" and "Course" since we can't generate a composite key from "StudentId" and "CourseId".

In this case, EF Core will generate the interim table ("CourseStudent"), but we can't expand it.

The database context for the "by convention" database modeling is shown below. This doesn't contain any specific configuration logic, as we used a "by convention" modeling strategy.

public class ApplicationDbContext : DbContext
{
    public DbSet<Student> Students { get; set; } = default!;

    public DbSet<Teacher> Teachers { get; set; } = default!;

    public DbSet<Course> Courses { get; set; } = default!;

    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options)
    {

    }
}

The configuration of the database context is shown below:

// appsettings.json
"ConnectionStrings": {
    "DbConn": "Server=127.0.0.1;Port=5432;Database=byConventionDb;User Id=postgres;Password=postgres;"
}
// Program.cs
// Data services
builder.Services.AddDbContext<ApplicationDbContext>(opt => opt.UseNpgsql(builder.Configuration.GetConnectionString("DbConn")));

Configuring Relationships by Data Annotations

As we have previously seen, the "by convention" method has a series of limitations. For instance, you can't customize the "CourseStudents" table as you want in the many-to-many relationship example.

Additionally, the level of customization for entities and attributes is limited to the options provided by the convention-based technique.

In order to overcome these limitations, we could use annotations to specify hints for the EF Core regarding entities and property roles.

Here are the changed data models that have been adjusted to incorporate data annotations.

[Table("Students")]
public class Student
{
    [Key]
    public Guid Id { get; set; }

    public StudentProfile StudentProfile { get; set; } = default!;

    // StudentId from StudentCourse
    [ForeignKey("StudentId")]
    public ICollection<StudentCourse> StudentCourses { get; set; } = default!;
}

The "Table" annotation specifies the name of the table that the "Student" entity will be associated with.

The primary key is now emphasized with the "Key" annotation, which clarifies its role.

Additionally, we provide the "StudentId" foreign key by utilizing the annotation with the same name.

[Table("StudentProfiles")]
public class StudentProfile
{
    [Key]
    public Guid Id { get; set; }

    [Column("firstName")]
    public string FirstName { get; set; } = string.Empty;

    [Column("lastName")]
    public string LastName { get; set; } = string.Empty;

    [Column("birthDay")]
    public DateTime BirthDay { get; set; } = DateTime.MinValue;

    [ForeignKey(nameof(Student))]
    public Guid StudentId { get; set; }

    public Student Student { get; set; } = default!;

    [Column("parentContactNumber")]
    public string ParentContactNumber { get; set; } = string.Empty;

    [Column("emergencyContactNumber")]
    public string EmergencyContactNumber { get; set; } = string.Empty;

    [Column("gpa")]
    public double GPA { get; set; }

}

The "Table" annotation specifies the name of the table that the "StudentProfile" entity will be associated with.

We use the "ForeignKey" annotation for linking the "Student" with it's corresponding "StudentProfile".

Moreover, we use the "Column" annotation to specify the column name that each property will be mapped to.

[Table("CourseStudent")]
[PrimaryKey(nameof(StudentId), nameof(CourseId))]
public class StudentCourse
{
    public Guid StudentId { get; set; }

    public Student Student { get; set; } = default!;

    public Guid CourseId { get; set; }

    public Course Course { get; set; } = default!;

}

The "Table" annotation specifies the name of the table that the "StudentCourse" entity will be associated with.

The "PrimaryKey" is used to define the primary key of the current entity. This is a composed primary key, consisting of a "StudentId" and a "CourseId".

[Table("Teachers")]
public class Teacher
{
    [Key]
    public Guid Id { get; set; }

    public TeacherProfile TeacherProfile { get; set; } = default!;

    public ICollection<Course> Courses { get; set; } = default!;

}

The "Table" annotation specifies the name of the table that the "Teacher" entity will be associated with.

The "Key" annotation is used to highlight the primary key of the current entity.

[Table("TeacherProfiles")]
public class TeacherProfile
{
    [Key]
    public Guid Id { get; set; }

    [Column("firstName")]
    public string FirstName { get; set; } = string.Empty;

    [Column("lastName")]
    public string LastName { get; set; } = string.Empty;

    [Column("birthDay")]
    public DateTime BirthDay { get; set; } = DateTime.MinValue;

    [ForeignKey(nameof(Teacher))]
    public Guid TeacherId { get; set; }

    public Teacher Teacher { get; set; } = default!;

    [Column("department")]
    public string Department { get; set; } = string.Empty;

    [Column("academicTitle")]
    public string AcademicTitle { get; set; } = string.Empty;

    [Column("salary")]
    public decimal Salary { get; set; }

}

The "Table" annotation specifies the name of the table that the "TeacherProfiles" entity will be associated with.

The "Key" annotation is used to highlight the primary key of the current entity.

We use the "ForeignKey" annotation for linking the "Teacher" with it's corresponding "TeacherProfile".

Moreover, we use the "Column" annotation to specify the column name that each property will be mapped to.

[Table("Courses")]
public class Course
{
    [Key]
    public Guid Id { get; set; }

    [Column("name")]
    public string Name { get; set; } = string.Empty;

    [ForeignKey(nameof(Teacher))]
    public Guid TeacherId { get; set; }

    public Teacher Teacher { get; set; } = default!;

    // CourseId from StudentCourse
    [ForeignKey("CourseId")]
    public ICollection<StudentCourse> StudentCourses { get; set; } = default!;
}

The "Table" annotation specifies the name of the table that the "Course" entity will be associated with.

The "Key" annotation is used to highlight the primary key of the current entity.

We use the "ForeignKey" annotation for linking the "teacher" with it's corresponding "courses".

Additionally, we provide the "CourseId" foreign key by utilizing the annotation with the same name.

The database context for the "by annotation" database modeling is shown below. This doesn't contain any specific configuration logic, as we used a "by annotation" modeling strategy.

public class ApplicationDbContext : DbContext
{
    public DbSet<Student> Students { get; set; } = default!;

    public DbSet<Teacher> Teachers { get; set; } = default!;

    public DbSet<Course> Courses { get; set; } = default!;

    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options)
    {

    }
}

The configuration of the database context is shown below:

// appsettings.json
"ConnectionStrings": {
    "DbConn": "Server=127.0.0.1;Port=5432;Database=byConventionDb;User Id=postgres;Password=postgres;"
}
// Program.cs
// Data services
builder.Services.AddDbContext<ApplicationDbContext>(opt => opt.UseNpgsql(builder.Configuration.GetConnectionString("DbConn")));

Configuring Relationships by FluentAPI

The "by FluentAPI" modeling strategy offers the highest degree of customization among the three known methods.

We will use a similar model configuration with the "by convention" method.

As a result, we will not go over the same details as in the previous modeling technique.

Please refer to that section for a description of the models.

The modeling logic is described by the FluentAPI in the database context class.

public class Student
{
    public Guid Id { get; set; }

    public StudentProfile StudentProfile { get; set; } = default!;

    public ICollection<StudentCourse> StudentCourses { get; set; } = default!;
}
public class StudentProfile
{
    public Guid Id { get; set; }

    public string FirstName { get; set; } = string.Empty;

    public string LastName { get; set; } = string.Empty;

    public DateTime BirthDay { get; set; } = DateTime.MinValue;

    public Guid StudentId { get; set; }

    public Student Student { get; set; } = default!;

    public string ParentContactNumber { get; set; } = string.Empty;

    public string EmergencyContactNumber { get; set; } = string.Empty;

    public double GPA { get; set; }
}
public class StudentCourse
{
    public Guid StudentId { get; set; }

    public Student Student { get; set; } = default!;

    public Guid CourseId { get; set; }

    public Course Course { get; set; } = default!;
}
public class Teacher
{
    public Guid Id { get; set; }

    public TeacherProfile TeacherProfile { get; set; } = default!;

    public ICollection<Course> Courses { get; set; } = default!;
}
public class TeacherProfile
{
    public Guid Id { get; set; }

    public string FirstName { get; set; } = string.Empty;

    public string LastName { get; set; } = string.Empty;

    public DateTime BirthDay { get; set; } = DateTime.MinValue;

    public Guid TeacherId { get; set; }

    public Teacher Teacher { get; set; } = default!;

    public string Department { get; set; } = string.Empty;

    public string AcademicTitle { get; set; } = string.Empty;

    public decimal Salary { get; set; }
}
public class Course
{
    public Guid Id { get; set; }

    public string Name { get; set; } = string.Empty;

    public Guid TeacherId { get; set; }

    public Teacher Teacher { get; set; } = default!;

    public ICollection<StudentCourse> StudentCourses { get; set; } = default!;
}
public class ApplicationDbContext : DbContext
{

    public DbSet<Student> Students { get; set; } = default!;

    public DbSet<Teacher> Teachers { get; set; } = default!;

    public DbSet<Course> Courses { get; set; } = default!;

    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options)
    {

    }



    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {

    // One to one
    modelBuilder.Entity<Student>()
        .HasOne(s => s.StudentProfile)
        .WithOne(sp => sp.Student)
        .HasForeignKey<StudentProfile>(sp => sp.StudentId);

    modelBuilder.Entity<Teacher>()
        .HasOne(t => t.TeacherProfile)
        .WithOne(tp => tp.Teacher)
        .HasForeignKey<TeacherProfile>(tp => tp.TeacherId);

    // One to many
    modelBuilder.Entity<Course>()
        .HasOne(c => c.Teacher)
        .WithMany(t => t.Courses)
        .HasForeignKey(c => c.TeacherId);

    // Many to many
    modelBuilder.Entity<StudentCourse>()
        .HasKey(sc => new { sc.StudentId, sc.CourseId });

    modelBuilder
        .Entity<StudentCourse>().ToTable("CourseStudent");

    modelBuilder
        .Entity<StudentCourse>()
        .HasOne(sc => sc.Student)
        .WithMany(s => s.StudentCourses);

    modelBuilder
        .Entity<StudentCourse>()
        .HasOne(sc => sc.Course)
        .WithMany(c => c.StudentCourses);
    }
}

The "OnModelCreating" method describes the relationships between data model entities.

The "one-to-one" relationships between "Student" and "StudentProfile", respectively "Teacher" and "TeacherProfile" are described by using "HasOne" and "WithOne" methods.

In these circumstances, we must also indicate the foreign keys.

We do this by invoking the "HasForeignKey" method.

The "one-to-many" relation between "Teacher" and "Course" is described by using "HasOne" and "WithMany" methods.

Like in the previous case, we need to indicate the foreign key for the relationship, and we use the same "HasForeignKey" method mentioned before.

The "many-to-many" relationship between students and courses is described in multiple steps.

In the first place, we define the composed primary key for the intermediary "CourseStudent" table by using the "HasKey" method.

Then, we define "one-to-many" relationships between "Student" and "StudentCourse," respectively, "course" and "student course," by using the same methods mentioned before.

The configuration of the database context is shown below:

// appsettings.json
"ConnectionStrings": {
    "DbConn": "Server=127.0.0.1;Port=5432;Database=byConventionDb;User Id=postgres;Password=postgres;"
}
// Program.cs
// Data services
builder.Services.AddDbContext<ApplicationDbContext>(opt => opt.UseNpgsql(builder.Configur

Conclusion

In conclusion, understanding and effectively configuring relationships in EF Core is crucial for designing a robust and efficient database model. This article has provided a comprehensive overview of key relationship terms, including principal key, principal entity, foreign key, dependent entity, navigational property, required relationship, optional relationship, and composite keys.

The discussion delved into three distinct methods for configuring relationships in EF Core: by convention, data annotations, and FluentAPI. The "by convention" approach demonstrated how EF Core automatically infers relationships based on naming conventions, saving time and effort but limiting customization options. The "by data annotations" method showcased a more explicit way to define relationships, allowing for increased customization and overcoming some limitations of the convention-based approach. Finally, the "by FluentAPI" strategy demonstrated the highest level of customization, offering fine-grained control over the configuration of relationships.

The practical examples illustrated these concepts in the context of a school/university database, showcasing one-to-one, one-to-many, and many-to-many relationships among entities such as students, teachers, profiles, and courses.

Choosing the appropriate method for relationship configuration depends on the specific requirements of the application and the desired level of customization. While the "by convention" method is convenient for simple scenarios, the "by data annotations" and "by FluentAPI" approaches provide more flexibility and control for complex database designs.

In conclusion, mastering the art of configuring relationships in EF Core empowers developers to create efficient and well-organized database models tailored to the needs of their applications.