Relasi antar tabel adalah bagian penting dari desain database relasional, dan Entity Framework Core menyediakan cara deklaratif untuk mendefinisikan relasi tersebut langsung dari model C#, baik relasi one-to-many maupun many-to-many, tanpa perlu menulis foreign key secara manual lewat SQL.

Relasi one-to-many terjadi ketika satu baris pada satu tabel bisa berelasi dengan banyak baris di tabel lain, seperti satu Kategori memiliki banyak Produk, sementara relasi many-to-many terjadi ketika banyak baris di satu tabel bisa berelasi dengan banyak baris di tabel lain, seperti Produk dan Tag.

Artikel ini membahas cara mendefinisikan kedua jenis relasi ini di EF Core, termasuk navigation property yang memudahkan akses data terkait, serta skip navigation yang menyederhanakan relasi many-to-many tanpa perlu mendefinisikan entity perantara secara eksplisit.

Mendefinisikan Relasi One-to-Many

List pada Kategori dan foreign key pada Produk mendefinisikan relasi

public class Kategori { public int Id { get; set; } public string Nama { get; set; } public List<Produk> DaftarProduk { get; set; } = new(); } public class Produk { public int Id { get; set; } public string Nama { get; set; } public int KategoriId { get; set; } // foreign key public Kategori Kategori { get; set; } // navigation property }
Property KategoriId pada Produk berperan sebagai foreign key, sementara navigation property Kategori memungkinkan akses langsung ke object Kategori terkait tanpa perlu query terpisah. Di sisi Kategori, List memungkinkan akses ke seluruh produk yang termasuk kategori tersebut secara langsung.

Mengambil Data Terkait dengan Include

Include memuat data relasi sekaligus dalam satu query

var kategori = db.Kategori .Include(k => k.DaftarProduk) .FirstOrDefault(k => k.Id == 1); foreach (var p in kategori.DaftarProduk) Console.WriteLine(p.Nama);
Secara default, EF Core tidak memuat data relasi secara otomatis (lazy loading harus diaktifkan eksplisit atau dihindari untuk performa yang lebih bisa diprediksi), sehingga Include diperlukan untuk memuat data terkait, seperti DaftarProduk pada contoh ini, dalam satu query JOIN yang efisien alih-alih beberapa query terpisah.

Mendefinisikan Relasi Many-to-Many

EF Core modern mendukung many-to-many tanpa entity perantara eksplisit

public class Produk { public int Id { get; set; } public string Nama { get; set; } public List<Tag> DaftarTag { get; set; } = new(); } public class Tag { public int Id { get; set; } public string Nama { get; set; } public List<Produk> DaftarProduk { get; set; } = new(); }
Sejak EF Core 5, relasi many-to-many bisa didefinisikan langsung dengan List pada kedua sisi tanpa perlu membuat class perantara (join table) secara eksplisit di kode C#, meski di balik layar EF Core tetap membuat tabel perantara tersebut secara otomatis di database.

Mengakses dan Memodifikasi Relasi Many-to-Many

Menambah item ke List navigation property memperbarui tabel perantara

var produk = db.Produk.Include(p => p.DaftarTag).First(p => p.Id == 1); var tagBaru = db.Tag.First(t => t.Nama == "Diskon"); produk.DaftarTag.Add(tagBaru); db.SaveChanges(); -- Expected: baris baru otomatis ditambahkan -- ke tabel perantara ProdukTag di database
Menambahkan atau menghapus item dari list navigation property, seperti produk.DaftarTag.Add(tagBaru), secara otomatis diterjemahkan EF Core menjadi operasi INSERT atau DELETE pada tabel perantara yang menghubungkan Produk dan Tag, tanpa perlu developer menulis manipulasi tabel perantara tersebut secara manual.

Mengatasi Cycle dan Payload Besar pada Relasi dengan DTO Projection

Select() langsung ke DTO menghindari cycle serialisasi dan over-fetching

// Entity dengan relasi dua arah (Order <-> OrderItem) public class Order { public int Id { get; set; } public List<OrderItem> Items { get; set; } = new(); } // Projection langsung ke DTO - hindari serialisasi entity mentah var hasil = await _db.Orders .Where(o => o.Id == orderId) .Select(o => new OrderSummaryDto { OrderId = o.Id, TotalItem = o.Items.Count, TotalHarga = o.Items.Sum(i => i.Harga * i.Qty) }) .FirstOrDefaultAsync(); // EF Core menerjemahkan ini menjadi satu query SQL dengan agregasi, // bukan mengambil seluruh Items lalu dihitung di memori C#.

Relasi one-to-many dan many-to-many pada EF Core, ketika navigasinya dibuat dua arah, sering menimbulkan masalah object cycle saat entity hasil query langsung diserialisasi menjadi JSON. System.Text.Json secara default akan melempar exception JsonException: A possible object cycle was detected, karena Order merujuk ke OrderItem yang merujuk balik ke Order, membentuk referensi melingkar tanpa akhir.

Solusi yang sering dipakai secara cepat adalah mengaktifkan ReferenceHandler.IgnoreCycles pada opsi JSON, tetapi pendekatan ini hanya menyembunyikan gejala dan tetap mengirim seluruh graph entity beserta kolom yang tidak dibutuhkan client. Pendekatan yang lebih tepat adalah melakukan projection langsung ke DTO lewat method Select() sebelum query dieksekusi, sehingga EF Core menerjemahkan agregasi dan penghitungan ke dalam SQL, bukan mengambil semua baris mentah lalu diproses di sisi aplikasi.

Pendekatan DTO projection ini juga berdampak langsung pada performa karena query yang dihasilkan hanya meminta kolom yang benar-benar dipakai DTO, bukan seluruh kolom entity beserta tabel relasinya. Pada relasi many-to-many dengan tabel penghubung eksplisit, projection yang sama bisa dipakai untuk meratakan struktur data menjadi bentuk yang lebih sederhana tanpa perlu mengekspos entity join table ke response API sama sekali.

Many-to-Many dengan Join Entity Eksplisit untuk Kolom Tambahan

Join entity eksplisit dibutuhkan begitu relasi many-to-many punya atribut sendiri

// Relasi many-to-many murni (EF Core 5+) cukup begini, tapi tidak bisa simpan kolom tambahan public class Student { public int Id { get; set; } public List<Course> Courses { get; set; } = new(); } public class Course { public int Id { get; set; } public List<Student> Students { get; set; } = new(); } // Ketika butuh kolom tambahan (mis. tanggal daftar, nilai akhir), buat join entity eksplisit public class Enrollment { public int StudentId { get; set; } public Student Student { get; set; } = null!; public int CourseId { get; set; } public Course Course { get; set; } = null!; public DateTime EnrolledAt { get; set; } public decimal? NilaiAkhir { get; set; } } // OnModelCreating modelBuilder.Entity<Enrollment>().HasKey(e => new { e.StudentId, e.CourseId }); // Composite primary key mencegah satu Student terdaftar dua kali di Course yang sama

EF Core versi 5 ke atas mendukung relasi many-to-many implisit tanpa perlu mendefinisikan entity penghubung secara manual, cukup dengan dua koleksi navigasi yang saling merujuk. Namun pola ini hanya cukup ketika tabel penghubung murni berisi dua foreign key tanpa kolom tambahan apapun; begitu dibutuhkan atribut tambahan pada relasi itu sendiri, seperti tanggal pendaftaran atau nilai akhir pada relasi Student-Course, relasi tersebut harus diubah menjadi dua relasi one-to-many eksplisit lewat entity penghubung seperti Enrollment. Migrasi dari relasi implisit ke eksplisit ini umumnya membutuhkan migration baru karena nama dan struktur tabel penghubung ikut berubah.

Entity Enrollment pada contoh di atas berperan sebagai join table yang terlihat secara eksplisit di kode, lengkap dengan composite primary key dari kombinasi StudentId dan CourseId yang mencegah satu mahasiswa terdaftar dua kali pada mata kuliah yang sama. Pendekatan eksplisit ini memberi kontrol penuh untuk menambahkan validasi atau business logic khusus setiap kali sebuah pendaftaran baru dibuat, sesuatu yang sulit dilakukan pada relasi many-to-many implisit. Properti EnrolledAt dan NilaiAkhir pada entity ini bisa diperlakukan sama seperti properti biasa pada entity lain, termasuk mendapatkan validasi dan query filter tersendiri.

Query terhadap relasi semacam ini biasanya dilakukan lewat navigasi dari entity Enrollment itu sendiri, misalnya mengambil seluruh pendaftaran mahasiswa tertentu beserta nilai akhirnya lewat _db.Enrollments.Where(e => e.StudentId == id).Include(e => e.Course), alih-alih mencoba memaksakan query dari koleksi navigasi many-to-many langsung yang tidak lagi relevan setelah join entity eksplisit diperkenalkan ke dalam model. Pola ini juga memudahkan pembuatan laporan rekap nilai per mata kuliah tanpa perlu join manual tambahan di level aplikasi.

Mengatur Perilaku Cascade Delete Agar Sesuai Aturan Bisnis

modelBuilder.Entity<Pesanan>() .HasMany(p => p.DetailPesanan) .WithOne(d => d.Pesanan) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity<Pelanggan>() .HasMany(p => p.Pesanan) .WithOne(o => o.Pelanggan) .OnDelete(DeleteBehavior.Restrict); // Hapus Pesanan ikut menghapus DetailPesanan, // tapi hapus Pelanggan akan gagal selama masih punya Pesanan

Perilaku default EF Core untuk relasi yang bersifat required biasanya adalah cascade delete, yang tidak selalu cocok dengan aturan bisnis sebenarnya pada aplikasi. Menghapus data induk yang masih memiliki riwayat transaksi penting, seperti pelanggan yang masih punya pesanan lama, idealnya dicegah terlebih dahulu daripada membiarkan seluruh riwayatnya ikut terhapus secara otomatis tanpa konfirmasi tambahan.

DeleteBehavior.Restrict membuat operasi delete terhadap data induk akan gagal dengan exception apabila masih ada data anak yang terkait dengannya, sehingga memaksa proses hapus dilakukan secara eksplisit atau diganti dengan mekanisme soft delete. Mengatur perilaku ini satu per satu untuk setiap relasi, bukan sekadar mengandalkan perilaku default framework, membuat konsekuensi dari operasi delete jauh lebih mudah diprediksi saat database sudah dipakai di lingkungan production.

Kesalahan Umum yang Sering Terjadi

Lupa Memanggil Include Saat Membutuhkan Data Relasi

Mengakses navigation property tanpa Include sebelumnya (dan tanpa lazy loading diaktifkan) akan menghasilkan koleksi kosong atau null, bukan error yang jelas, sehingga sering membingungkan developer yang mengira datanya memang tidak ada di database.

Mengaktifkan Lazy Loading Tanpa Memahami Dampaknya

Lazy loading yang memuat data relasi secara otomatis saat diakses bisa menyebabkan masalah N+1 query, di mana satu query awal diikuti banyak query tambahan tersembunyi untuk setiap item yang diiterasi, sangat memperlambat performa pada data besar.

Tidak Mendefinisikan Foreign Key Secara Eksplisit

Mengandalkan EF Core menebak foreign key secara otomatis berdasarkan konvensi penamaan bisa menghasilkan skema yang tidak sesuai ekspektasi pada kasus relasi yang kompleks; mendefinisikan foreign key secara eksplisit lebih aman untuk relasi yang tidak mengikuti konvensi standar.

Kapan Pendekatan Ini Tidak Cocok Dipakai

Navigation property dan Include memberikan cara yang sangat nyaman untuk bekerja dengan data relasional tanpa menulis JOIN secara manual, namun developer tetap perlu memahami kapan data relasi benar-benar dimuat untuk menghindari query yang tidak efisien.

Dukungan many-to-many tanpa entity perantara eksplisit menyederhanakan kode untuk kasus sederhana, namun untuk relasi many-to-many yang membutuhkan data tambahan pada tabel perantaranya sendiri (misalnya tanggal ditambahkannya tag), entity perantara eksplisit tetap dibutuhkan.

Ringkasan

EF Core menyederhanakan pendefinisian relasi one-to-many dan many-to-many langsung dari model C#, dengan navigation property dan Include sebagai alat utama untuk mengakses data terkait secara efisien. Pahami kapan data relasi benar-benar dimuat untuk menghindari masalah performa seperti N+1 query yang sering terjadi akibat lazy loading yang tidak disadari.

Seri Belajar .NET Core:

← Sebelumnya: Migration dan Seeding Data dengan EF Core

→ Selanjutnya: Repository Pattern di ASP.NET Core