Domain-Driven Design (DDD)؛ بخش هشتم: الگوهای Repository و Specification
نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۰۶/۰۳ ۰۸:۰۵
آدرس: www.dntips.ir
| مطالب | ۳۶۹۴ |
| نویسندگان | ۲۷۶ |
| گروههای مطالب | ۱۰۲۴ |
| نقشههای راه | ۱۱۹ |
| دورهها | ۱۴ |
| اشتراکها | ۱۷۹۱۴ |
public interface IOrderRepository
{
Task<Order> GetByIdAsync(int id);
Task AddAsync(Order order);
Task RemoveAsync(Order order);
}public class OrderRepository : IOrderRepository
{
private readonly ApplicationDbContext _context;
public OrderRepository(ApplicationDbContext context)
{
_context = context;
}
public async Task<Order> GetByIdAsync(int id)
{
// EF Core specific implementation
return await _context.Orders.FindAsync(id);
}
public async Task AddAsync(Order order)
{
await _context.Orders.AddAsync(order);
}
public async Task RemoveAsync(Order order)
{
_context.Orders.Remove(order);
}
}OrderRepository از ApplicationDbContext استفاده میکند، اما این جزئیات در پشت واسط IOrderRepository پنهان شده است. لایه سرویس یا دامنه فقط با واسط IOrderRepository کار میکند و هیچ اطلاعی از نحوه ذخیرهسازی دادهها ندارد.DbContext متصل هستند؟ اگر چنین است، چقدر زمان برای تست کردن این کلاسها صرف میکنید؟public class OrderRepository
{
public IEnumerable<Order> GetPaidOrders()
{
return _context.Orders.Where(o => o.Status == OrderStatus.Paid).ToList();
}
}public interface ISpecification<T>
{
Expression<Func<T, bool>> Criteria { get; }
}public class PaidOrdersSpecification : ISpecification<Order>
{
public Expression<Func<Order, bool>> Criteria => o => o.Status == OrderStatus.Paid;
}public class OrderRepository
{
public IEnumerable<Order> Find(ISpecification<Order> spec)
{
return _context.Orders.Where(spec.Criteria).ToList();
}
}PaidOrdersSpecification، سفارشهای پرداخت شده را پیدا کنیم، بدون اینکه منطق کوئری تکرار شود.ChargeBill) را شناختیم و با Command/Handler و Eventها مسیر را کامل کردیم، وقت آن است که سراغ دو الگوی مهم در دنیای DDD + EF-Core برویم:BuildingContext بهطور مستقیم در Command Handler استفاده شد. این بد نیست، اما یک اشکال ظریف دارد:Command Handler ما حالا EF-Core را بهطور «مستقیم» میشناسد، در حالی که ما میخواهیم Handler فقط با زبان دامنه (Domain Language) کار کند.
ChargeBillpublic interface IChargeBillRepository
{
Task<ChargeBill?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task AddAsync(ChargeBill chargeBill, CancellationToken cancellationToken = default);
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}public class ChargeBillRepository : IChargeBillRepository
{
private readonly BuildingContext _context;
public ChargeBillRepository(BuildingContext context)
{
_context = context;
}
public async Task<ChargeBill?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.ChargeBills.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
}
public async Task AddAsync(ChargeBill chargeBill, CancellationToken cancellationToken = default)
{
await _context.ChargeBills.AddAsync(chargeBill, cancellationToken);
}
public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
await _context.SaveChangesAsync(cancellationToken);
}
}«تمام قبضهای پرداختنشده واحد X که مهلت پرداختشان گذشته»
public interface ISpecification<T>
{
IQueryable<T> Apply(IQueryable<T> query);
}public class OverdueUnpaidChargeBillsSpec : ISpecification<ChargeBill>
{
private readonly Guid _unitId;
public OverdueUnpaidChargeBillsSpec(Guid unitId)
{
_unitId = unitId;
}
public IQueryable<ChargeBill> Apply(IQueryable<ChargeBill> query)
{
return query.Where(c => c.UnitId == _unitId &&
!c.IsPaid &&
c.DueDate < DateTime.UtcNow);
}
}public async Task<List<ChargeBill>> GetBySpecificationAsync(
ISpecification<ChargeBill> specification,
CancellationToken cancellationToken = default)
{
var query = _context.ChargeBills.AsQueryable();
query = specification.Apply(query);
return await query.ToListAsync(cancellationToken);
}var bills = await _context.ChargeBills
.Where(x => x.UnitId == unitId && !x.IsPaid && x.DueDate < DateTime.UtcNow)
.ToListAsync();var spec = new OverdueUnpaidChargeBillsSpec(unitId); var bills = await repository.GetBySpecificationAsync(spec);
محاسبه و اعمال جریمهی تأخیر در پرداخت شارژ واحدها
LatePaymentRegistered) منتشر شود.public record ApplyLateFeesCommand(Guid UnitId) : IRequest;
public class ApplyLateFeesCommandHandler : IRequestHandler<ApplyLateFeesCommand>
{
private readonly IChargeBillRepository _repository;
public ApplyLateFeesCommandHandler(IChargeBillRepository repository)
{
_repository = repository;
}
public async Task<Unit> Handle(ApplyLateFeesCommand request, CancellationToken cancellationToken)
{
// استفاده از Specification
var spec = new OverdueUnpaidChargeBillsSpec(request.UnitId);
var overdueBills = await _repository.GetBySpecificationAsync(spec, cancellationToken);
foreach (var bill in overdueBills)
{
var penalty = bill.Amount * 0.05m; // جریمه ۵ درصد
bill.RegisterLatePayment(penalty);
}
await _repository.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}public record LatePaymentRegistered(Guid ChargeBillId, decimal PenaltyAmount, DateTime RegisteredAt) : INotification;
ChargeBill) هنگام RegisterLatePayment باید این Event هم Raise شود:public void RegisterLatePayment(decimal penaltyAmount)
{
if (DateTime.UtcNow <= DueDate)
throw new InvalidOperationException("Due date has not passed yet.");
LatePenalty = penaltyAmount;
AddDomainEvent(new LatePaymentRegistered(this.Id, penaltyAmount, DateTime.UtcNow));
}public class LatePaymentEmailHandler : INotificationHandler<LatePaymentRegistered>
{
private readonly IEmailService _emailService;
public LatePaymentEmailHandler(IEmailService emailService)
{
_emailService = emailService;
}
public async Task Handle(LatePaymentRegistered notification, CancellationToken cancellationToken)
{
var toEmail = "owner@example.com"; // در دنیای واقعی از DB
var subject = "جریمهی تأخیر برای پرداخت شارژ";
var body = $"برای قبض {notification.ChargeBillId} جریمهای به مبلغ {notification.PenaltyAmount} ثبت شد.";
await _emailService.SendAsync(toEmail, subject, body);
}
}RegisterLatePayment را صدا زدیم تا جریمه اعمال و Event تولید شود.DbContext یا Context در EF خود رفتار مشابه UnitOfWork را دارد و نیازی نیست لایه دیگری برای آن اضافه کنیم. این تحلیل به درستی ماهیت تراکنش و رفتار EF را برجسته میکند..Save() در هر Repository جدا فراخوانی شوند، یک تراکنش واحد به چندین تراکنش تبدیل میشود — این میتواند منجر به inconsistency شود..Include و IQueryable را در اختیار میگذارند، سبب میشوند انتزاع به گونهای "leaky" باشد که وابستگی به ORM را عملاً حفظ میکند و قابلیت جابجایی را کاهش میدهد.public class ApplyLateFeesCommandHandler : IRequestHandler<ApplyLateFeesCommand>
{
private readonly IChargeBillRepository _repository;
private readonly IUnitOfWork _unitOfWork;
public ApplyLateFeesCommandHandler(IChargeBillRepository repository, IUnitOfWork unitOfWork)
{
_repository = repository;
_unitOfWork = unitOfWork;
}
public async Task<Unit> Handle(ApplyLateFeesCommand request, CancellationToken cancellationToken)
{
var spec = new OverdueUnpaidChargeBillsSpec(request.UnitId);
var overdueBills = await _repository.GetBySpecificationAsync(spec, cancellationToken);
foreach (var bill in overdueBills)
{
var penalty = bill.Amount * 0.05m;
bill.RegisterLatePayment(penalty);
}
// ✅ an atomic transaction
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}IChargeBillRepository فقط شامل متدهای دسترسی به Aggregate Root (مثلاً AddAsync, GetByIdAsync, GetBySpecificationAsync) است.IUnitOfWork (که معمولاً یک Wrapper ساده روی EF DbContext است) وظیفهی commit کردن تغییرات را دارد.Where، مرتبسازی (OrderBy/OrderByDescending)، بارگذاری مشتاق مرتبطها (Include و ThenInclude)، صفحهبندی (Skip/Take)، و سایر عملیات رایج را فراهم میکند. برای مثال میتوان به صورت زیر از آن استفاده کرد:var spec = new CustomerByLastNameSpec("Smith");
var customers = await _dbContext.Customers
.WithSpecification(spec)
.ToListAsync(); // دریافت مشتریان با نام خانوادگی برابر "Smith"PremiumCustomers2023Spec روبرو میشوید که بلافاصله هدف آن را مشخص میکند.var customers = await _dbContext.Customers
.Where(c => c.IsPremium && c.RegistrationYear == 2023)
.Include(c => c.Orders)
.OrderBy(c => c.Name)
.ToListAsync();public class PremiumCustomers2023Spec : Specification<Customer>
{
public PremiumCustomers2023Spec()
{
Query.Where(c => c.IsPremium && c.RegistrationYear == 2023)
.Include(c => c.Orders)
.OrderBy(c => c.Name);
}
}PremiumCustomers2023Spec تعریف میکند که قابل استفاده مجدد است.public interface IRepository<T> where T : class
{
Task<T?> GetAsync(ISpecification<T> spec);
Task<List<T>> ListAsync(ISpecification<T> spec);
Task<int> CountAsync(ISpecification<T> spec);
}public class EfRepository<T> : IRepository<T> where T : class
{
private readonly DbContext _db;
public async Task<List<T>> ListAsync(ISpecification<T> spec)
{
var query = SpecificationEvaluator.Default.GetQuery(_db.Set<T>().AsQueryable(), spec);
return await query.ToListAsync();
}
// سایر متدها مانند GetAsync و CountAsync به همین شکل پیادهسازی میشوند.
}SpecificationEvaluator دارد که پرسوجوی LINQ را بر اساس قوانین تعریفشده در Specification تولید میکند. به این ترتیب، Repository دیگر نیازی به درک منطق فیلترها ندارد و فقط با یک شیء Specification سروکار دارد.public class ProductFilterSpec : Specification<Product>
{
public ProductFilterSpec(string? brand, string? type, decimal? minPrice, bool onlyInStock)
{
if (!string.IsNullOrEmpty(brand)) Query.Where(p => p.Brand == brand);
if (!string.IsNullOrEmpty(type)) Query.Where(p => p.Type == type);
if (minPrice != null) Query.Where(p => p.Price >= minPrice);
if (onlyInStock) Query.Where(p => p.IsInStock);
Query.OrderBy(p => p.Name);
}
}[HttpGet("products")]
public async Task<ActionResult<List<Product>>> GetProducts([FromQuery] string? brand, string? type, decimal? minPrice, bool onlyInStock = false)
{
var spec = new ProductFilterSpec(brand, type, minPrice, onlyInStock);
var products = await _repo.ListAsync(spec);
return Ok(products);
}ProductFilterSpec قرار دارد و بهراحتی قابل تغییر و نگهداری است.public class AndSpecification<T> : Specification<T>
{
public AndSpecification(ISpecification<T> left, ISpecification<T> right)
{
Query.Where(x => left.IsSatisfiedBy(x) && right.IsSatisfiedBy(x));
}
}
// اکنون میتوان Specificationها را با هم ترکیب کرد، مثلاً:
// new AndSpecification(new PremiumCustomers2023Spec(), new CustomersWithLargeOrdersSpec());public class OrdersWithDetailsSpec : Specification<Order>
{
public OrdersWithDetailsSpec(DateTime start, DateTime end)
{
Query.Where(o => o.Created >= start && o.Created <= end)
.Include(o => o.Items)
.ThenInclude(i => i.Product)
.OrderByDescending(o => o.Created)
.TagWith("RangeQuery"); // استفاده از ویژگی TagWith
}
}IsSatisfiedBy در کتابخانه Ardalis.Specification امکانپذیر است.// نمونهسازی یک شیء Specification
var spec = new PremiumCustomers2023Spec();
// تست منطق با یک شیء مشتری در حافظه
var customerInstance = new Customer { IsPremium = true, RegistrationYear = 2023, Name = "Alice" };
var isSatisfied = spec.IsSatisfiedBy(customerInstance); // این خط مقدار true را باز میگرداند.