SQLiteはシンプルなRDBですが、EF Coreと組み合わせるとリレーション設計が一気に強力になります。 一方で、外部キー・Cascade削除・多対多などを正しく理解していないと、 「意図しない削除」「N+1問題」「パフォーマンス劣化」が起きがちです。 この記事では、SQLite × EF Core のリレーション設計と実務パターンを整理します。
・1対多・多対多のモデル定義
・外部キーとナビゲーションプロパティの関係
・Include / ThenInclude の使いどころ
・Cascade / Restrict など削除動作の制御
・SQLite特有の制限と注意点
・業務アプリ向けベストプラクティス
1. 前提:サンプルドメイン(顧客と注文)
この記事では、よくある「顧客(Customer)と注文(Order)」を例にします。
- Customer 1人に対して、複数の Order(1対多)
- Order は必ず Customer に属する(必須リレーション)
■ モデル定義(1対多)
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
// 1対多:Customer 1人に複数の Order
public ICollection<Order> Orders { get; set; } = new List<Order>();
}
public class Order
{
public int Id { get; set; }
public DateTime OrderedAt { get; set; }
public decimal Amount { get; set; }
// 外部キー
public int CustomerId { get; set; }
// ナビゲーション(多対1)
public Customer Customer { get; set; }
}
2. DbContextでのリレーション構成
EF Coreは命名規約だけでもリレーションを推論しますが、
業務アプリでは OnModelCreating で明示的に定義しておく方が安全です。
■ DbContext定義
using Microsoft.EntityFrameworkCore;
public class AppDbContext : DbContext
{
public DbSet<Customer> Customers { get; set; }
public DbSet<Order> Orders { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlite("Data Source=app.db");
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Customer - Order : 1対多
modelBuilder.Entity<Customer>()
.HasMany(c => c.Orders)
.WithOne(o => o.Customer)
.HasForeignKey(o => o.CustomerId)
.OnDelete(DeleteBehavior.Restrict); // or Cascade
}
}
■ OnDelete(DeleteBehavior.XXX) の意味
- Cascade: 親削除時に子も自動削除
- Restrict: 子が残っていると親を削除できない
- SetNull: 親削除時に外部キーをNULLにする
3. Include / ThenInclude で関連データを一括取得
ナビゲーションプロパティは、デフォルトでは遅延読み込みされません(SQLite+EF Core標準ではオフ)。
関連データを一度に取得したい場合は Include / ThenInclude を使います。
■ 顧客とその注文一覧を一括取得
using var db = new AppDbContext();
var customers = await db.Customers
.Include(c => c.Orders)
.ToListAsync();
foreach (var c in customers)
{
Console.WriteLine($"{c.Name} の注文数: {c.Orders.Count}");
}
■ ネストしたリレーション(ThenInclude)
例えば、Order に OrderLines(明細)がある場合:
var customers = await db.Customers
.Include(c => c.Orders)
.ThenInclude(o => o.OrderLines)
.ToListAsync();
■ N+1問題を防ぐ
Include を使わずにループ内でナビゲーションを辿ると、 毎回クエリが発行される(N+1問題)ので注意。
4. 多対多(Many-to-Many)の定義
EF Core 5以降では、中間テーブルを明示的に書かなくても多対多を定義できます。 例:User と Role の多対多。
■ モデル定義(簡易多対多)
public class User
{
public int Id { get; set; }
public string Name { get; set; }
public ICollection<Role> Roles { get; set; } = new List<Role>();
}
public class Role
{
public int Id { get; set; }
public string Name { get; set; }
public ICollection<User> Users { get; set; } = new List<User>();
}
■ DbContext(暗黙の中間テーブル)
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>()
.HasMany(u => u.Roles)
.WithMany(r => r.Users)
.UsingEntity(j => j.ToTable("UserRoles"));
}
これで、UserRoles という中間テーブルが自動生成されます。
■ 多対多の操作
var user = await db.Users
.Include(u => u.Roles)
.FirstAsync(u => u.Id == 1);
var adminRole = await db.Roles.FirstAsync(r => r.Name == "Admin");
user.Roles.Add(adminRole);
await db.SaveChangesAsync();
5. SQLite特有の制限と注意点
■ 外部キー制約は明示的に有効化が必要な場合がある
SQLiteは PRAGMA foreign_keys = ON; が必要なケースがあります。
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlite("Data Source=app.db");
}
public override int SaveChanges()
{
Database.ExecuteSqlRaw("PRAGMA foreign_keys = ON;");
return base.SaveChanges();
}
(最近のEF Core+UseSqliteでは自動ONになるケースもありますが、明示しておくと安心)
■ Cascade削除の挙動
- SQLiteは外部キー制約が有効でないと Cascade が効かない
- 大量データのCascade削除はパフォーマンスに注意
6. 削除動作(Cascade / Restrict)の設計指針
業務アプリでは、削除ポリシーを明確にしておくことが重要です。
■ よくあるパターン
- マスタ系(Customerなど):Restrict(子があると削除不可)
- 一時データ・ログ系:Cascade(親削除時に子も削除)
- 論理削除(IsDeletedフラグ)を使い、物理削除しない
■ 論理削除の例
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
public bool IsDeleted { get; set; }
}
クエリ側で Where(c => !c.IsDeleted) を標準にする。
7. 実務で使えるクエリパターン集
■ 顧客と直近の注文1件を取得
var customers = await db.Customers
.Select(c => new
{
Customer = c,
LastOrder = c.Orders
.OrderByDescending(o => o.OrderedAt)
.FirstOrDefault()
})
.ToListAsync();
■ 特定期間に注文がある顧客だけ取得
var from = new DateTime(2026, 1, 1);
var to = new DateTime(2026, 12, 31);
var customers = await db.Customers
.Where(c => c.Orders.Any(o => o.OrderedAt >= from && o.OrderedAt <= to))
.ToListAsync();
■ 顧客ごとの注文合計金額
var result = await db.Customers
.Select(c => new
{
c.Id,
c.Name,
TotalAmount = c.Orders.Sum(o => (decimal?)o.Amount) ?? 0m
})
.ToListAsync();
8. 業務アプリ向けベストプラクティス
- リレーションはモデル+OnModelCreatingで明示定義
- 削除動作(Cascade / Restrict / 論理削除)を最初に決める
- 関連データ取得はInclude / ThenIncludeでN+1を防ぐ
- 多対多はUsingEntityで中間テーブルを明示しておくと安心
- SQLiteのforeign_keys = ON を意識する
- 大量データのCascade削除はパフォーマンスに注意
まとめ:SQLite × EF Core リレーションは“設計がすべて”
- 1対多・多対多・削除動作を明示的に設計することで、意図しないデータ破壊を防げる
- Include / ThenInclude を使いこなせば、読みやすく高速なクエリを書ける
- SQLite特有の外部キー制約・Cascade挙動も理解しておくと安心
「とりあえず動く」EF Coreから、 「壊れず・読みやすく・保守しやすい」リレーション設計へ。 この記事のパターンをベースに、実際のドメインに合わせてモデルを組み立ててみてください。