前回は、既存データベースから EF Core のモデルを生成する流れを見ました。
今回は、生成したモデルや手書きしたモデルを、アプリケーションで使いやすい形へ仕上げていきます。
特に、データアクセス層の例外と、エンティティ、表示用モデルの分け方を扱います。
データアクセス層は、単に SQL を隠す場所ではありません。
データベースの事情を、アプリケーションの言葉へ翻訳する場所でもあります。
データベース例外をそのまま外へ出さない
EF Core で保存に失敗すると、DbUpdateException や DbUpdateConcurrencyException が発生します。
SQL Server 固有の例外が内側に含まれることもあります。
try
{
await context.SaveChangesAsync();
}
catch (DbUpdateException ex)
{
Console.WriteLine(ex.Message);
}
このまま画面や API へ返すと、利用者には意味がわかりにくく、内部情報を漏らす可能性もあります。
そこで、データアクセス層の中で独自例外へ変換します。
public abstract class DataAccessException : Exception
{
protected DataAccessException(string message, Exception? innerException = null)
: base(message, innerException)
{
}
}
public sealed class DuplicateRecordException : DataAccessException
{
public DuplicateRecordException(string message, Exception? innerException = null)
: base(message, innerException)
{
}
}
public sealed class RecordChangedException : DataAccessException
{
public RecordChangedException(string message, Exception? innerException = null)
: base(message, innerException)
{
}
}
呼び出し側は、データベース固有の事情ではなく、アプリケーションとして意味のある例外を扱えます。
保存例外を変換する
リポジトリやデータアクセスサービスで、保存処理を包みます。
public async Task<int> SaveChangesAsync()
{
try
{
return await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
throw new RecordChangedException(
"他の利用者によってデータが変更されています。", ex);
}
catch (DbUpdateException ex)
{
throw new DataStoreException(
"データベースの更新に失敗しました。", ex);
}
}
DataStoreException も用意しておきます。
public sealed class DataStoreException : DataAccessException
{
public DataStoreException(string message, Exception? innerException = null)
: base(message, innerException)
{
}
}
例外を変換するときは、元の例外を innerException として残します。
ログでは詳細を追えるようにしつつ、画面や API には整理されたメッセージを返せます。
存在しないデータをどう扱うか
指定された主キーのデータが見つからない場合、null を返す方法と、例外を投げる方法があります。
public async Task<Car?> FindAsync(int id)
{
return await _context.Cars.FindAsync(id);
}
これは自然な形です。
ただし、後続処理では存在することが前提なら、専用メソッドを用意してもよいです。
public async Task<Car> GetRequiredAsync(int id)
{
var car = await _context.Cars.FindAsync(id);
return car ?? throw new RecordNotFoundException(
$"指定された車が見つかりません。Id: {id}");
}
例外です。
public sealed class RecordNotFoundException : DataAccessException
{
public RecordNotFoundException(string message)
: base(message)
{
}
}
FindAsync() と GetRequiredAsync() のように、メソッド名で違いを表すと呼び出し側が読みやすくなります。
エンティティに必要な制約を持たせる
エンティティは、データベースへ保存される型です。
そのため、最低限の制約や状態変更のメソッドを持たせると、データの意味が明確になります。
public sealed class Car
{
public int Id { get; private set; }
public string Make { get; private set; } = "";
public string Color { get; private set; } = "";
public string PetName { get; private set; } = "";
public bool IsDrivable { get; private set; } = true;
private Car()
{
}
public Car(string make, string color, string petName)
{
Rename(make, color, petName);
}
public void Rename(string make, string color, string petName)
{
if (string.IsNullOrWhiteSpace(make))
{
throw new ArgumentException("メーカー名は必須です。", nameof(make));
}
Make = make.Trim();
Color = color.Trim();
PetName = petName.Trim();
}
public void MarkAsUnavailable()
{
IsDrivable = false;
}
public void MarkAsAvailable()
{
IsDrivable = true;
}
}
EF Core のために、引数なしコンストラクターを残しています。
一方で、通常の作成では必要な値を渡すコンストラクターを使う形にできます。
表示用モデルは読み取りに寄せる
画面に返す型は、保存用のエンティティと分けます。
public sealed class CarListItem
{
public int Id { get; init; }
public string Name { get; init; } = "";
public string State { get; init; } = "";
}
init を使うと、作成後に値を書き換えにくくなります。
表示用モデルは、読み取り結果として扱うことが多いため、この形がよく合います。
投影処理です。
public async Task<List<CarListItem>> GetListAsync()
{
return await _context.Cars
.AsNoTracking()
.OrderBy(car => car.Make)
.Select(car => new CarListItem
{
Id = car.Id,
Name = car.Make + " " + car.Color + " " + car.PetName,
State = car.IsDrivable ? "販売可能" : "整備中"
})
.ToListAsync();
}
画面に必要な形をここで作ることで、画面側はデータベース構造を意識しなくて済みます。
入力用モデルは書き込みに寄せる
入力用モデルでは、利用者から受け取ってよい項目だけを定義します。
public sealed class CreateCarRequest
{
public string Make { get; init; } = "";
public string Color { get; init; } = "";
public string PetName { get; init; } = "";
}
登録処理では、入力用モデルからエンティティを作ります。
public async Task<int> CreateAsync(CreateCarRequest request)
{
var car = new Car(
request.Make,
request.Color,
request.PetName);
_context.Cars.Add(car);
await SaveChangesAsync();
return car.Id;
}
入力用モデルに Id や CreatedAt を含めなければ、利用者が管理項目を書き換える余地を減らせます。
モデルの仕上げは境界をはっきりさせる作業
データアクセス層のモデルを整えるとき、意識したい境界は次の 3 つです。
- データベースに保存する型
- 利用者へ見せる型
- 利用者から受け取る型
この 3 つを無理にひとつのクラスへまとめると、最初は楽でも、後で変更しづらくなります。
例外も同じです。
データベースの例外をそのまま外へ出すのではなく、アプリケーションの言葉へ変換することで、呼び出し側の処理が安定します。
次回は、DbContext に集約できる共通処理をさらに深く見ていきます。