dotnet-repository-pattern について
レイヤードアーキテクチャで集約ルート境界とUnit of Workを採用する.NET開発者向けのリポジトリ生成スキルです。インターフェースやEF Core実装のテンプレート、クエリ指針を提供します。
主な機能
- 01Domain層のリポジトリインターフェース
- 02Infrastructure層のEF Core実装
- 03集約ルートごとのリポジトリ
- 04Unit of Workとの連携
- 05読み取りクエリの最適化指針
活用シーン
- 01.NETアプリのリポジトリ層構築
- 02集約ルートのデータアクセスパターン生成
- 03子エンティティやSpecificationクエリの追加
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ ronnythedev/ dotnet-clean-architecture-skills --skill dotnet-repository-patternインストール後、Claude Code で/dotnet-repository-patternを入力して読み込まれたか確認します。
詳細
リポジトリパターンジェネレーター
概要
このスキルは、データアクセスを抽象化するリポジトリを生成します。
- ドメイン層のインターフェース - データアクセスの契約を定義します
- インフラストラクチャ層の実装 - EF Core を使用します
- 集約ルートごと - エンティティごとではありません
- ユニットオブワークとの統合 - IUnitOfWork 経由で SaveChanges を実行します
クイックリファレンス
| リポジトリメソッド | 目的 | 戻り値 |
|---|---|---|
GetByIdAsync | 主キーで取得します | Entity? |
GetByXxxAsync | ビジネスキーで取得します | Entity? |
GetAllAsync | すべて取得します(使用は控えめに) | IReadOnlyList<Entity> |
Add | 新しいエンティティを追跡します | void |
Update | 変更されたエンティティを追跡します | void |
Remove | 削除されたエンティティを追跡します | void |
ExistsAsync | 存在を確認します | bool |
リポジトリ構造
/Domain/{Aggregate}/
└── I{Entity}Repository.cs # Interface (Domain layer)
/Infrastructure/Repositories/
└── {Entity}Repository.cs # Implementation (Infrastructure layer)
テンプレート:リポジトリインターフェース(ドメイン層)
// src/{name}.domain/{Aggregate}/I{Entity}Repository.cs
namespace {name}.domain.{aggregate};
public interface I{Entity}Repository
{
// ═══════════════════════════════════════════════════════════════
// QUERY METHODS
// ═══════════════════════════════════════════════════════════════
/// <summary>
/// Gets an entity by its unique identifier
/// </summary>
Task<{Entity}?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets an entity by its unique identifier with related entities
/// </summary>
Task<{Entity}?> GetByIdWithDetailsAsync(
Guid id,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets an entity by a unique business key
/// </summary>
Task<{Entity}?> GetByNameAsync(
string name,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all entities for a parent organization
/// </summary>
Task<IReadOnlyList<{Entity}>> GetByOrganizationIdAsync(
Guid organizationId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all active entities
/// </summary>
Task<IReadOnlyList<{Entity}>> GetAllActiveAsync(
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if an entity exists
/// </summary>
Task<bool> ExistsAsync(
Guid id,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if an entity with the given name exists
/// </summary>
Task<bool> ExistsByNameAsync(
string name,
CancellationToken cancellationToken = default);
// ═══════════════════════════════════════════════════════════════
// COMMAND METHODS (tracking only, no SaveChanges)
// ═══════════════════════════════════════════════════════════════
/// <summary>
/// Adds a new entity to the context
/// </summary>
void Add({Entity} {entity});
/// <summary>
/// Adds multiple entities to the context
/// </summary>
void AddRange(IEnumerable<{Entity}> {entities});
/// <summary>
/// Updates an existing entity in the context
/// </summary>
void Update({Entity} {entity});
/// <summary>
/// Removes an entity from the context
/// </summary>
void Remove({Entity} {entity});
/// <summary>
/// Removes multiple entities from the context
/// </summary>
void RemoveRange(IEnumerable<{Entity}> {entities});
}
テンプレート:リポジトリ実装(インフラストラクチャ層)
// src/{name}.infrastructure/Repositories/{Entity}Repository.cs
using Microsoft.EntityFrameworkCore;
using {name}.domain.{aggregate};
namespace {name}.infrastructure.repositories;
internal sealed class {Entity}Repository : I{Entity}Repository
{
private readonly ApplicationDbContext _dbContext;
public {Entity}Repository(ApplicationDbContext dbContext)
{
_dbContext = dbContext;
}
// ═══════════════════════════════════════════════════════════════
// QUERY METHODS
// ═══════════════════════════════════════════════════════════════
public async Task<{Entity}?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default)
{
return await _dbContext
.Set<{Entity}>()
.FirstOrDefaultAsync(e => e.Id == id, cancellationToken);
}
public async Task<{Entity}?> GetByIdWithDetailsAsync(
Guid id,
CancellationToken cancellationToken = default)
{
return await _dbContext
.Set<{Entity}>()
.Include(e => e.{ChildEntities})
.Include(e => e.{OtherRelation})
.FirstOrDefaultAsync(e => e.Id == id, cancellationToken);
}
public async Task<{Entity}?> GetByNameAsync(
string name,
CancellationToken cancellationToken = default)
{
return await _dbContext
.Set<{Entity}>()
.FirstOrDefaultAsync(
e => e.Name.ToLower() == name.ToLower(),
cancellationToken);
}
public async Task<IReadOnlyList<{Entity}>> GetByOrganizationIdAsync(
Guid organizationId,
CancellationToken cancellationToken = default)
{
return await _dbContext
.Set<{Entity}>()
.Where(e => e.OrganizationId == organizationId)
.OrderBy(e => e.Name)
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<{Entity}>> GetAllActiveAsync(
CancellationToken cancellationToken = default)
{
return await _dbContext
.Set<{Entity}>()
.Where(e => e.IsActive)
.OrderBy(e => e.Name)
.ToListAsync(cancellationToken);
}
public async Task<bool> ExistsAsync(
Guid id,
CancellationToken cancellationToken = default)
{
return await _dbContext
.Set<{Entity}>()
.AnyAsync(e => e.Id == id, cancellationToken);
}
public async Task<bool> ExistsByNameAsync(
string name,
CancellationToken cancellationToken = default)
{
return await _dbContext
.Set<{Entity}>()
.AnyAsync(
e => e.Name.ToLower() == name.ToLower(),
cancellationToken);
}
// ═══════════════════════════════════════════════════════════════
// COMMAND METHODS
// ═══════════════════════════════════════════════════════════════
public void Add({Entity} {entity})
{
_dbContext.Set<{Entity}>().Add({entity});
}
public void AddRange(IEnumerable<{Entity}> {entities})
{
_dbContext.Set<{Entity}>().AddRange({entities});
}
public void Update({Entity} {entity})
{
_dbContext.Set<{Entity}>().Update({entity});
}
public void Remove({Entity} {entity})
{
_dbContext.Set<{Entity}>().Remove({entity});
}
public void RemoveRange(IEnumerable<{Entity}> {entities})
{
_dbContext.Set<{Entity}>().RemoveRange({entities});
}
}
テンプレート:子エンティティへのアクセスを備えたリポジトリ
// src/{name}.domain/{Aggregate}/I{Entity}Repository.cs
namespace {name}.domain.{aggregate};
public interface I{Entity}Repository
{
// Standard methods...
// ═══════════════════════════════════════════════════════════════
// CHILD ENTITY QUERIES (accessed through aggregate root)
// ═══════════════════════════════════════════════════════════════
/// <summary>
/// Gets a child entity through its aggregate root
/// </summary>
Task<{ChildEntity}?> Get{ChildEntity}ByIdAsync(
Guid {entity}Id,
Guid {childEntity}Id,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all child entities for a parent
/// </summary>
Task<IReadOnlyList<{ChildEntity}>> Get{ChildEntities}By{Entity}IdAsync(
Guid {entity}Id,
CancellationToken cancellationToken = default);
}
// src/{name}.infrastructure/Repositories/{Entity}Repository.cs
internal sealed class {Entity}Repository : I{Entity}Repository
{
// ... other methods
public async Task<{ChildEntity}?> Get{ChildEntity}ByIdAsync(
Guid {entity}Id,
Guid {childEntity}Id,
CancellationToken cancellationToken = default)
{
var {entity} = await _dbContext
.Set<{Entity}>()
.Include(e => e.{ChildEntities})
.FirstOrDefaultAsync(e => e.Id == {entity}Id, cancellationToken);
return {entity}?.{ChildEntities}
.FirstOrDefault(c => c.Id == {childEntity}Id);
}
public async Task<IReadOnlyList<{ChildEntity}>> Get{ChildEntities}By{Entity}IdAsync(
Guid {entity}Id,
CancellationToken cancellationToken = default)
{
var {entity} = await _dbContext
.Set<{Entity}>()
.Include(e => e.{ChildEntities})
.FirstOrDefaultAsync(e => e.Id == {entity}Id, cancellationToken);
return {entity}?.{ChildEntities}.ToList()
?? new List<{ChildEntity}>();
}
}
テンプレート:仕様パターンを使用したリポジトリ
// src/{name}.domain/Abstractions/ISpecification.cs
using System.Linq.Expressions;
namespace {name}.domain.abstractions;
public interface ISpecification<T>
{
Expression<Func<T, bool>> Criteria { get; }
List<Expression<Func<T, object>>> Includes { get; }
List<string> IncludeStrings { get; }
Expression<Func<T, object>>? OrderBy { get; }
Expression<Func<T, object>>? OrderByDescending { get; }
int? Take { get; }
int? Skip { get; }
bool IsPagingEnabled { get; }
}
// src/{name}.domain/Abstractions/BaseSpecification.cs
using System.Linq.Expressions;
namespace {name}.domain.abstractions;
public abstract class BaseSpecification<T> : ISpecification<T>
{
public Expression<Func<T, bool>> Criteria { get; private set; } = _ => true;
public List<Expression<Func<T, object>>> Includes { get; } = new();
public List<string> IncludeStrings { get; } = new();
public Expression<Func<T, object>>? OrderBy { get; private set; }
public Expression<Func<T, object>>? OrderByDescending { get; private set; }
public int? Take { get; private set; }
public int? Skip { get; private set; }
public bool IsPagingEnabled { get; private set; }
protected void AddCriteria(Expression<Func<T, bool>> criteria)
{
Criteria = criteria;
}
protected void AddInclude(Expression<Func<T, object>> includeExpression)
{
Includes.Add(includeExpression);
}
protected void AddInclude(string includeString)
{
IncludeStrings.Add(includeString);
}
protected void ApplyOrderBy(Expression<Func<T, object>> orderByExpression)
{
OrderBy = orderByExpression;
}
protected void ApplyOrderByDescending(Expression<Func<T, object>> orderByDescExpression)
{
OrderByDescending = orderByDescExpression;
}
protected void ApplyPaging(int skip, int take)
{
Skip = skip;
Take = take;
IsPagingEnabled = true;
}
}
// src/{name}.domain/{Aggregate}/Specifications/Active{Entities}Specification.cs
using {name}.domain.abstractions;
namespace {name}.domain.{aggregate}.specifications;
public sealed class Active{Entities}Specification : BaseSpecification<{Entity}>
{
public Active{Entities}Specification()
{
AddCriteria(e => e.IsActive);
ApplyOrderBy(e => e.Name);
}
}
public sealed class {Entities}ByOrganizationSpecification : BaseSpecification<{Entity}>
{
public {Entities}ByOrganizationSpecification(Guid organizationId)
{
AddCriteria(e => e.OrganizationId == organizationId && e.IsActive);
AddInclude(e => e.{ChildEntities});
ApplyOrderBy(e => e.Name);
}
}
// Repository with specification support
public interface I{Entity}Repository
{
Task<IReadOnlyList<{Entity}>> GetAsync(
ISpecification<{Entity}> specification,
CancellationToken cancellationToken = default);
Task<{Entity}?> GetFirstOrDefaultAsync(
ISpecification<{Entity}> specification,
CancellationToken cancellationToken = default);
Task<int> CountAsync(
ISpecification<{Entity}> specification,
CancellationToken cancellationToken = default);
}
テンプレート:汎用リポジトリベース(任意)
// src/{name}.infrastructure/Repositories/Repository.cs
using Microsoft.EntityFrameworkCore;
using {name}.domain.abstractions;
namespace {name}.infrastructure.repositories;
internal abstract class Repository<T> where T : Entity
{
protected readonly ApplicationDbContext DbContext;
protected Repository(ApplicationDbContext dbContext)
{
DbContext = dbContext;
}
public async Task<T?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default)
{
return await DbContext
.Set<T>()
.FirstOrDefaultAsync(e => e.Id == id, cancellationToken);
}
public void Add(T entity)
{
DbContext.Set<T>().Add(entity);
}
public void Update(T entity)
{
DbContext.Set<T>().Update(entity);
}
public void Remove(T entity)
{
DbContext.Set<T>().Remove(entity);
}
}
// Using the base repository
internal sealed class {Entity}Repository : Repository<{Entity}>, I{Entity}Repository
{
public {Entity}Repository(ApplicationDbContext dbContext) : base(dbContext)
{
}
// Add entity-specific methods
public async Task<{Entity}?> GetByNameAsync(
string name,
CancellationToken cancellationToken = default)
{
return await DbContext
.Set<{Entity}>()
.FirstOrDefaultAsync(
e => e.Name.ToLower() == name.ToLower(),
cancellationToken);
}
}
リポジトリの登録
// src/{name}.infrastructure/DependencyInjection.cs
private static void AddPersistence(IServiceCollection services, IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString("Database")
?? throw new ArgumentNullException(nameof(configuration));
services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseNpgsql(connectionString)
.UseSnakeCaseNamingConvention();
});
// Register Unit of Work
services.AddScoped<IUnitOfWork>(sp =>
sp.GetRequiredService<ApplicationDbContext>());
// Register Repositories
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IOrganizationRepository, OrganizationRepository>();
services.AddScoped<IDepartmentRepository, DepartmentRepository>();
services.AddScoped<ISurveyRepository, SurveyRepository>();
// Add more repositories here...
// Register SQL Connection Factory for Dapper queries
services.AddSingleton<ISqlConnectionFactory>(_ =>
new SqlConnectionFactory(connectionString));
}
クエリ最適化パターン
読み取り専用クエリでの AsNoTracking
public async Task<IReadOnlyList<{Entity}>> GetAllForDisplayAsync(
CancellationToken cancellationToken = default)
{
return await _dbContext
.Set<{Entity}>()
.AsNoTracking() // Performance: no change tracking
.Where(e => e.IsActive)
.OrderBy(e => e.Name)
.ToListAsync(cancellationToken);
}
選択的な Include(過剰なデータ取得を避ける)
// ❌ WRONG: Loading everything
public async Task<{Entity}?> GetByIdAsync(Guid id, CancellationToken ct)
{
return await _dbContext
.Set<{Entity}>()
.Include(e => e.Children)
.Include(e => e.Parent)
.Include(e => e.Logs) // Potentially thousands of records!
.FirstOrDefaultAsync(e => e.Id == id, ct);
}
// ✅ CORRECT: Separate methods for different needs
public async Task<{Entity}?> GetByIdAsync(Guid id, CancellationToken ct)
{
return await _dbContext
.Set<{Entity}>()
.FirstOrDefaultAsync(e => e.Id == id, ct);
}
public async Task<{Entity}?> GetByIdWithChildrenAsync(Guid id, CancellationToken ct)
{
return await _dbContext
.Set<{Entity}>()
.Include(e => e.Children)
.FirstOrDefaultAsync(e => e.Id == id, ct);
}
大規模なコレクションでの分割クエリ
public async Task<{Entity}?> GetByIdWithAllRelationsAsync(
Guid id,
CancellationToken cancellationToken = default)
{
return await _dbContext
.Set<{Entity}>()
.Include(e => e.Children)
.Include(e => e.OtherRelation)
.AsSplitQuery() // Splits into multiple SQL queries
.FirstOrDefaultAsync(e => e.Id == id, cancellationToken);
}
重要なルール
- 集約ルートごとにリポジトリを用意する - エンティティごとではありません
- リポジトリで SaveChanges を実行しない - それは IUnitOfWork の役割です
- インターフェースはドメイン層に置く - 実装はインフラストラクチャ層に置きます
- CancellationToken を使用する - すべての非同期メソッドで使用します
- 見つからない場合は null を返す - どう対応するかはハンドラーに判断させます
- 読み取りには AsNoTracking を使用する - 変更しない場合に使用します
- Include は選択的に行う - 過剰なデータ取得を避けます
- フィルターなしの GetAll は避ける - 大規模な環境では危険な場合があります
- 子エンティティには集約を通じてアクセスする - 子エンティティのリポジトリを公開しないでください
- 実装には internal クラスを使用する - 実装の詳細を隠します
避けるべきアンチパターン
// ❌ WRONG: SaveChanges in repository
public void Add({Entity} {entity})
{
_dbContext.Set<{Entity}>().Add({entity});
_dbContext.SaveChanges(); // Don't do this!
}
// ✅ CORRECT: Only track, save via UnitOfWork
public void Add({Entity} {entity})
{
_dbContext.Set<{Entity}>().Add({entity});
}
// In handler:
await _unitOfWork.SaveChangesAsync(ct);
// ❌ WRONG: Repository for child entities
public interface IOrderItemRepository { ... }
// ✅ CORRECT: Access through aggregate root
public interface IOrderRepository
{
Task<OrderItem?> GetOrderItemAsync(Guid orderId, Guid itemId, ...);
}
// ❌ WRONG: Exposing IQueryable
public IQueryable<{Entity}> GetAll() => _dbContext.Set<{Entity}>();
// ✅ CORRECT: Return materialized lists
public async Task<IReadOnlyList<{Entity}>> GetAllAsync(CancellationToken ct)
{
return await _dbContext.Set<{Entity}>().ToListAsync(ct);
}
// ❌ WRONG: Business logic in repository
public async Task<{Entity}?> GetActiveByIdAsync(Guid id, CancellationToken ct)
{
var entity = await GetByIdAsync(id, ct);
if (entity?.IsActive == false)
throw new BusinessException("Entity is inactive"); // Wrong!
return entity;
}
// ✅ CORRECT: Let handler handle business logic
public async Task<{Entity}?> GetByIdAsync(Guid id, CancellationToken ct)
{
return await _dbContext.Set<{Entity}>()
.FirstOrDefaultAsync(e => e.Id == id, ct);
}
関連スキル
dotnet-domain-entity-generator- リポジトリ用のエンティティを生成しますdotnet-ef-core-configuration- エンティティのマッピングを構成しますdotnet-cqrs-command-generator- ハンドラーでリポジトリを使用しますdotnet-clean-architecture- プロジェクト全体の構造
コメント
Markdown 対応