در معماری داده، تعریف درست رابطه‌ها میان موجودیت‌ها (Entities) برای یکپارچگی، کارایی و سادگی نگه‌داری ضروری است. در EF Core سه الگوی رایج روابط عبارت‌اند از: یک‌به‌یک (1–1)، یک‌به‌چند (1–N) و چندبه‌چند (N–N). در این مقاله، هر سه رابطه را با دو رویکرد Data Annotations و Fluent API پیاده‌سازی می‌کنیم و نکات مهمی مثل Required/Optional، رفتار حذف (DeleteBehavior)، کلیدها و ایندکس‌ها، و مدل‌سازی جدول واسط با یا بدون Payload را مرور می‌کنیم.

۱) رابطه یک‌به‌یک (One-to-One)

سناریوی مرسوم: هر User دقیقاً یک UserProfile دارد. کلید خارجی معمولاً در جدول وابسته (UserProfile) نگهداری می‌شود. دقت کنید که برای 1–1 باید یکی از طرفین مالک کلید خارجی باشد.

Data Annotations


public class User
{
    public int Id { get; set; }

    [Required, MaxLength(100)]
    public string Name { get; set; } = string.Empty;

    public UserProfile? Profile { get; set; }   // Optional navigation from principal
}

public class UserProfile
{
    public int Id { get; set; }                 // PK == FK (الگوی متداول برای 1-1)
    [Required, MaxLength(200)]
    public string Address { get; set; } = string.Empty;

    [Required]
    public User User { get; set; } = default!;
}
  

Fluent API (کلید/کلیدخارجی، Required، DeleteBehavior)


// در OnModelCreating:
modelBuilder.Entity<User>(e =>
{
    e.ToTable("Users");
    e.HasKey(u => u.Id);
    e.Property(u => u.Name).IsRequired().HasMaxLength(100);
});

modelBuilder.Entity<UserProfile>(e =>
{
    e.ToTable("UserProfiles");
    e.HasKey(p => p.Id);

    // الگوی 1-1 با PK=FK در جدول وابسته
    e.HasOne(p => p.User)
     .WithOne(u => u.Profile)
     .HasForeignKey<UserProfile>(p => p.Id)       // FK = Id
     .IsRequired()
     .OnDelete(DeleteBehavior.Cascade);              // حذف کاربر، پروفایل را هم حذف کند
});
  

نکته: اگر نخواهید PK=FK باشد، می‌توانید در UserProfile فیلد UserId داشته باشید و با HasForeignKey<UserProfile>(x => x.UserId) نگاشت کنید؛ اما برای 1–1 باید Unique Index روی UserId بگذارید تا یک‌به‌یک باقی بماند:


modelBuilder.Entity<UserProfile>()
    .HasIndex(p => p.UserId)
    .IsUnique();
  

۲) رابطه یک‌به‌چند (One-to-Many)

سناریوی مرسوم: یک Category چندین Product دارد. کلید خارجی در سمت «چند» (Product) قرار می‌گیرد.

Data Annotations


public class Category
{
    public int Id { get; set; }

    [Required, MaxLength(120)]
    public string Title { get; set; } = string.Empty;

    public List<Product> Products { get; set; } = new();
}

public class Product
{
    public int Id { get; set; }

    [Required, MaxLength(150)]
    public string Name { get; set; } = string.Empty;

    [Precision(18,2)]
    public decimal Price { get; set; }

    public int CategoryId { get; set; }      // FK
    public Category Category { get; set; } = default!;
}
  

Fluent API (ایندکس‌ها، رفتار حذف، Navigation)


// OnModelCreating:
modelBuilder.Entity<Category>(e =>
{
    e.ToTable("Categories");
    e.HasKey(c => c.Id);
    e.Property(c => c.Title).IsRequired().HasMaxLength(120);

    // ایندکس برای جستجو
    e.HasIndex(c => c.Title);
});

modelBuilder.Entity<Product>(e =>
{
    e.ToTable("Products");
    e.HasKey(p => p.Id);
    e.Property(p => p.Name).IsRequired().HasMaxLength(150);
    e.Property(p => p.Price).HasPrecision(18,2);

    e.HasOne(p => p.Category)
     .WithMany(c => c.Products)
     .HasForeignKey(p => p.CategoryId)
     .IsRequired()
     .OnDelete(DeleteBehavior.Restrict);   // جلوگیری از حذف دسته‌ای که محصول دارد
});
  

اگر بخواهید رابطه اختیاری باشد، CategoryId را int? تعریف و .IsRequired(false) تنظیم کنید. همچنین برای سناریوهای گزارش‌گیری سنگین، استفاده از AsNoTracking() و SplitQuery() را در نظر بگیرید.

۳) رابطه چندبه‌چند (Many-to-Many) – بدون جدول واسط سفارشی

از EF Core 5 به بعد، می‌توان N–N را بدون تعریف کلاس واسط صریح مدل کرد. مثال: دانشجوهای متعدد در دوره‌های متعدد شرکت می‌کنند.

مدل ساده (Implicit Join)


public class Student
{
    public int Id { get; set; }
    [Required, MaxLength(120)]
    public string Name { get; set; } = string.Empty;

    public List<Course> Courses { get; set; } = new();
}

public class Course
{
    public int Id { get; set; }
    [Required, MaxLength(160)]
    public string Title { get; set; } = string.Empty;

    public List<Student> Students { get; set; } = new();
}
  

Fluent API (نام‌گذاری جدول واسط و ایندکس‌ها)


// OnModelCreating:
modelBuilder.Entity<Student>(e =>
{
    e.ToTable("Students");
    e.HasKey(s => s.Id);
    e.Property(s => s.Name).IsRequired().HasMaxLength(120);

    e.HasMany(s => s.Courses)
     .WithMany(c => c.Students)
     .UsingEntity<Dictionary<string, object>>(
        "StudentCourse",                                  // نام جدول واسط
        r => r.HasOne<Course>().WithMany().HasForeignKey("CourseId")
              .OnDelete(DeleteBehavior.Cascade),
        l => l.HasOne<Student>().WithMany().HasForeignKey("StudentId")
              .OnDelete(DeleteBehavior.Cascade),
        j => {
            j.ToTable("StudentCourses");
            j.HasKey("StudentId", "CourseId");
            j.HasIndex("CourseId");
            j.HasIndex("StudentId");
        }
     );
});
  

۴) رابطه چندبه‌چند با جدول واسط سفارشی (Payload)

وقتی جدول واسط نیاز به ستون‌های اضافی (مثل تاریخ ثبت‌نام، نمره، وضعیت) دارد، باید کلاس واسط مجزا بسازید و رابطه را به دو رابطه 1–N بشکنید.

مدل با Payload


public class Enrollment          // جدول واسط با Payload
{
    public int StudentId { get; set; }
    public int CourseId  { get; set; }

    public DateTime EnrolledAt { get; set; } = DateTime.UtcNow;
    [Precision(5,2)]
    public decimal? Grade { get; set; }

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

public class Student
{
    public int Id { get; set; }
    [Required, MaxLength(120)]
    public string Name { get; set; } = string.Empty;

    public List<Enrollment> Enrollments { get; set; } = new();
}

public class Course
{
    public int Id { get; set; }
    [Required, MaxLength(160)]
    public string Title { get; set; } = string.Empty;

    public List<Enrollment> Enrollments { get; set; } = new();
}
  

Fluent API (کلید مرکب، روابط، DeleteBehavior)


// OnModelCreating:
modelBuilder.Entity<Enrollment>(e =>
{
    e.ToTable("Enrollments");
    e.HasKey(x => new { x.StudentId, x.CourseId });    // PK مرکب

    e.Property(x => x.EnrolledAt)
     .HasDefaultValueSql("GETUTCDATE()");

    e.HasOne(x => x.Student)
     .WithMany(s => s.Enrollments)
     .HasForeignKey(x => x.StudentId)
     .OnDelete(DeleteBehavior.Cascade);

    e.HasOne(x => x.Course)
     .WithMany(c => c.Enrollments)
     .HasForeignKey(x => x.CourseId)
     .OnDelete(DeleteBehavior.Cascade);
});
  

۵) Required/Optional، رفتار حذف، و نکات تکمیلی

  • رابطه Required: نوع FK غیر Nullable (مثل int) و .IsRequired() در Fluent.
  • رابطه Optional: نوع FK Nullable (مثل int?) و .IsRequired(false).
  • DeleteBehavior: از Restrict برای جلوگیری از حذف والد دارای فرزند، از Cascade برای حذف خودکار فرزندان، و در صورت نیاز از SetNull استفاده کنید (FK باید Nullable باشد).
  • ایندکس‌ها: بر روی کلیدهای خارجی و ستون‌های پرجست‌وجو ایندکس بگذارید.
  • نام‌گذاری: با ToTable و HasColumnName استاندارد خود را اعمال کنید.

نمونه‌ی کامل DbContext (تجمیع پیکربندی‌ها)


public class AppDbContext : DbContext
{
    public DbSet<User> Users => Set<User>();
    public DbSet<UserProfile> UserProfiles => Set<UserProfile>();
    public DbSet<Category> Categories => Set<Category>();
    public DbSet<Product> Products => Set<Product>();
    public DbSet<Student> Students => Set<Student>();
    public DbSet<Course> Courses => Set<Course>();
    public DbSet<Enrollment> Enrollments => Set<Enrollment>();

    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) {}

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // 1-1: User <-> UserProfile (PK=FK)
        modelBuilder.Entity<User>(e =>
        {
            e.ToTable("Users");
            e.HasKey(u => u.Id);
            e.Property(u => u.Name).IsRequired().HasMaxLength(100);
        });

        modelBuilder.Entity<UserProfile>(e =>
        {
            e.ToTable("UserProfiles");
            e.HasKey(p => p.Id);
            e.Property(p => p.Address).IsRequired().HasMaxLength(200);

            e.HasOne(p => p.User)
             .WithOne(u => u.Profile)
             .HasForeignKey<UserProfile>(p => p.Id)
             .IsRequired()
             .OnDelete(DeleteBehavior.Cascade);
        });

        // 1-N: Category -> Products
        modelBuilder.Entity<Category>(e =>
        {
            e.ToTable("Categories");
            e.HasKey(c => c.Id);
            e.Property(c => c.Title).IsRequired().HasMaxLength(120);
            e.HasIndex(c => c.Title);
        });

        modelBuilder.Entity<Product>(e =>
        {
            e.ToTable("Products");
            e.HasKey(p => p.Id);
            e.Property(p => p.Name).IsRequired().HasMaxLength(150);
            e.Property(p => p.Price).HasPrecision(18,2);

            e.HasOne(p => p.Category)
             .WithMany(c => c.Products)
             .HasForeignKey(p => p.CategoryId)
             .IsRequired()
             .OnDelete(DeleteBehavior.Restrict);
        });

        // N-N (Implicit): Student <-> Course با جدول واسط خودکار
        modelBuilder.Entity<Student>(e =>
        {
            e.ToTable("Students");
            e.HasKey(s => s.Id);
            e.Property(s => s.Name).IsRequired().HasMaxLength(120);

            e.HasMany(s => s.Courses)
             .WithMany(c => c.Students)
             .UsingEntity<Dictionary<string, object>>(
                "StudentCourses",
                r => r.HasOne<Course>().WithMany().HasForeignKey("CourseId").OnDelete(DeleteBehavior.Cascade),
                l => l.HasOne<Student>().WithMany().HasForeignKey("StudentId").OnDelete(DeleteBehavior.Cascade),
                j => {
                    j.ToTable("StudentCourses");
                    j.HasKey("StudentId", "CourseId");
                    j.HasIndex("CourseId");
                    j.HasIndex("StudentId");
                }
             );
        });

        // N-N با Payload: Enrollment به‌صورت 1-N + 1-N
        modelBuilder.Entity<Enrollment>(e =>
        {
            e.ToTable("Enrollments");
            e.HasKey(x => new { x.StudentId, x.CourseId });
            e.Property(x => x.EnrolledAt).HasDefaultValueSql("GETUTCDATE()");
            e.Property(x => x.Grade).HasPrecision(5,2);

            e.HasOne(x => x.Student)
             .WithMany(s => s.Enrollments)
             .HasForeignKey(x => x.StudentId)
             .OnDelete(DeleteBehavior.Cascade);

            e.HasOne(x => x.Course)
             .WithMany(c => c.Enrollments)
             .HasForeignKey(x => x.CourseId)
             .OnDelete(DeleteBehavior.Cascade);
        });
    }
}
  

جمع‌بندی: برای 1–1 یکی از طرفین باید مالک FK باشد و معمولاً از PK=FK استفاده می‌شود؛ برای 1–N، FK در سمت «چند» قرار می‌گیرد و رفتار حذف را آگاهانه انتخاب کنید؛ برای N–N اگر Payload ندارید از نگاشت ضمنی استفاده کنید و در صورت نیاز به ستون‌های اضافی، جدول واسط سفارشی (Payload) بسازید.