前回は、DbContext の設定と保存処理を拡張する方法を見ました。
今回は、データアクセス層の入口として リポジトリ を作ります。
リポジトリは、データの取得、追加、更新、削除をまとめるための設計です。
画面やサービスが DbContext を直接触らず、目的に合ったメソッドを通してデータへアクセスできるようにします。
リポジトリを使う目的
EF Core の DbContext 自体も、すでにリポジトリに近い役割を持っています。
そのため、必ず独自リポジトリを作るべきというわけではありません。
それでもリポジトリが役立つ場面があります。
- よく使う検索条件を名前付きメソッドにしたい
- 画面側から
Include()やAsNoTracking()を隠したい - 保存時の例外処理や同時実行制御をまとめたい
- テストでデータアクセス部分を差し替えたい
- 複数のエンティティに共通する操作をそろえたい
大切なのは、EF Core を薄く包むだけの層を増やさないことです。
リポジトリには、アプリケーションの意図が読み取れるメソッドを置くと価値が出ます。
基本のインターフェイス
まず、共通操作を表すインターフェイスを作ります。
public interface IRepository<T>
where T : class
{
Task<T?> FindAsync(int id);
Task<List<T>> GetAllAsync();
Task AddAsync(T entity);
void Update(T entity);
void Delete(T entity);
Task<int> SaveChangesAsync();
}
実装です。
using Microsoft.EntityFrameworkCore;
public class Repository<T> : IRepository<T>
where T : class
{
protected readonly AutoLotContext Context;
protected readonly DbSet<T> Table;
public Repository(AutoLotContext context)
{
Context = context;
Table = context.Set<T>();
}
public virtual async Task<T?> FindAsync(int id)
{
return await Table.FindAsync(id);
}
public virtual async Task<List<T>> GetAllAsync()
{
return await Table.ToListAsync();
}
public virtual async Task AddAsync(T entity)
{
await Table.AddAsync(entity);
}
public virtual void Update(T entity)
{
Table.Update(entity);
}
public virtual void Delete(T entity)
{
Table.Remove(entity);
}
public Task<int> SaveChangesAsync()
{
return Context.SaveChangesAsync();
}
}
この段階では、まだ薄い共通化です。
本当に大事なのは、エンティティごとの目的に合った操作を足していくところです。
読み取り専用の共通リポジトリ
一覧表示やビューからの取得のように、更新しない読み取り処理だけを分けたい場合があります。
public interface IReadOnlyRepository<T>
where T : class
{
Task<T?> FindAsync(int id);
Task<List<T>> GetAllAsync();
}
読み取り専用の実装では、追跡なしを既定にできます。
public class ReadOnlyRepository<T> : IReadOnlyRepository<T>
where T : class
{
private readonly DbSet<T> _table;
public ReadOnlyRepository(AutoLotContext context)
{
_table = context.Set<T>();
}
public async Task<T?> FindAsync(int id)
{
return await _table.FindAsync(id);
}
public async Task<List<T>> GetAllAsync()
{
return await _table
.AsNoTracking()
.ToListAsync();
}
}
ただし、FindAsync() はコンテキストの追跡状態も見に行きます。
完全に追跡なしで統一したい場合は、主キー条件を明示したメソッドにするなど、エンティティごとに設計する方がよいです。
車専用のリポジトリ
共通リポジトリだけでは、アプリケーション固有の意図が出ません。
車専用のインターフェイスを作ります。
public interface ICarRepository : IRepository<Car>
{
Task<List<CarListItem>> GetAvailableCarsAsync();
Task<CarDetail?> GetDetailAsync(int id);
Task<bool> ExistsAsync(int id);
}
実装です。
public sealed class CarRepository : Repository<Car>, ICarRepository
{
public CarRepository(AutoLotContext context)
: base(context)
{
}
public async Task<List<CarListItem>> GetAvailableCarsAsync()
{
return await Context.Cars
.AsNoTracking()
.Where(car => car.IsDrivable)
.OrderBy(car => car.Make)
.ThenBy(car => car.PetName)
.Select(car => new CarListItem
{
Id = car.Id,
DisplayName = car.Make + " " + car.Color + " " + car.PetName,
Status = "販売可能",
OrderCount = car.Orders.Count
})
.ToListAsync();
}
public async Task<CarDetail?> GetDetailAsync(int id)
{
return await Context.Cars
.AsNoTracking()
.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,
Orders = car.Orders
.OrderByDescending(order => order.OrderDate)
.Select(order => new OrderSummary
{
Id = order.Id,
CustomerName = order.CustomerName,
OrderDate = order.OrderDate
})
.ToList()
})
.SingleOrDefaultAsync();
}
public async Task<bool> ExistsAsync(int id)
{
return await Context.Cars.AnyAsync(car => car.Id == id);
}
}
GetAvailableCarsAsync() という名前を見るだけで、何を取りたいのかがわかります。
呼び出し側は、Where() や Select() の詳細を知る必要がありません。
依存性注入へ登録する
ASP.NET Core などで使う場合は、リポジトリをサービスとして登録します。
builder.Services.AddDbContext<AutoLotContext>(options =>
{
options.UseSqlServer(builder.Configuration.GetConnectionString("AutoLot"));
});
builder.Services.AddScoped<ICarRepository, CarRepository>();
画面側ではインターフェイスに依存します。
public sealed class CarsController : Controller
{
private readonly ICarRepository _cars;
public CarsController(ICarRepository cars)
{
_cars = cars;
}
public async Task<IActionResult> Index()
{
var cars = await _cars.GetAvailableCarsAsync();
return View(cars);
}
}
この形にすると、画面側は DbContext の具体的な使い方から少し離れられます。
保存単位をどう扱うか
共通リポジトリに SaveChangesAsync() を持たせると簡単です。
ただし、複数のリポジトリをまたいで一度に保存したい場合は、保存単位を別に考える必要があります。
public interface IUnitOfWork
{
ICarRepository Cars { get; }
IOrderRepository Orders { get; }
Task<int> SaveChangesAsync();
}
public sealed class UnitOfWork : IUnitOfWork
{
private readonly AutoLotContext _context;
public UnitOfWork(
AutoLotContext context,
ICarRepository cars,
IOrderRepository orders)
{
_context = context;
Cars = cars;
Orders = orders;
}
public ICarRepository Cars { get; }
public IOrderRepository Orders { get; }
public Task<int> SaveChangesAsync()
{
return _context.SaveChangesAsync();
}
}
この考え方を使うと、複数の操作をひとつの保存単位として扱いやすくなります。
await unitOfWork.Cars.AddAsync(car);
await unitOfWork.Orders.AddAsync(order);
await unitOfWork.SaveChangesAsync();
ただし、EF Core の DbContext 自体も作業単位を表しています。
独自の作業単位を追加するかどうかは、アプリケーションの規模と複雑さを見て判断します。
リポジトリの使いすぎに注意する
リポジトリは便利ですが、すべての LINQ を隠す必要はありません。
次のようなメソッドが増えすぎると、かえって保守しづらくなります。
Task<List<Car>> GetRedCarsAsync();
Task<List<Car>> GetBlueCarsAsync();
Task<List<Car>> GetRedToyotaCarsAsync();
Task<List<Car>> GetBlueToyotaCarsAsync();
条件の組み合わせが増えるなら、検索条件をオブジェクトとして渡す方がよい場合があります。
public sealed class CarSearchCondition
{
public string? Make { get; set; }
public string? Color { get; set; }
public bool? IsDrivable { get; set; }
}
public async Task<List<CarListItem>> SearchAsync(CarSearchCondition condition)
{
var query = Context.Cars.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(condition.Make))
{
query = query.Where(car => car.Make == condition.Make);
}
if (!string.IsNullOrWhiteSpace(condition.Color))
{
query = query.Where(car => car.Color == condition.Color);
}
if (condition.IsDrivable is not null)
{
query = query.Where(car => car.IsDrivable == condition.IsDrivable);
}
return await query
.OrderBy(car => car.Make)
.Select(car => new CarListItem
{
Id = car.Id,
DisplayName = car.Make + " " + car.Color + " " + car.PetName,
Status = car.IsDrivable ? "販売可能" : "整備中",
OrderCount = car.Orders.Count
})
.ToListAsync();
}
名前付きメソッドと検索条件オブジェクトを使い分けると、リポジトリが膨らみすぎるのを防げます。
この記事のまとめ
リポジトリは、データアクセスの入口をそろえるための設計です。
ただし、EF Core を機械的に包むだけでは価値が薄くなります。
重要なのは、アプリケーションの意図を名前にすることです。
GetAvailableCarsAsync()、GetDetailAsync()、SearchAsync() のようなメソッドは、呼び出し側に「何をしたいのか」を伝えます。
その内側で、Include()、投影、追跡なし、同時実行制御などを適切に扱うのがデータアクセス層の役割です。
次回は、マイグレーションやデータベース初期化をプログラムから扱う方法を見ていきます。