عنوان:

‫Domain-Driven Design (DDD)؛ بخش هشتم: الگوهای Repository و Specification


نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۰۶/۰۳ ۰۸:۰۵
آدرس: www.dntips.ir
الگوی Repository: پل ارتباطی بین دنیای دامنه و دیتابیس 🌉

تا به حال در پروژه‌ای بوده‌اید که منطق دیتابیس (مثل کوئری‌ها و دستورات SQL) به صورت مستقیم در داخل کلاس‌های سرویس یا حتی کنترلرها قرار داشته باشد؟ این کار به ظاهر ساده است، اما به مرور زمان باعث ایجاد وابستگی‌های سفت و سخت می‌شود.
فرض کنید تصمیم بگیرید دیتابیس را از SQL Server به MongoDB تغییر دهید. در این حالت، باید تک‌تک کوئری‌ها را در کل پروژه تغییر دهید، که کاری پرهزینه و پرخطر است.
اینجاست که الگوی Repository به کمک ما می‌آید. این الگو، به طور خاص در DDD، یک پل ارتباطی حیاتی بین مدل دامنه و لایه Persistence (ذخیره‌سازی داده) ایجاد می‌کند.

Repository چیست؟

Repository (مخزن)، یک واسط (Interface) است که برای مجموعه‌ای از Aggregates (که در درس‌های قبل درباره آن صحبت کردیم) رفتارهای ذخیره، بازیابی و حذف را شبیه‌سازی می‌کند.
در واقع، Repository مانند یک مجموعه (Collection) از Aggregate ها در حافظه است. وقتی از Repository استفاده می‌کنید، دیگر نگران این نیستید که داده‌ها چگونه ذخیره یا بازیابی می‌شوند. فقط به آن می‌گویید: "این Aggregate را ذخیره کن" یا "آن Aggregate را برایم پیدا کن".

چرا از Repository Pattern استفاده کنیم؟

استفاده از Repository Pattern مزایای زیادی به همراه دارد:
  • جداسازی (Decoupling): این الگو، منطق دامنه را از جزئیات مربوط به دیتابیس جدا می‌کند. این به شما اجازه می‌دهد که بدون نگرانی از منطق دیتابیس، روی قوانین تجاری تمرکز کنید.
  • تست‌پذیری: با استفاده از واسط (Interface) Repository، می‌توانید در زمان تست واحد (Unit Testing)، یک نسخه ساختگی (Mock) از Repository ایجاد کنید. این کار به شما امکان می‌دهد که منطق دامنه را به صورت مستقل از دیتابیس واقعی تست کنید.
  • انعطاف‌پذیری: اگر تصمیم به تغییر دیتابیس داشته باشید، فقط کافی است پیاده‌سازی Repository را تغییر دهید، بدون اینکه به مدل دامنه یا لایه سرویس آسیبی برسد.

مثال عملی:
فرض کنید یک Aggregate به نام Order داریم. واسط Repository آن می‌تواند به شکل زیر باشد:
public interface IOrderRepository
{
    Task<Order> GetByIdAsync(int id);
    Task AddAsync(Order order);
    Task RemoveAsync(Order order);
}

حالا، پیاده‌سازی این واسط با استفاده از EF Core می‌تواند به این شکل باشد:
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 متصل هستند؟ اگر چنین است، چقدر زمان برای تست کردن این کلاس‌ها صرف می‌کنید؟
الگوی Repository، یک لایه حیاتی برای جداسازی و حفظ ثبات در معماری نرم‌افزار شماست. این الگو به ما اجازه می‌دهد که به جای درگیر شدن با جزئیات فنی، روی طراحی یک مدل دامنه‌ قوی و غنی تمرکز کنیم.


الگوی Specification: فیلتری قدرتمند برای منطق تجاری 🔍

تا به حال در پروژه‌ای بوده‌اید که برای پیدا کردن داده‌های مورد نیازتان، مجبور باشید کوئری‌های تکراری و طولانی بنویسید؟ مثلاً برای پیدا کردن "سفارش‌های پرداخت شده در ماه گذشته" یا "مشتریان جدیدی که بیش از ۱۰۰ دلار خرید کرده‌اند". این کوئری‌ها معمولاً در چندین بخش از کد تکرار می‌شوند و هرگونه تغییر در قوانین، نیاز به به‌روزرسانی در همه‌ی نقاط دارد.
اینجاست که الگوی Specification به کمک ما می‌آید.

Specification چیست؟

الگوی Specification به ما اجازه می‌دهد که قوانین تجاری را به عنوان یک "مشخصه" یا "فیلتر" در مدل دامنه‌ی خود تعریف کنیم. به عبارت ساده، یک Specification یک شیء است که می‌داند چگونه یک معیار خاص را ارزیابی کند.
این الگو به ما امکان می‌دهد که کوئری‌ها را به صورت قابل استفاده مجدد (Reusable) و ترکیب‌پذیر (Composable) ایجاد کنیم.

چرا از الگوی Specification استفاده کنیم؟

استفاده از الگوی Specification مزایای کلیدی به همراه دارد:
  • جداسازی منطق: این الگو، منطق فیلتر کردن را از لایه‌ی Repository (مخزن) یا هر لایه‌ی دیگری که از آن استفاده می‌کند، جدا می‌کند. این به شما امکان می‌دهد تا بدون نگرانی از جزئیات پیاده‌سازی، روی قوانین تجاری تمرکز کنید.
  • خوانایی و شفافیت: با استفاده از این الگو، کوئری‌های شما بسیار خواناتر می‌شوند. به جای نوشتن کوئری‌های پیچیده با Linq یا SQL، می‌توانید از نام‌هایی با معنی استفاده کنید.
  • قابلیت ترکیب: یکی از قدرتمندترین ویژگی‌های این الگو، قابلیت ترکیب Specification ها با یکدیگر است. مثلاً می‌توانید Specification های "مشتری جدید" و "خرید بیش از ۱۰۰ دلار" را با یکدیگر ترکیب کرده و یک Specification جدید ایجاد کنید.

مثال عملی:
فرض کنید می‌خواهیم سفارش‌های پرداخت شده را پیدا کنیم.
بدون Specification:
public class OrderRepository
{
    public IEnumerable<Order> GetPaidOrders()
    {
        return _context.Orders.Where(o => o.Status == OrderStatus.Paid).ToList();
    }
}
این روش ساده است، اما اگر این منطق در چندین جا تکرار شود، به زودی با مشکل مواجه می‌شویم.

با الگوی Specification:
ابتدا یک واسط (Interface) برای Specification ایجاد می‌کنیم:
public interface ISpecification<T>
{
    Expression<Func<T, bool>> Criteria { get; }
}
حالا یک Specification برای "سفارش‌های پرداخت شده" می‌سازیم:
public class PaidOrdersSpecification : ISpecification<Order>
{
    public Expression<Func<Order, bool>> Criteria => o => o.Status == OrderStatus.Paid;
}
حالا در Repository خود، می‌توانیم از این Specification استفاده کنیم:
public class OrderRepository
{
    public IEnumerable<Order> Find(ISpecification<Order> spec)
    {
        return _context.Orders.Where(spec.Criteria).ToList();
    }
}
این روش به ما امکان می‌دهد که هر زمان که خواستیم، با استفاده از PaidOrdersSpecification، سفارش‌های پرداخت شده را پیدا کنیم، بدون اینکه منطق کوئری تکرار شود.

به این فکر کنید: آیا در پروژه‌تان، کوئری‌ها و فیلترها در چندین بخش از کد تکرار می‌شوند؟ چگونه می‌توانید با تبدیل آن‌ها به Specification های قابل استفاده مجدد، کد خود را تمیزتر و قابل نگهداری‌تر کنید؟
الگوی Specification، یک ابزار قدرتمند در DDD است که به ما کمک می‌کند تا منطق تجاری مربوط به کوئری‌ها را به صورت کپسوله‌شده و در یک مکان متمرکز نگه داریم. این یک گام مهم به سوی یک طراحی نرم‌افزاری منظم و مقیاس‌پذیر است.

نظرات

  • وحید نصیری در ۱۴۰۴/۰۶/۰۳ ۰۸:۵۲
    در ادامه مثال قبلی، حالا که Aggregate اصلی‌مان (ChargeBill) را شناختیم و با Command/Handler و Eventها مسیر را کامل کردیم، وقت آن است که سراغ دو الگوی مهم در دنیای DDD + EF-Core برویم:
    • Repository Pattern
    • Specification Pattern

    Repository: پل بین دنیای Domain و EF-Core
    اگر کمی مکث کنیم و به جریان حاصل فکر کنیم، متوجه می‌شویم که در حال حاضر BuildingContext به‌طور مستقیم در Command Handler استفاده شد. این بد نیست، اما یک اشکال ظریف دارد:
    Command Handler ما حالا EF-Core را به‌طور «مستقیم» می‌شناسد، در حالی که ما می‌خواهیم Handler فقط با زبان دامنه (Domain Language) کار کند.
    اینجاست که Repository وارد می‌شود.

    تعریف Repository برایChargeBill
    public interface IChargeBillRepository
    {
        Task<ChargeBill?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
        Task AddAsync(ChargeBill chargeBill, CancellationToken cancellationToken = default);
        Task SaveChangesAsync(CancellationToken cancellationToken = default);
    }

    پیاده‌سازی Repository با EF-Core
    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);
        }
    }

    Specification: وقتی نیاز داریم هوشمندانه‌تر Query بزنیم
    تا این‌جا Repository ما ساده است. اما اگر بخواهیم چیزی مثل:
    «تمام قبض‌های پرداخت‌نشده واحد X که مهلت پرداختشان گذشته»
    را پیدا کنیم، چه می‌کنیم؟ آیا باید هر بار شرط‌ها را تکرار کنیم؟ یا بهتر نیست یک شیء مستقل داشته باشیم که شرایط جستجو را تعریف کند؟ این همان جایی است که Specification Pattern وارد می‌شود.

    یک Specification ساده
    public interface ISpecification<T>
    {
        IQueryable<T> Apply(IQueryable<T> query);
    }

    Specification برای «قبض‌های پرداخت‌نشده و گذشته از موعد»
    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);
        }
    }

    استفاده از Specification در Repository
    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);
    }

    تا این‌جا Repository و Specification را کنار Aggregate خود قرار دادیم. اگر کمی به عقب برگردیم و به تصویر کلی نگاه کنیم:
    • Command Handler فقط با Repository کار می‌کند.
    • Repository با EF-Core سر و کار دارد.
    • Specification به ما امکان می‌دهد Queryها را «به زبان دامنه» تعریف کنیم.

    یعنی به‌جای اینکه در Handler بگوییم:
    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);
    به این ترتیب، کدی که نوشته‌ایم شفاف‌تر، تمیزتر و نزدیک‌تر به زبان دامنه شده است.
  • وحید نصیری در ۱۴۰۴/۰۶/۰۳ ۰۸:۵۹
    پیاده سازی یک Use Case واقعی: سرویس «محاسبه و اعمال جریمه تأخیر» با Specification

    در ادامه یک Use Case واقعی را بر اساس همین سیستم بررسی می‌کنیم:
    محاسبه و اعمال جریمه‌ی تأخیر در پرداخت شارژ واحدها

    تعریف مسئله
    فرض کنید مدیر ساختمان می‌خواهد یک عملیات زمان‌بندی‌شده اجرا شود (مثلاً هر شب ساعت ۱۲):
    • تمام قبض‌های پرداخت‌نشده که از تاریخ سررسید گذشته‌اند پیدا شوند.
    • برای هرکدام جریمه محاسبه شود (مثلاً ۵٪ مبلغ).
    • در دیتابیس ذخیره شود.
    • و در نهایت یک Event (LatePaymentRegistered) منتشر شود.

    پیاده‌سازی Use Case

    ۱. تعریف Command
    public record ApplyLateFeesCommand(Guid UnitId) : IRequest;

    ۲. Command Handler
    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;
        }
    }

    ۳. Domain Event برای جریمه‌ی تأخیر
    public record LatePaymentRegistered(Guid ChargeBillId, decimal PenaltyAmount, DateTime RegisteredAt) : INotification;

    در داخل Aggregate (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));
    }

    ۴. Handler برای Event
    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);
        }
    }

    جمع‌بندی Use Case
    • ما با استفاده از Specification قبض‌های گذشته از موعد را پیدا کردیم.
    • در Aggregate متد RegisterLatePayment را صدا زدیم تا جریمه اعمال و Event تولید شود.
    • با MediatR Event منتشر شد.
    • و در نهایت Handler مربوطه ایمیل اطلاع‌رسانی ارسال کرد.

    به این ترتیب، Use Case «محاسبه جریمه‌ی تأخیر» هم به زبان دامنه نزدیک است، هم در لایه‌های مختلف (Application, Domain, Infrastructure) به‌خوبی تفکیک شده.

    این نمودار دقیقاً جریان را نشان می‌دهد:
    • Scheduler یا Job شبانه، Command را ارسال می‌کند.
    • Handler از Repository و Specification استفاده می‌کند.
    • Aggregate رویداد تولید می‌کند.
    • DbContext تغییرات را ذخیره می‌کند.
    • MediatR رویداد را Publish می‌کند.
    • Handler ایمیل اجرا می‌شود.
  • سید امید موسوی در ۱۴۰۴/۰۶/۰۴ ۱۴:۵۶
    با توجه به اینکه Repository در DDD یک اصل مهم و پل ارتباطی بین دامنه و دیتابیس است، Repository ها روی UnitOfWork ایده خوبی نیستند. میشه در مورد این بحث الان نظر بدید.
    • وحید نصیری در ۱۴۰۴/۰۶/۰۵ ۰۹:۱۹
      نقاط قوت مطلب یاد شده
      • شفاف‌سازی کاربرد واحد UnitOfWork
      • نویسنده به خوبی تأکید کرده که DbContext یا Context در EF خود رفتار مشابه UnitOfWork را دارد و نیازی نیست لایه دیگری برای آن اضافه کنیم. این تحلیل به درستی ماهیت تراکنش و رفتار EF را برجسته می‌کند.
      • به خطر افتادن انسجام تراکنش‌ها
      • اگر متدهای .Save() در هر Repository جدا فراخوانی شوند، یک تراکنش واحد به چندین تراکنش تبدیل می‌شود — این می‌تواند منجر به inconsistency شود.
      • پیشنهاد جایگزین منطقی
      • پیشنهاد استفاده از الگوهای Command/Query که به وضوح مرزها را تعریف می‌کنند، یک رویکرد مدرن و کاربردی برای معماری‌های با پیچیدگی بیشتر است.

      اما: ضعف‌ها و جا برای نقد
      • عدم تفکیک بین الگوی Repository و پیاده‌سازی اشتباه آن
      • این مقاله به طور کلی استفاده از Repository بر روی Unit of Work را نقد می‌کند، اما باید تاکید بیشتری روی این موضوع می‌کرد که مشکل اصلی، پیاده‌سازی عمومی و بی‌ارزش (Generic Repository) روی یک ORM مدرن است، نه خود الگوی Repository. الگوی Repository هنوز هم در این شرایط بسیار مفید است: زمانیکه بخواهیم لایه دسترسی به داده را از یک تکنولوژی خاص (مثلاً SQL Server) جدا کنیم تا در آینده بتوان آن را با چیز دیگری (مانند یک دیتابیس NoSQL یا حتی یک سرویس خارجی) جایگزین کرد. برای ساده‌سازی تست‌های واحد (Unit Tests) با استفاده از پیاده‌سازی‌های در حافظه (In-memory). زمانیکه کوئری‌ها بسیار پیچیده هستند و کپسوله کردن آن‌ها در یک Repository خاص (و نه عمومی) به خوانایی کد کمک می‌کند.
      • عدم درنظر گرفتن تست‌پذیری (Testability)
      • همانطور که در کامنت‌ها اشاره شده، Repository به ارائه نوعی از abstraction کمک می‌کند که تست‌پذیری را بهبود می‌دهد.
      • مناسب بودن در معماری‌های DDD و بزرگ
      • در پروژه‌های بزرگ و با رویکرد Domain-Driven Design یا CQRS، الگوی Repository یکی از اصول بنیادی است و حذف آن در چنین مواردی ممکن است مناسب نباشد.
      • حذف کامل Repository ممکن است به پیچیدگی منتهی شود
      • عدم وجود یک سطح انتزاع به شکل Repository ممکن است منجر به پراکندگی کد CRUD و تکرار در لایه سرویس یا کنترلر شود؛ مگر اینکه جایگزین مناسبی مثل service generic یا الگوی دیگری در نظر گرفته شود.
      • مسأله leakage abstraction و از استفاده از IQueryable
      • یک دیدگاه مهم این است که برخی Repositoryهای generic که قابلیت‌هایی مانند .Include و IQueryable را در اختیار می‌گذارند، سبب می‌شوند انتزاع به گونه‌ای "leaky" باشد که وابستگی به ORM را عملاً حفظ می‌کند و قابلیت جابجایی را کاهش می‌دهد.

      نتیجه‌گیری:

      در نهایت، شاید بهترین راه‌حل، سبک هیبرید باشد: از Repository برای همان بخش‌هایی که واقعاً مزایایی دارد استفاده شود و در بقیه موارد، یک مدیریت تراکنش واضح و واحد (مثلاً از طریق Context در سطح درخواست) در نظر گرفته شود.
      • سید امید موسوی در ۱۴۰۴/۰۶/۰۵ ۱۰:۵۸
        با توجه به این موضوع بهتر هست مثال سرویس «محاسبه و اعمال جریمه تأخیر» رو با UnitOfWork پیاده سازی کنیم

        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 کردن تغییرات را دارد.
        این کار باعث می‌شود از inconsistency در داده‌ها جلوگیری شود.

  • سید امید موسوی در ۱۴۰۴/۰۶/۰۵ ۱۵:۴۱
    کتابخانه‌ی Ardalis.Specification یک لایه‌ی انتزاعی برای پیاده‌سازی الگوی مشخصه (Specification Pattern) در NET. است. این کتابخانه در پروژه‌هایی مانند eShopOnWeb و قالب‌های معماری تمیز (Clean Architecture) مایکروسافت استفاده شده و دارای مستندات کاملی است. برای استفاده در EF Core، بسته‌ی جداگانه‌ی Ardalis.Specification.EntityFrameworkCore منتشر شده است که امکاناتی نظیر اعمال شروط Where، مرتب‌سازی (OrderBy/OrderByDescending)، بارگذاری مشتاق مرتبط‌ها (Include و ThenInclude)، صفحه‌بندی (Skip/Take)، و سایر عملیات رایج را فراهم می‌کند. برای مثال می‌توان به صورت زیر از آن استفاده کرد:
    var spec = new CustomerByLastNameSpec("Smith");
    var customers = await _dbContext.Customers
        .WithSpecification(spec)
        .ToListAsync();   // دریافت مشتریان با نام خانوادگی برابر "Smith"
    • وحید نصیری در ۱۴۰۴/۰۶/۲۰ ۱۰:۱۴
      پیاده‌سازی الگوی Specification در .NET با Ardalis.Specification

      الگوی Specification یک الگوی طراحی است که به شما امکان می‌دهد یک قاعده کسب‌وکار را به عنوان یک شیء مستقل تعریف کنید. در زمینه دسترسی به داده‌ها، این الگو منطق پرس‌وجو (شامل فیلترها، مرتب‌سازی‌ها، بارگذاری‌های مشتاقانه (eager loading) و سایر قوانین) را از کد اصلی برنامه جدا می‌کند و آن را در یک کلاس مستقل بسته‌بندی می‌کند.
      استفاده از این الگو چندین مزیت کلیدی دارد:
      • جداسازی نگرانی‌ها (Separation of Concerns): منطق پرس‌وجو از لایه سرویس یا کنترلر جدا می‌شود. این باعث می‌شود هر بخش از کد فقط مسئول وظیفه خود باشد.
      • قابلیت استفاده مجدد (Reusability): یک کلاس Specification می‌تواند در هر جای برنامه که به آن منطق پرس‌وجوی خاص نیاز است، مجدداً استفاده شود. این از تکرار کد (DRY - Don't Repeat Yourself) جلوگیری می‌کند.
      • قابلیت تست (Testability): از آنجایی که هر Specification یک کلاس مستقل است، می‌توان منطق پرس‌وجوی آن را به صورت جداگانه و بدون نیاز به اتصال به پایگاه داده، با استفاده از تست‌های واحد (Unit Tests) آزمایش کرد.
      • خوانایی (Readability): نام‌گذاری مناسب کلاس‌های Specification به مستندسازی خودکار کد کمک می‌کند. به جای دیدن یک پرس‌وجوی طولانی LINQ، با یک نام گویا مانند PremiumCustomers2023Spec روبرو می‌شوید که بلافاصله هدف آن را مشخص می‌کند.

      کتابخانه Ardalis.Specification یکی از محبوب‌ترین پیاده‌سازی‌های این الگو در اکوسیستم .NET است که امکانات قدرتمندی را فراهم می‌آورد و به طور مداوم با نسخه‌های جدید .NET و Entity Framework Core به‌روزرسانی می‌شود. نسخه ۹ این کتابخانه به طور کامل با .NET 9 و EF Core 9 سازگار است و امکانات پیشرفته‌ای مانند Tagging برای پرس‌وجوها، کاهش سربار حافظه و پشتیبانی کامل از ویژگی‌های جدید EF Core را ارائه می‌دهد.

      یک مثال عملی: پرس‌وجو برای مشتریان خاص

      فرض کنید می‌خواهید یک پرس‌وجو برای پیدا کردن "تمام مشتریان ویژه (Premium) که در سال ۲۰۲۳ ثبت‌نام کرده‌اند" ایجاد کنید. بدون الگوی Specification، ممکن است کد شما در Repository یا سرویس به شکل زیر باشد:
      var customers = await _dbContext.Customers
          .Where(c => c.IsPremium && c.RegistrationYear == 2023)
          .Include(c => c.Orders)
          .OrderBy(c => c.Name)
          .ToListAsync();
      این کد به خودی خود بد نیست، اما اگر این منطق در چندین نقطه از برنامه نیاز باشد، مجبور به کپی و تکرار آن خواهید بود. با الگوی Specification، این منطق در یک کلاس جداگانه قرار می‌گیرد:
      public class PremiumCustomers2023Spec : Specification<Customer>
      {
          public PremiumCustomers2023Spec()
          {
              Query.Where(c => c.IsPremium && c.RegistrationYear == 2023)
                   .Include(c => c.Orders)
                   .OrderBy(c => c.Name);
          }
      }
      این کد یک قاعده مشخص را به نام PremiumCustomers2023Spec تعریف می‌کند که قابل استفاده مجدد است.

      ادغام Specification با الگوی Repository

      الگوی Repository (مخزن) یک واسط (interface) برای دسترسی به داده‌ها فراهم می‌کند و لایه دسترسی به داده را از منطق کسب‌وکار جدا می‌سازد. ترکیب این دو الگو به یک راهکار بسیار تمیز و قابل نگهداری منجر می‌شود.
      واسط Repository:
      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);
      }

      پیاده‌سازی Repository با Entity Framework Core:
      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 به همین شکل پیاده‌سازی می‌شوند.
      }
      کتابخانه Ardalis.Specification یک ارزیاب داخلی به نام SpecificationEvaluator دارد که پرس‌وجوی LINQ را بر اساس قوانین تعریف‌شده در Specification تولید می‌کند. به این ترتیب، Repository دیگر نیازی به درک منطق فیلترها ندارد و فقط با یک شیء Specification سروکار دارد.

      مثالی از دنیای واقعی: فیلتر کردن محصولات در یک API

      تصور کنید یک API دارید که به کاربران امکان می‌دهد محصولات را بر اساس نام برند، نوع، حداقل قیمت و وضعیت موجودی فیلتر کنند. با الگوی Specification، به‌جای ایجاد چندین متد برای هر ترکیب فیلتر، تنها یک Specification پویا ایجاد می‌کنید:
      کلاس 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);
          }
      }

      استفاده از آن در کنترلر (Controller):
      [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 قرار دارد و به‌راحتی قابل تغییر و نگهداری است.

      قابلیت‌های پیشرفته و ترکیب Specification‌ها

      الگوی Specification به شما اجازه می‌دهد تا Specification‌های کوچک‌تر را برای ایجاد منطق‌های پیچیده‌تر ترکیب کنید. Ardalis.Specification به صورت داخلی از این قابلیت پشتیبانی می‌کند، اما می‌توانید به صورت دستی نیز این کار را انجام دهید.
      مثال ترکیب Specifications:
      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());
      همچنین، قابلیت‌های پیشرفته‌ای مانند بارگذاری مشتاقانه (Eager Loading)، مرتب‌سازی (Sorting) و بروزرسانی پرس‌وجو (Projection) نیز به راحتی با این الگو قابل پیاده‌سازی هستند. Tagging (برچسب‌گذاری) یک ویژگی جدید در EF Core است که به شما اجازه می‌دهد نامی برای پرس‌وجوهای SQL خود تعیین کنید. این امر برای نظارت و بهینه‌سازی عملکرد (Performance Tuning) پرس‌وجوها در پایگاه داده بسیار مفید است.
      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
          }
      }

      تست‌پذیری Specification در انزوا

      یکی از بزرگ‌ترین مزایای این الگو، امکان تست منطق پرس‌وجو بدون نیاز به پایگاه داده است. این امر به لطف متد IsSatisfiedBy در کتابخانه Ardalis.Specification امکان‌پذیر است.
      // نمونه‌سازی یک شیء Specification
      var spec = new PremiumCustomers2023Spec();
      
      // تست منطق با یک شیء مشتری در حافظه
      var customerInstance = new Customer { IsPremium = true, RegistrationYear = 2023, Name = "Alice" };
      var isSatisfied = spec.IsSatisfiedBy(customerInstance); // این خط مقدار true را باز می‌گرداند.
      این قابلیت، فرآیند توسعه و اشکال‌زدایی را به شدت تسریع می‌کند و به شما اجازه می‌دهد منطق کسب‌وکار را به صورت واحد و مطمئن تست کنید.