عنوان:

‫مدیریت دغدغه‌های عرضی در Entity Framework Core با استفاده از Interceptorها


نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۵/۰۷ ۰۹:۵۰
آدرس: www.dntips.ir
چکیده: در توسعه سامانه‌های مبتنی بر دات‌نت (NET Framework / .NET Core.)، یکپارچه‌سازی و پیاده‌سازی متمرکز منطق‌های زیرساختی و عرضی (Cross-Cutting Concerns) از اهمیت بالایی برخوردار است. تکرار کدهایی نظیر ثبت تاریخچه تغییرات (Audit Logging)، حذف نرم (Soft Delete) و درج پیام‌های الگوی Outbox در دستورات مختلف، علاوه بر افزایش خطای انسانی و نگهداری‌پذیری پایین، منجر به آلودگی کدهای حوزه کسب‌وکار (Domain Logic) می‌شود. قابلیت Interceptor در Entity Framework Core (به‌اختصار EF Core) ابزاری قدرتمند جهت فراتابی (Interception) و دستکاری عملیات پایگاه‌داده به صورت خودکار فراهم می‌سازد. این مقاله با بررسی ساختار SaveChangesInterceptor و پیاده‌سازی متمرکز الگوی Outbox، نشان می‌دهد که چگونه می‌توان با جداسازی کامل دغدغه‌های زیرساختی، کد بدنه برنامه را ساده، امن و عاری از پیچیدگی غیرضروری نگه داشت.

مقدمه
در معماری‌های مدرن نرم‌افزار، نظیر توسعه دامنه-محور (DDD) و معماری‌های پیازی/تمیز (Clean/Onion Architecture)، اصل تفکیک دغدغه‌ها (Separation of Concerns) از اهمیت بسزایی برخوردار است. گرداننده‌های دستورات (Command Handlers) یا سرویس‌های حوزه برنامه‌کاربردی، وظیفه انجام تغییرات وضعیت (State Transitions) را بر عهده دارند.
با این حال، در غالب پروژه‌های واقعی، ذخیره‌سازی وضعیت تغییریافته نیاز به عملیات جانبی دیگری دارد:
  • حذف نرم (Soft Delete): تغییر حالت وضعیت داده به جای حذف فیزیکی آن از پایگاه‌داده.
  • ثبت ردپا (Audit Trail): ثبت زمان تغییر، شناسه کاربر ایجادکننده یا ویرایش‌کننده.
  • الگوی Outbox: ذخیره رویدادهای دامنه (Domain Events) در یک جدول واسط جهت ارسال مطمئن به پیام‌رسان‌ها (Message Brokers).
در صورتی که این فرآیندها درون تک‌تک Serviceها یا Command Handlerها بنویسید، احتمال فراموشی پیاده‌سازی آن‌ها به شدت افزایش می‌یابد. خطای ناشی از عدم درج رویداد Outbox یا عدم درج فیلد ردپا غالباً بدون تولید استثنا (Exception) رخ داده و متوجه‌شدن آن نیازمند ردیابی‌های پیگیرانه است. ابزار Interceptor در EF Core راه‌حلی متمرکز جهت حل این چالش ساختاری ارائه می‌دهد.

Interceptor چیست و چگونه عمل می‌کند؟
Interceptorها در EF Core نقش میان‌افزار (Middleware) را برای لایه دسترسی به داده (DbContext) ایفا می‌کنند. به کمک Interceptorها می‌توان پیش از اجرا یا پس از اجرای عملیات پایگاه‌داده (مانند اجرای پرس‌وجوها، باز یا بسته‌شدن اتصالات، یا فرآیند ذخیره‌سازی تغییرات)، به این فرآیندها متصل شد و رفتار آن‌ها را متوقف، بازرسی یا تغییر داد.
[ Domain Handler ] ──> context.SaveChangesAsync()
                               │
                               ▼
                   ┌───────────────────────┐
                   │   OutboxInterceptor   │ ──> (Inspect ChangeTracker & Insert Outbox Messages)
                   └───────────────────────┘
                               │
                               ▼
                   [ Execute DB Transaction ]

مقایسه Interceptor و بازنویسی (Override) متدSaveChangesAsync
در نسخه‌های قدیمی‌تر EF Core، الگوی متداول برای اعمال تغییرات خودکار، بازنویسی متد SaveChangesAsync در کلاس DbContext بود. با اینکه این روش همچنان کارآمد است، استفاده از SaveChangesInterceptor دو مزیت کلیدی دارد:
  • تک‌مسئولیتی و تفکیک (Modularization): به جای انباشتن خطوط متعدد کد در DbContext اصلی، هر دغدغه زیرساختی (Outbox، Auditing و ...) در یک کلاس کاملاً مستقل و تست‌پذیر پیاده‌سازی می‌شود.
  • قابلیت ترکیب (Composability): امکان تزریق و پیکربندی چندین Interceptor به‌صورت هم‌زمان و تعیین زنجیره اجرای آن‌ها وجود دارد.

پیاده‌سازی متمرکز الگوی Outbox
یکی از حیاتی‌ترین کاربردهای Interceptor، پیاده‌سازی خودکار الگوی Outbox است. در سیستم‌های توزیع‌شده، هنگام اعمال یک تغییر در پایگاه‌داده، رویداد مربوطه باید به سایر سرویس‌ها اطلاع‌رسانی شود. برای جلوگیری از ناهماهنگی داده‌ها در صورت بروز ناپایداری‌های شبکه، رویداد دامنه در همان تراکنش پایگاه‌داده، درون جدولی به نام Outbox ثبت می‌شود تا بعداً توسط یک پردازنده پس‌زمینه (Background Processor) خوانده و ارسال گردد.

رویکرد سنتی (دستی) و مخاطرات آن
بدون استفاده از Interceptor، کد Handler باید ساخت رویداد و ذخیره‌سازی پیام Outbox را صریحاً مدیریت کند:
public async Task Handle(DeleteUserCommand command, CancellationToken ct)
{
    var user = await _context.Users.FindAsync(command.Id, ct);
    user.Delete();

    var domainEvent = new UserDeletedEvent(user.Id);
    var outboxMessage = new OutboxMessage
    {
        Id = Guid.NewGuid(),
        Type = domainEvent.GetType().AssemblyQualifiedName!,
        Payload = JsonSerializer.Serialize(domainEvent, domainEvent.GetType()),
        CreatedAt = DateTime.UtcNow
    };

    _context.Set<OutboxMessage>().Add(outboxMessage);
    await _context.SaveChangesAsync(ct);
}
اشکال عمده: چنانچه برنامه‌نویس خط مربوط به افزودن OutboxMessage را فراموش کند، هیچ خطایی صادر نمی‌شود اما سایر سیستم‌ها از حذف این کاربر مطلع نخواهند شد و داده‌ها دچار عدم همگام‌سازی (Data Drift) می‌شوند.

پیاده‌سازی با Interceptor
برای اتوماسیون این فرآیند، ابتدا یک ساختار پایه برای موجودیت‌ها (Entities) جهت مدیریت رویدادهای دامنه تعریف می‌کنیم:
public interface IDomainEvent { }

public abstract class Entity
{
    private readonly List<IDomainEvent> _domainEvents = new();
    
    public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();

    protected void Raise(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent);

    public void ClearDomainEvents() => _domainEvents.Clear();
}
سپس کلاس OutboxInterceptor را پیاده‌سازی می‌کنیم. این کلاس پیش از ثبت نهایی تغییرات در پایگاه‌داده، موجودیت‌های تغییریافته را از طریق ChangeTracker بازرسی کرده، رویدادها را استخراج، به OutboxMessage تبدیل و به Context اضافه می‌کند:
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using System.Text.Json;

public class OutboxInterceptor : SaveChangesInterceptor
{
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        if (eventData.Context is null)
            return base.SavingChangesAsync(eventData, result, cancellationToken);

        var context = eventData.Context;

        // ۱. استخراج تمامی رویدادهای موجودیت‌های متصل به ChangeTracker
        var domainEvents = context.ChangeTracker
            .Entries<Entity>()
            .Select(entry => entry.Entity)
            .SelectMany(entity =>
            {
                var events = entity.DomainEvents.ToList();
                entity.ClearDomainEvents(); // پاک‌سازی جهت جلوگیری از پردازش تکراری
                return events;
            })
            .ToList();

        // ۲. تبدیل رویدادها به پیام‌های Outbox
        var outboxMessages = domainEvents.Select(domainEvent => new OutboxMessage
        {
            Id = Guid.NewGuid(),
            Type = domainEvent.GetType().AssemblyQualifiedName!,
            Payload = JsonSerializer.Serialize(domainEvent, domainEvent.GetType()),
            CreatedAt = DateTime.UtcNow
        }).ToList();

        // ۳. افزودن پیام‌ها به پایگاه‌داده در همان تراکنش
        if (outboxMessages.Count > 0)
        {
            context.Set<OutboxMessage>().AddRange(outboxMessages);
        }

        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }
}

ساده‌سازی کد Handler
با ثبت این Interceptor در تزریق وابستگی‌ها (Dependency Injection)، کد Command Handler به ساده‌ترین شکل ممکن تقلیل می‌یابد:
public async Task Handle(DeleteUserCommand command, CancellationToken ct)
{
    var user = await _context.Users.FindAsync(command.Id, ct);
    user.Delete(); // متد Delete درون خود یک UserDeletedEvent ثبت می‌کند
    
    await _context.SaveChangesAsync(ct);
}

مرزبندی کاربرد: چه زمانی نباید از Interceptor استفاده کرد؟
با وجود قدرت بالای Interceptorها، نباید منطق کسب‌وکار (Business Logic) را وارد آن‌ها کرد. Interceptorها صرفاً برای دغدغه‌های عرضی و زیرساختی طراحی شده‌اند.

مواردی که باید در Interceptor پیاده‌سازی شوندمواردی که نباید در Interceptor پیاده‌سازی شوند
ثبت خودکار Audit Trail (مانند CreatedAt, ModifiedBy)اعتبارسنجی منطق کسب‌وکار (مانند: "عنوان مقاله نباید خالی باشد")
درج خودکار پیام‌های Outboxقواعد دامنه (مانند: "ارسال ایمیل خوش‌آمدگویی پس از ثبت‌نام")
اعمال فیلتر حذف نرم (Soft Delete)رفتارهای مشروط بر اساس سناریوهای خاص کاربر
قاعده طلایی:
اگر مجبور هستید عملکرد یا علت اجرای یک کد درون Interceptor را برای مدیر محصول (Product Manager) توضیح دهید، آن کد متعلق به Interceptor نیست و باید درون Domain / Application Handler قرار گیرد.

نکات تکمیلی
جهت ارتقاء سطح کیفی پیاده‌سازی در محیط‌های واقعی (Production)، رعایت موارد زیر توصیه می‌شود:
نحوه ثبت در Dependency Injection:
  • هنگام ثبت Interceptor در DI Container، اگر Interceptor شما نیازمند وابستگی‌هایی با طول عمر Scoped است (مانند دریافت اطلاعات کاربر جاری از IHttpContextAccessor)، باید Interceptor نیز به‌صورت Scoped ثبت شده و یا از طریق IServiceProvider به Context اضافه شود:
services.AddScoped<OutboxInterceptor>();
services.AddDbContext<ApplicationDbContext>((sp, options) =>
{
    var interceptor = sp.GetRequiredService<OutboxInterceptor>();
    options.UseSqlServer(connectionString)
           .AddInterceptors(interceptor);
});
مدیریت همزمانی (Concurrency) و ترتیب اجرا:
  • در صورت استفاده از چند Interceptor همزمان (مثلاً یکی برای Audit Logging و دیگری برای Outbox)، ترتیب ثبت آن‌ها در AddInterceptors اهمیت دارد. فرآیندهایی که موجودیت‌ها را تغییر می‌دهند (مانند Soft Delete) باید قبل از Interceptor ثبت Outbox اجرا شوند تا رویدادهای تولیدشده احتمالی نیز توسط Outbox دریافت گردند.
ارسال پیام‌ها (Dispatching):
  • پس از ثبت پیام‌ها در جدول Outbox توسط Interceptor، ابزارهایی نظیر Wolverine یا MassTransit یا کارهای پس‌زمینه (IHostedService) می‌توانند پیام‌ها را خوانده و با تضمین حداقل یک‌بار تحویل (At-least-once delivery) پردازش کنند.

نتیجه‌گیری
استفاده از SaveChangesInterceptor در EF Core رویکردی مدرن، تمیز و مطمئن جهت مدیریت دغدغه‌های عرضی زیرساخت ارائه می‌دهد. این ابزار با خارج‌کردن کدهای تکراری از گرداننده‌ها و متمرکزساختن آن‌ها در لایه زیرساخت، احتمال بروز خطاهای خاموش را به صفر رسانده و خوانایی، تست‌پذیری و نگهداری‌پذیری سیستم را به شکل چشمگیری افزایش می‌دهد.