前回は、EF Core を中心にデータアクセス層を分ける考え方を見ました。
今回は、データアクセス層で扱う エンティティ と、画面や API に渡す 表示用モデル を分けて考えます。
どちらも C# のクラスなので、最初は同じものに見えるかもしれません。
しかし役割は違います。
エンティティは、データベースへ保存する対象です。
表示用モデルは、利用者に見せる形へ整えたデータです。
エンティティは保存の単位
エンティティは、EF Core が追跡し、データベースの行として保存する型です。
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 DateTime DateBuilt { get; set; }
public int? RadioId { get; set; }
public Radio? Radio { get; set; }
public List<Order> Orders { get; set; } = new();
}
namespace AutoLot.Models.Entities;
public sealed class Radio
{
public int Id { get; set; }
public bool HasTweeters { get; set; }
public bool HasSubWoofers { get; set; }
public string RadioId { get; set; } = "";
public Car? Car { get; set; }
}
エンティティには、外部キー、ナビゲーションプロパティ、データベースの制約に関わる情報が含まれます。
これは保存には必要ですが、画面にそのまま出したい形とは限りません。
表示用モデルは利用者に渡す形
画面の一覧では、すべての列が必要とは限りません。
たとえば車の一覧なら、次のような形で十分なことがあります。
namespace AutoLot.Models.ViewModels;
public sealed class CarListItem
{
public int Id { get; set; }
public string DisplayName { get; set; } = "";
public string Status { get; set; } = "";
public int OrderCount { get; set; }
}
DisplayName は、メーカー名、色、愛称を組み合わせた表示用の文字列です。
Status は、走行可能かどうかを利用者向けの文言に変換したものです。
このような型は、データベースへ保存するためではなく、画面や API に渡すために使います。
エンティティをそのまま返す問題
API でエンティティをそのまま返すと、手軽に見えます。
app.MapGet("/cars", async (AutoLotContext context) =>
{
return await context.Cars.ToListAsync();
});
ただし、次のような問題が起きやすくなります。
- 外部キーや内部管理用の列まで公開される
- 関連データの循環参照でシリアライズに失敗する
- 画面に不要な列まで取得してしまう
- データベース変更が API の形に直接影響する
- 入力用と出力用の都合が混ざる
小さな管理ツールなら許容できる場合もあります。
しかし、長く育てるアプリケーションでは、表示用モデルへ投影する方が安全です。
問い合わせ時に投影する
EF Core では、Select() を使って必要な形へ変換できます。
public async Task<List<CarListItem>> GetCarListAsync()
{
return await _context.Cars
.OrderBy(car => car.Make)
.ThenBy(car => car.PetName)
.Select(car => new CarListItem
{
Id = car.Id,
DisplayName = car.Make + " " + car.Color + " " + car.PetName,
Status = car.IsDrivable ? "販売可能" : "整備中",
OrderCount = car.Orders.Count
})
.ToListAsync();
}
この書き方の利点は、必要な列だけを SQL で取得しやすいことです。
取得後に C# 側で全件変換するより、データベース側に任せられる処理が増えます。
詳細表示用の形を作る
一覧と詳細では、必要な情報が違います。
namespace AutoLot.Models.ViewModels;
public sealed class CarDetail
{
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; }
public DateTime DateBuilt { get; set; }
public string RadioSummary { get; set; } = "";
public List<OrderSummary> Orders { get; set; } = new();
}
namespace AutoLot.Models.ViewModels;
public sealed class OrderSummary
{
public int Id { get; set; }
public string CustomerName { get; set; } = "";
public DateTime OrderDate { get; set; }
}
取得処理です。
public async Task<CarDetail?> GetCarDetailAsync(int id)
{
return await _context.Cars
.Where(car => car.Id == id)
.Select(car => new CarDetail
{
Id = car.Id,
Make = car.Make,
Color = car.Color,
PetName = car.PetName,
IsDrivable = car.IsDrivable,
DateBuilt = car.DateBuilt,
RadioSummary = car.Radio == null
? "ラジオなし"
: car.Radio.RadioId,
Orders = car.Orders
.OrderByDescending(order => order.OrderDate)
.Select(order => new OrderSummary
{
Id = order.Id,
CustomerName = order.CustomerName,
OrderDate = order.OrderDate
})
.ToList()
})
.SingleOrDefaultAsync();
}
関連データを Include() で読み込んでから変換する方法もあります。
しかし、表示に必要な形が決まっているなら、最初から投影した方が意図が明確です。
入力用モデルはさらに分ける
表示用モデルと入力用モデルも、分けた方がよい場合があります。
namespace AutoLot.Models.InputModels;
public sealed class CreateCarInput
{
public string Make { get; set; } = "";
public string Color { get; set; } = "";
public string PetName { get; set; } = "";
public DateTime DateBuilt { get; set; }
}
登録処理では、入力用モデルからエンティティを作ります。
public async Task<int> CreateCarAsync(CreateCarInput input)
{
var car = new Car
{
Make = input.Make.Trim(),
Color = input.Color.Trim(),
PetName = input.PetName.Trim(),
DateBuilt = input.DateBuilt,
IsDrivable = true
};
_context.Cars.Add(car);
await _context.SaveChangesAsync();
return car.Id;
}
入力用モデルを分けると、利用者から受け取ってよい項目だけを明示できます。
たとえば Id、RowVersion、CreatedAt、UpdatedAt のような管理項目を外から書き換えられる事故を避けやすくなります。
共通化しすぎない
表示用モデルが増えると、共通化したくなることがあります。
public sealed class CarModel
{
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; }
}
一覧、詳細、登録、更新のすべてで同じ型を使うと、最初は楽です。
しかし画面ごとに必要な項目が違ってくると、使わないプロパティや意味の曖昧なプロパティが増えます。
モデルは、用途に合わせて少し分けるくらいが扱いやすいです。
「型が増えること」より、「ひとつの型が複数の責務を持つこと」の方が後でつらくなります。
エンティティに処理を書いてよいか
エンティティに何も書かず、すべてサービスやリポジトリに置く設計もあります。
一方で、エンティティ自身が持つべき小さな判断は、エンティティに置いた方が自然なこともあります。
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; private set; } = true;
public void MarkAsUnavailable()
{
IsDrivable = false;
}
public void MarkAsAvailable()
{
IsDrivable = true;
}
}
ただし、DbContext を使う処理、メール送信、外部 API 呼び出しなどはエンティティに入れない方がよいです。
エンティティは、アプリケーションの中心に近い型です。外側の仕組みを知りすぎない方が保ちやすくなります。
この記事のまとめ
エンティティは保存するための型です。
表示用モデルは、利用者へ渡すための型です。
入力用モデルは、利用者から受け取るための型です。
この区別を早めに持っておくと、データベース、画面、API の変更が互いに直接ぶつかりにくくなります。
次回は、DbContext の設定をもう少し発展させ、共通設定、イベント、保存処理の拡張を見ていきます。