π― Sprint 2 - Task 1: Migrate Repository Pattern to Clean Architecture
Epic: #121
Sprint: 2 (2 weeks)
Estimated Effort: 4 days
Depends On: #122, #123, #124 (Sprint 1 Foundation)
π Description
Migrate the existing repository pattern to align with Clean Architecture principles. This includes creating generic base repositories, implementing Unit of Work pattern, and moving repository implementations to the Infrastructure layer.
π― Current State Analysis
Current Repository Issues
// Current: Only AuthorRepository exists, mixed concerns
public class AuthorRepository : IAuthorRepository
{
private readonly AppDbContext _context;
// Direct EF dependency, no abstraction
// No Unit of Work pattern
// Inconsistent patterns across entities
}
// Missing repositories for other entities
// No generic base repository
// EF Context directly injected everywhere
Target Clean Architecture Repository
// Target: Clean, generic, testable repositories
public interface IRepository<T> where T : BaseEntity
{
Task<T?> GetByIdAsync(long id, CancellationToken cancellationToken = default);
Task<IEnumerable<T>> GetAllAsync(CancellationToken cancellationToken = default);
Task<T> AddAsync(T entity, CancellationToken cancellationToken = default);
Task UpdateAsync(T entity, CancellationToken cancellationToken = default);
Task DeleteAsync(T entity, CancellationToken cancellationToken = default);
}
β
Acceptance Criteria
1. Domain Repository Interfaces (in Domain layer)
2. Application Repository Contracts (in Application layer)
3. Infrastructure Repository Implementations
4. Advanced Repository Features
ποΈ Implementation Details
Domain Layer Interfaces
// Refhub.Domain/Interfaces/IRepository.cs
namespace Refhub.Domain.Interfaces;
public interface IRepository<T> where T : BaseEntity
{
// Read Operations
Task<T?> GetByIdAsync(long id, CancellationToken cancellationToken = default);
Task<IEnumerable<T>> GetAllAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<T>> GetByIdsAsync(IEnumerable<long> ids, CancellationToken cancellationToken = default);
Task<T?> GetFirstOrDefaultAsync(ISpecification<T> specification, CancellationToken cancellationToken = default);
Task<IEnumerable<T>> GetAsync(ISpecification<T> specification, CancellationToken cancellationToken = default);
Task<int> CountAsync(ISpecification<T>? specification = null, CancellationToken cancellationToken = default);
Task<bool> ExistsAsync(ISpecification<T> specification, CancellationToken cancellationToken = default);
// Write Operations
Task<T> AddAsync(T entity, CancellationToken cancellationToken = default);
Task<IEnumerable<T>> AddRangeAsync(IEnumerable<T> entities, CancellationToken cancellationToken = default);
Task UpdateAsync(T entity, CancellationToken cancellationToken = default);
Task UpdateRangeAsync(IEnumerable<T> entities, CancellationToken cancellationToken = default);
Task DeleteAsync(T entity, CancellationToken cancellationToken = default);
Task DeleteRangeAsync(IEnumerable<T> entities, CancellationToken cancellationToken = default);
}
// Refhub.Domain/Interfaces/IBookRepository.cs
public interface IBookRepository : IRepository<Book>
{
Task<Book?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default);
Task<Book?> GetBySlugWithDetailsAsync(string slug, CancellationToken cancellationToken = default);
Task<IEnumerable<Book>> GetByAuthorIdAsync(long authorId, CancellationToken cancellationToken = default);
Task<IEnumerable<Book>> GetByCategoryIdAsync(long categoryId, CancellationToken cancellationToken = default);
Task<PagedResult<Book>> GetPagedAsync(BookSearchCriteria criteria, int page, int pageSize, CancellationToken cancellationToken = default);
Task<IEnumerable<Book>> GetRelatedBooksAsync(long bookId, int count = 5, CancellationToken cancellationToken = default);
Task<bool> IsSlugUniqueAsync(string slug, long? excludeId = null, CancellationToken cancellationToken = default);
Task<IEnumerable<Book>> GetRecentBooksAsync(int count = 10, CancellationToken cancellationToken = default);
Task<IEnumerable<Book>> GetPopularBooksAsync(int count = 10, CancellationToken cancellationToken = default);
}
Unit of Work Pattern
// Refhub.Domain/Interfaces/IUnitOfWork.cs
public interface IUnitOfWork : IDisposable
{
IBookRepository Books { get; }
IAuthorRepository Authors { get; }
ICategoryRepository Categories { get; }
IKeywordRepository Keywords { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
Task BeginTransactionAsync(CancellationToken cancellationToken = default);
Task CommitTransactionAsync(CancellationToken cancellationToken = default);
Task RollbackTransactionAsync(CancellationToken cancellationToken = default);
}
// Refhub.Infrastructure/Data/UnitOfWork.cs
public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _context;
private IDbContextTransaction? _transaction;
public UnitOfWork(AppDbContext context)
{
_context = context;
Books = new BookRepository(context);
Authors = new AuthorRepository(context);
Categories = new CategoryRepository(context);
Keywords = new KeywordRepository(context);
}
public IBookRepository Books { get; }
public IAuthorRepository Authors { get; }
public ICategoryRepository Categories { get; }
public IKeywordRepository Keywords { get; }
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
// Dispatch domain events before saving
await DispatchDomainEventsAsync(cancellationToken);
// Apply audit information
ApplyAuditInformation();
return await _context.SaveChangesAsync(cancellationToken);
}
public async Task BeginTransactionAsync(CancellationToken cancellationToken = default)
{
_transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
}
public async Task CommitTransactionAsync(CancellationToken cancellationToken = default)
{
if (_transaction != null)
{
await _transaction.CommitAsync(cancellationToken);
await _transaction.DisposeAsync();
_transaction = null;
}
}
public async Task RollbackTransactionAsync(CancellationToken cancellationToken = default)
{
if (_transaction != null)
{
await _transaction.RollbackAsync(cancellationToken);
await _transaction.DisposeAsync();
_transaction = null;
}
}
private async Task DispatchDomainEventsAsync(CancellationToken cancellationToken)
{
var domainEntities = _context.ChangeTracker
.Entries<BaseEntity>()
.Where(x => x.Entity.DomainEvents.Any())
.ToList();
var domainEvents = domainEntities
.SelectMany(x => x.Entity.DomainEvents)
.ToList();
domainEntities.ForEach(entity => entity.Entity.ClearDomainEvents());
foreach (var domainEvent in domainEvents)
{
await _mediator.Publish(domainEvent, cancellationToken);
}
}
private void ApplyAuditInformation()
{
var entries = _context.ChangeTracker.Entries<BaseEntity>();
foreach (var entry in entries)
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedAt = DateTime.UtcNow;
entry.Entity.CreatedBy = _currentUserService.UserId;
break;
case EntityState.Modified:
entry.Entity.UpdatedAt = DateTime.UtcNow;
entry.Entity.UpdatedBy = _currentUserService.UserId;
break;
}
}
}
public void Dispose()
{
_transaction?.Dispose();
_context.Dispose();
}
}
Base Repository Implementation
// Refhub.Infrastructure/Data/Repositories/BaseRepository.cs
public abstract class BaseRepository<T> : IRepository<T> where T : BaseEntity
{
protected readonly AppDbContext Context;
protected readonly DbSet<T> DbSet;
protected BaseRepository(AppDbContext context)
{
Context = context;
DbSet = context.Set<T>();
}
public virtual async Task<T?> GetByIdAsync(long id, CancellationToken cancellationToken = default)
{
return await DbSet.FindAsync(new object[] { id }, cancellationToken);
}
public virtual async Task<IEnumerable<T>> GetAllAsync(CancellationToken cancellationToken = default)
{
return await DbSet.ToListAsync(cancellationToken);
}
public virtual async Task<IEnumerable<T>> GetByIdsAsync(IEnumerable<long> ids, CancellationToken cancellationToken = default)
{
return await DbSet.Where(x => ids.Contains(x.Id)).ToListAsync(cancellationToken);
}
public virtual async Task<T?> GetFirstOrDefaultAsync(ISpecification<T> specification, CancellationToken cancellationToken = default)
{
return await ApplySpecification(specification).FirstOrDefaultAsync(cancellationToken);
}
public virtual async Task<IEnumerable<T>> GetAsync(ISpecification<T> specification, CancellationToken cancellationToken = default)
{
return await ApplySpecification(specification).ToListAsync(cancellationToken);
}
public virtual async Task<int> CountAsync(ISpecification<T>? specification = null, CancellationToken cancellationToken = default)
{
if (specification == null)
return await DbSet.CountAsync(cancellationToken);
return await ApplySpecification(specification).CountAsync(cancellationToken);
}
public virtual async Task<bool> ExistsAsync(ISpecification<T> specification, CancellationToken cancellationToken = default)
{
return await ApplySpecification(specification).AnyAsync(cancellationToken);
}
public virtual async Task<T> AddAsync(T entity, CancellationToken cancellationToken = default)
{
await DbSet.AddAsync(entity, cancellationToken);
return entity;
}
public virtual async Task<IEnumerable<T>> AddRangeAsync(IEnumerable<T> entities, CancellationToken cancellationToken = default)
{
await DbSet.AddRangeAsync(entities, cancellationToken);
return entities;
}
public virtual Task UpdateAsync(T entity, CancellationToken cancellationToken = default)
{
DbSet.Update(entity);
return Task.CompletedTask;
}
public virtual Task UpdateRangeAsync(IEnumerable<T> entities, CancellationToken cancellationToken = default)
{
DbSet.UpdateRange(entities);
return Task.CompletedTask;
}
public virtual Task DeleteAsync(T entity, CancellationToken cancellationToken = default)
{
if (entity is ISoftDeletable softDeletable)
{
softDeletable.SoftDelete();
DbSet.Update(entity);
}
else
{
DbSet.Remove(entity);
}
return Task.CompletedTask;
}
public virtual Task DeleteRangeAsync(IEnumerable<T> entities, CancellationToken cancellationToken = default)
{
foreach (var entity in entities)
{
if (entity is ISoftDeletable softDeletable)
{
softDeletable.SoftDelete();
}
}
var softDeletableEntities = entities.Where(e => e is ISoftDeletable).ToList();
var hardDeleteEntities = entities.Where(e => e is not ISoftDeletable).ToList();
if (softDeletableEntities.Any())
DbSet.UpdateRange(softDeletableEntities);
if (hardDeleteEntities.Any())
DbSet.RemoveRange(hardDeleteEntities);
return Task.CompletedTask;
}
private IQueryable<T> ApplySpecification(ISpecification<T> specification)
{
return SpecificationEvaluator<T>.GetQuery(DbSet.AsQueryable(), specification);
}
}
Book Repository Implementation
// Refhub.Infrastructure/Data/Repositories/BookRepository.cs
public class BookRepository : BaseRepository<Book>, IBookRepository
{
public BookRepository(AppDbContext context) : base(context) { }
public async Task<Book?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default)
{
return await DbSet
.Where(b => b.Slug == slug && !b.IsDeleted)
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<Book?> GetBySlugWithDetailsAsync(string slug, CancellationToken cancellationToken = default)
{
return await DbSet
.Include(b => b.Category)
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.Include(b => b.BookKeywords).ThenInclude(bk => bk.Keyword)
.Include(b => b.RelatedTo).ThenInclude(r => r.RelatedBook)
.Include(b => b.RelatedFrom).ThenInclude(r => r.Book)
.Where(b => b.Slug == slug && !b.IsDeleted)
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<IEnumerable<Book>> GetByAuthorIdAsync(long authorId, CancellationToken cancellationToken = default)
{
return await DbSet
.Include(b => b.Category)
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.Where(b => b.BookAuthors.Any(ba => ba.AuthorId == authorId) && !b.IsDeleted)
.ToListAsync(cancellationToken);
}
public async Task<IEnumerable<Book>> GetByCategoryIdAsync(long categoryId, CancellationToken cancellationToken = default)
{
return await DbSet
.Include(b => b.Category)
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.Where(b => b.CategoryId == categoryId && !b.IsDeleted)
.ToListAsync(cancellationToken);
}
public async Task<PagedResult<Book>> GetPagedAsync(BookSearchCriteria criteria, int page, int pageSize, CancellationToken cancellationToken = default)
{
var query = DbSet
.Include(b => b.Category)
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.Where(b => !b.IsDeleted);
// Apply search criteria
if (!string.IsNullOrEmpty(criteria.SearchText))
{
query = query.Where(b => EF.Functions.Like(b.Title.Value, $"%{criteria.SearchText}%") ||
EF.Functions.Like(b.Description, $"%{criteria.SearchText}%"));
}
if (criteria.CategoryId.HasValue)
{
query = query.Where(b => b.CategoryId == criteria.CategoryId.Value);
}
if (criteria.AuthorIds?.Any() == true)
{
query = query.Where(b => b.BookAuthors.Any(ba => criteria.AuthorIds.Contains(ba.AuthorId)));
}
var totalCount = await query.CountAsync(cancellationToken);
var items = await query
.OrderBy(b => b.Title.Value)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return new PagedResult<Book>(items, totalCount, page, pageSize);
}
public async Task<IEnumerable<Book>> GetRelatedBooksAsync(long bookId, int count = 5, CancellationToken cancellationToken = default)
{
var book = await GetByIdAsync(bookId, cancellationToken);
if (book == null) return Enumerable.Empty<Book>();
// Get books in same category, excluding current book
return await DbSet
.Include(b => b.Category)
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.Where(b => b.CategoryId == book.CategoryId && b.Id != bookId && !b.IsDeleted)
.Take(count)
.ToListAsync(cancellationToken);
}
public async Task<bool> IsSlugUniqueAsync(string slug, long? excludeId = null, CancellationToken cancellationToken = default)
{
var query = DbSet.Where(b => b.Slug == slug && !b.IsDeleted);
if (excludeId.HasValue)
{
query = query.Where(b => b.Id != excludeId.Value);
}
return !await query.AnyAsync(cancellationToken);
}
public async Task<IEnumerable<Book>> GetRecentBooksAsync(int count = 10, CancellationToken cancellationToken = default)
{
return await DbSet
.Include(b => b.Category)
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.Where(b => !b.IsDeleted)
.OrderByDescending(b => b.CreatedAt)
.Take(count)
.ToListAsync(cancellationToken);
}
public async Task<IEnumerable<Book>> GetPopularBooksAsync(int count = 10, CancellationToken cancellationToken = default)
{
// Note: This would need a ViewCount property on Book entity
return await DbSet
.Include(b => b.Category)
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.Where(b => !b.IsDeleted)
.OrderByDescending(b => b.CreatedAt) // Placeholder until ViewCount is implemented
.Take(count)
.ToListAsync(cancellationToken);
}
}
π§ Implementation Steps
Day 1: Domain & Application Interfaces
-
Create Base Repository Interfaces
IRepository<T> with generic CRUD operations
IUnitOfWork interface
- Specification pattern interfaces
-
Create Entity-Specific Interfaces
IBookRepository with book-specific methods
IAuthorRepository with author-specific methods
ICategoryRepository with category-specific methods
Day 2: Infrastructure Base Implementation
-
Base Repository Implementation
BaseRepository<T> with EF Core implementation
- Specification evaluator
- Audit trail support
-
Unit of Work Implementation
UnitOfWork with transaction support
- Domain event dispatching
- Audit information application
Day 3: Entity-Specific Repositories
-
Book Repository
- Migrate existing BookService data access logic
- Implement optimized queries with proper includes
- Add pagination and search capabilities
-
Author Repository
- Migrate from existing AuthorRepository
- Enhance with new interface methods
- Add author-specific query optimizations
Day 4: Testing & Integration
-
Unit Tests
- Repository unit tests with in-memory database
- Specification pattern tests
- Unit of Work tests
-
Integration
- Update command handlers to use repositories
- Update query handlers to use repositories
- Remove direct DbContext dependencies
π― Testing Criteria
π Related Tasks
π Resources
Sprint: 2 | Assignee: @hootanht | Priority: High | Size: Large
π― Sprint 2 - Task 1: Migrate Repository Pattern to Clean Architecture
Epic: #121
Sprint: 2 (2 weeks)
Estimated Effort: 4 days
Depends On: #122, #123, #124 (Sprint 1 Foundation)
π Description
Migrate the existing repository pattern to align with Clean Architecture principles. This includes creating generic base repositories, implementing Unit of Work pattern, and moving repository implementations to the Infrastructure layer.
π― Current State Analysis
Current Repository Issues
Target Clean Architecture Repository
β Acceptance Criteria
1. Domain Repository Interfaces (in Domain layer)
IRepository<T>base interface with CRUD operationsIBookRepositorywith book-specific methodsIAuthorRepositorywith author-specific methodsICategoryRepositorywith category-specific methodsIUnitOfWorkinterface for transaction management2. Application Repository Contracts (in Application layer)
3. Infrastructure Repository Implementations
BaseRepository<T>with common EF operationsBookRepositorywith optimized queries and includesAuthorRepositorymigration from current implementationCategoryRepositorywith hierarchical supportUnitOfWorkimplementation with transaction support4. Advanced Repository Features
ποΈ Implementation Details
Domain Layer Interfaces
Unit of Work Pattern
Base Repository Implementation
Book Repository Implementation
π§ Implementation Steps
Day 1: Domain & Application Interfaces
Create Base Repository Interfaces
IRepository<T>with generic CRUD operationsIUnitOfWorkinterfaceCreate Entity-Specific Interfaces
IBookRepositorywith book-specific methodsIAuthorRepositorywith author-specific methodsICategoryRepositorywith category-specific methodsDay 2: Infrastructure Base Implementation
Base Repository Implementation
BaseRepository<T>with EF Core implementationUnit of Work Implementation
UnitOfWorkwith transaction supportDay 3: Entity-Specific Repositories
Book Repository
Author Repository
Day 4: Testing & Integration
Unit Tests
Integration
π― Testing Criteria
π Related Tasks
π Resources
Sprint: 2 | Assignee: @hootanht | Priority: High | Size: Large