前回までは、EF Core の追加、検索、更新、削除、同時実行制御などを個別に見てきました。
ここからは、もう少しアプリケーション全体の形に目を向けます。
EF Core は便利ですが、どこからでも DbContext を直接使えるようにしてしまうと、処理の置き場所が散らばりやすくなります。
小さなサンプルでは問題になりません。
しかし業務アプリケーションでは、画面、入力検証、ドメインのルール、データベース操作、例外処理、テストの準備が少しずつ絡み合います。
そこで、データベースに触れる処理を データアクセス層 として分けて考えます。
データアクセス層を分ける理由
たとえば画面側の処理に、次のようなコードが直接書かれているとします。
public async Task<IActionResult> Index()
{
await using var context = new AutoLotContext(_options);
var cars = await context.Cars
.Where(car => car.IsDrivable)
.OrderBy(car => car.Make)
.ThenBy(car => car.PetName)
.ToListAsync();
return View(cars);
}
これは動きます。
ただ、同じような検索条件が別の画面、バッチ処理、API にも出てくると、次第に重複が増えます。
また、データベースの都合が画面側へ漏れていきます。
- テーブル名や列名に近い考え方が画面側に出る
- 関連データをどう読み込むかがあちこちに散る
- 同時実行制御や削除制約の扱いが画面ごとにばらつく
- テストでデータベースの準備が大きな負担になる
データアクセス層は、こうした散らばりを抑えるための境界です。
プロジェクトを分ける考え方
中規模以上のアプリケーションでは、次のようにプロジェクトを分けると見通しがよくなります。
AutoLot.Models
エンティティ、列挙型、表示用モデル
AutoLot.Dal
DbContext、マッピング設定、リポジトリ、マイグレーション
AutoLot.Web
画面、API、入力、表示
AutoLot.Tests
データアクセス層や業務処理のテスト
Models はできるだけデータ構造に集中します。
Dal はデータベースとのやり取りを受け持ちます。
Web は利用者との接点に集中します。
参照関係は、一方向にしておくと扱いやすくなります。
AutoLot.Web
-> AutoLot.Dal
-> AutoLot.Models
AutoLot.Tests
-> AutoLot.Dal
-> AutoLot.Models
Models が Web や Dal に依存しないことが大切です。
中心にあるモデルが外側の都合を知らない形にしておくと、利用場所が増えても壊れにくくなります。
モデル用プロジェクト
モデル用プロジェクトには、エンティティを置きます。
namespace AutoLot.Models.Entities;
public sealed class Car
{
public int Id { get; set; }
public string Make { get; set; } = "";
public string Color { get; set; } = "";
public string PetName { get; set; } = "";
public bool IsDrivable { get; set; } = true;
public List<Order> Orders { get; set; } = new();
}
namespace AutoLot.Models.Entities;
public sealed class Order
{
public int Id { get; set; }
public int CarId { get; set; }
public string CustomerName { get; set; } = "";
public DateTime OrderDate { get; set; } = DateTime.UtcNow;
public Car? Car { get; set; }
}
モデルはシンプルに見えますが、アプリケーションの言葉を表す重要な場所です。
データベースの列を写すだけではなく、「このアプリケーションでは何を扱っているのか」を表現します。
データアクセス用プロジェクト
データアクセス用プロジェクトには、DbContext を置きます。
using AutoLot.Models.Entities;
using Microsoft.EntityFrameworkCore;
namespace AutoLot.Dal;
public sealed class AutoLotContext : DbContext
{
public AutoLotContext(DbContextOptions<AutoLotContext> options)
: base(options)
{
}
public DbSet<Car> Cars => Set<Car>();
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Car>(entity =>
{
entity.ToTable("Inventory");
entity.HasKey(car => car.Id);
entity.Property(car => car.Make)
.HasMaxLength(50)
.IsRequired();
entity.Property(car => car.Color)
.HasMaxLength(50)
.IsRequired();
entity.Property(car => car.PetName)
.HasMaxLength(50);
});
modelBuilder.Entity<Order>(entity =>
{
entity.ToTable("Orders");
entity.HasKey(order => order.Id);
entity.HasOne(order => order.Car)
.WithMany(car => car.Orders)
.HasForeignKey(order => order.CarId);
});
}
}
DbContext は、単なる接続クラスではありません。
エンティティの集合、変更追跡、マッピング、保存処理の中心です。
だからこそ、画面側の都合で肥大化させず、データアクセス層に閉じ込めておく価値があります。
設計時に使うファクトリ
マイグレーションを作るとき、EF Core のコマンドは DbContext を生成する必要があります。
実行時には依存性注入で作ればよいのですが、コマンド実行時には Web アプリケーション全体を起動したくない場合があります。
そのために、設計時用のファクトリを用意できます。
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace AutoLot.Dal;
public sealed class AutoLotContextFactory
: IDesignTimeDbContextFactory<AutoLotContext>
{
public AutoLotContext CreateDbContext(string[] args)
{
var options = new DbContextOptionsBuilder<AutoLotContext>()
.UseSqlServer(
"Server=(localdb)\\mssqllocaldb;Database=AutoLot;Trusted_Connection=True;")
.Options;
return new AutoLotContext(options);
}
}
これで、データアクセス用プロジェクトに対してマイグレーションを作りやすくなります。
dotnet ef migrations add InitialCreate --project AutoLot.Dal --startup-project AutoLot.Web
dotnet ef database update --project AutoLot.Dal --startup-project AutoLot.Web
実務では接続文字列をコードに直書きしないことが多いです。
ただし、設計時ファクトリの役割を理解するための最小例としては、この形がわかりやすいでしょう。
グローバル using を整理する
プロジェクトを分けると、各ファイルの using が増えます。
よく使う名前空間は、GlobalUsings.cs にまとめられます。
global using AutoLot.Models.Entities;
global using Microsoft.EntityFrameworkCore;
便利ですが、増やしすぎると「どこから来た型なのか」が見えにくくなります。
本当に多くのファイルで使うものだけに絞るのが扱いやすいです。
例外も境界に置く
データアクセス層では、独自例外を用意しておくと、呼び出し側の処理が読みやすくなります。
namespace AutoLot.Dal.Exceptions;
public sealed class RecordNotFoundException : Exception
{
public RecordNotFoundException(string message)
: base(message)
{
}
}
たとえば、指定された車が存在しない場合に使えます。
public async Task<Car> FindCarAsync(int id)
{
var car = await _context.Cars.FindAsync(id);
return car ?? throw new RecordNotFoundException(
$"車が見つかりません。Id: {id}");
}
DbUpdateException や SqlException をそのまま画面側に伝えると、データベースの都合が外へ漏れます。
必要に応じて、アプリケーションの言葉に変換する場所を用意しておくとよいです。
最初から層を増やしすぎない
データアクセス層を分けることは有効です。
しかし、小さなアプリケーションに対して最初から複雑な抽象化を入れすぎると、かえって読みづらくなります。
判断の目安は、次のあたりです。
- 同じ検索条件が複数箇所に出てきた
- 画面側に
Include()や保存時の例外処理が増えてきた - テストでデータベース準備を共通化したくなった
- 複数の画面や API から同じ更新処理を使いたくなった
- 削除、履歴、監査などの共通ルールが必要になった
構造は、コードの重さを減らすためにあります。
見た目だけをきれいにするためではありません。
次回は、エンティティと表示用モデルをもう少し具体的に設計します。