前回は、エンティティ、表示用モデル、入力用モデルを分ける考え方を見ました。
今回は、DbContext の中でアプリケーション共通のデータアクセス規則を扱います。
DbContext は、単に DbSet<T> を並べる場所ではありません。
テーブル名、列の制約、リレーション、変更追跡、保存処理の拡張など、データベースとの接点をまとめる中心です。
マッピング設定を分ける
OnModelCreating() にすべての設定を書くと、エンティティが増えるにつれて長くなります。
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Car>(entity =>
{
entity.ToTable("Inventory");
entity.Property(car => car.Make).HasMaxLength(50).IsRequired();
entity.Property(car => car.Color).HasMaxLength(50).IsRequired();
});
modelBuilder.Entity<Order>(entity =>
{
entity.ToTable("Orders");
entity.Property(order => order.CustomerName).HasMaxLength(80);
});
}
エンティティごとに設定クラスへ分けると、見通しがよくなります。
using AutoLot.Models.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace AutoLot.Dal.Configuration;
public sealed class CarConfiguration : IEntityTypeConfiguration<Car>
{
public void Configure(EntityTypeBuilder<Car> builder)
{
builder.ToTable("Inventory");
builder.HasKey(car => car.Id);
builder.Property(car => car.Make)
.HasMaxLength(50)
.IsRequired();
builder.Property(car => car.Color)
.HasMaxLength(50)
.IsRequired();
builder.Property(car => car.PetName)
.HasMaxLength(50);
}
}
DbContext 側ではまとめて適用できます。
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AutoLotContext).Assembly);
}
設定が増えても、DbContext が巨大になりにくくなります。
共通の規約を上書きする
すべての string に既定の長さを設定したい場合があります。
そのような共通規則は、モデル構築時の規約として指定できます。
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder.Properties<string>()
.HaveMaxLength(100);
configurationBuilder.Properties<decimal>()
.HavePrecision(18, 2);
}
個別設定が必要な列は、エンティティ設定で上書きします。
builder.Property(car => car.Make)
.HasMaxLength(50)
.IsRequired();
全体の標準を決め、例外だけを個別に書くと、設定の意図が読みやすくなります。
変更追跡イベントを使う
EF Core は、エンティティの状態を追跡しています。
この状態変化に反応して処理を差し込むこともできます。
public sealed class AutoLotContext : DbContext
{
public AutoLotContext(DbContextOptions<AutoLotContext> options)
: base(options)
{
ChangeTracker.Tracked += OnEntityTracked;
ChangeTracker.StateChanged += OnEntityStateChanged;
}
private static void OnEntityTracked(
object? sender,
EntityTrackedEventArgs eventArgs)
{
Console.WriteLine(
$"追跡開始: {eventArgs.Entry.Entity.GetType().Name}");
}
private static void OnEntityStateChanged(
object? sender,
EntityStateChangedEventArgs eventArgs)
{
Console.WriteLine(
$"{eventArgs.Entry.Entity.GetType().Name}: " +
$"{eventArgs.OldState} -> {eventArgs.NewState}");
}
}
イベントは便利ですが、何でも入れる場所ではありません。
ログ出力や診断には向いていますが、業務上重要な保存ルールは SaveChanges() の拡張やサービスに置いた方が明確です。
保存時に監査情報を入れる
業務アプリケーションでは、作成日時や更新日時を自動で入れたいことがよくあります。
共通インターフェイスを用意します。
public interface IAuditable
{
DateTime CreatedAt { get; set; }
DateTime UpdatedAt { get; set; }
}
エンティティに実装します。
public sealed class Car : IAuditable
{
public int Id { get; set; }
public string Make { get; set; } = "";
public string Color { get; set; } = "";
public string PetName { get; set; } = "";
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
SaveChangesAsync() を上書きします。
public override Task<int> SaveChangesAsync(
CancellationToken cancellationToken = default)
{
ApplyAuditValues();
return base.SaveChangesAsync(cancellationToken);
}
private void ApplyAuditValues()
{
var now = DateTime.UtcNow;
foreach (var entry in ChangeTracker.Entries<IAuditable>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedAt = now;
entry.Entity.UpdatedAt = now;
}
if (entry.State == EntityState.Modified)
{
entry.Entity.UpdatedAt = now;
}
}
}
呼び出し側が毎回日時を設定しなくても、保存時に一貫して入ります。
論理削除を保存処理に組み込む
物理削除ではなく、削除済みフラグを立てたい場合があります。
public interface ISoftDeletable
{
bool IsDeleted { get; set; }
DateTime? DeletedAt { get; set; }
}
Remove() されたエンティティを、保存前に更新扱いへ変換します。
private void ApplySoftDelete()
{
var entries = ChangeTracker.Entries<ISoftDeletable>()
.Where(entry => entry.State == EntityState.Deleted);
foreach (var entry in entries)
{
entry.State = EntityState.Modified;
entry.Entity.IsDeleted = true;
entry.Entity.DeletedAt = DateTime.UtcNow;
}
}
保存処理から呼び出します。
public override Task<int> SaveChangesAsync(
CancellationToken cancellationToken = default)
{
ApplyAuditValues();
ApplySoftDelete();
return base.SaveChangesAsync(cancellationToken);
}
さらに、通常の問い合わせで削除済みを除外するなら、グローバルフィルターを設定します。
modelBuilder.Entity<Car>()
.HasQueryFilter(car => !car.IsDeleted);
これで、削除処理のルールを各画面に散らさずに済みます。
保存処理に入れすぎない
SaveChanges() の拡張は強力です。
ただし、何でもここに入れると、保存時に何が起きるのか見えにくくなります。
向いている処理は、次のような共通処理です。
- 作成日時、更新日時の設定
- 論理削除への変換
- 変更履歴の記録
- 共通の検証
一方で、次のような処理は慎重に扱うべきです。
- メール送信
- 外部 API 呼び出し
- 時間のかかる集計
- 画面固有の判断
保存処理は、データベースへ書き込む直前の最後の関門です。
便利な場所だからこそ、共通性の高い処理に絞ると安定します。
データアクセス層の中で完結させる
DbContext の設定や保存処理を整えると、画面側のコードは軽くなります。
public async Task<IActionResult> Delete(int id)
{
var car = await _repository.FindAsync(id);
if (car is null)
{
return NotFound();
}
_repository.Delete(car);
await _repository.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
このコードは、物理削除か論理削除かを知りません。
それはデータアクセス層の責務です。
境界をうまく作ると、呼び出し側は「何をしたいか」に集中できます。
データベースの細かい都合は、データアクセス層の中に閉じ込められます。
次回は、データアクセス層を呼び出す入口として、リポジトリの作り方を見ていきます。