Skip to content

πŸ—„οΈ 5. Migrate Repository Pattern to Clean ArchitectureΒ #125

@hootanht

Description

@hootanht

🎯 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)

  • IRepository<T> base interface with CRUD operations
  • IBookRepository with book-specific methods
  • IAuthorRepository with author-specific methods
  • ICategoryRepository with category-specific methods
  • IUnitOfWork interface for transaction management

2. Application Repository Contracts (in Application layer)

  • Repository interfaces extended with application-specific methods
  • Query specifications for complex filtering
  • Pagination support interfaces
  • Bulk operation interfaces

3. Infrastructure Repository Implementations

  • BaseRepository<T> with common EF operations
  • BookRepository with optimized queries and includes
  • AuthorRepository migration from current implementation
  • CategoryRepository with hierarchical support
  • UnitOfWork implementation with transaction support

4. Advanced Repository Features

  • Specification pattern for complex queries
  • Read/Write repository separation (CQRS optimization)
  • Caching layer abstraction
  • Audit logging for data changes

πŸ—οΈ 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

  1. Create Base Repository Interfaces

    • IRepository<T> with generic CRUD operations
    • IUnitOfWork interface
    • Specification pattern interfaces
  2. Create Entity-Specific Interfaces

    • IBookRepository with book-specific methods
    • IAuthorRepository with author-specific methods
    • ICategoryRepository with category-specific methods

Day 2: Infrastructure Base Implementation

  1. Base Repository Implementation

    • BaseRepository<T> with EF Core implementation
    • Specification evaluator
    • Audit trail support
  2. Unit of Work Implementation

    • UnitOfWork with transaction support
    • Domain event dispatching
    • Audit information application

Day 3: Entity-Specific Repositories

  1. Book Repository

    • Migrate existing BookService data access logic
    • Implement optimized queries with proper includes
    • Add pagination and search capabilities
  2. Author Repository

    • Migrate from existing AuthorRepository
    • Enhance with new interface methods
    • Add author-specific query optimizations

Day 4: Testing & Integration

  1. Unit Tests

    • Repository unit tests with in-memory database
    • Specification pattern tests
    • Unit of Work tests
  2. Integration

    • Update command handlers to use repositories
    • Update query handlers to use repositories
    • Remove direct DbContext dependencies

🎯 Testing Criteria

  • All repositories have comprehensive unit tests
  • Integration tests with real database
  • Unit of Work properly manages transactions
  • Domain events are dispatched correctly
  • Audit information is applied automatically
  • Soft delete works correctly
  • Specifications work for complex queries

πŸ”— Related Tasks

πŸ“š Resources


Sprint: 2 | Assignee: @hootanht | Priority: High | Size: Large

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions