جزئیات پست

الگوی طراحی Unit of Work

الگوی طراحی Unit of Work در ASP.NET Core؛ چگونه چند Repository را در یک تراکنش مدیریت کنیم؟

یکی از مشکلاتی که ممکن است هنگام استفاده از Repository Pattern در پروژه‌های ASP.NET Core با آن مواجه شویم، زمانی است که یک عملیات تجاری به چند Repository مختلف وابسته باشد.

فرض کنید یک سرویس داریم که برای انجام یک عملیات مشخص باید:

  1. در جدول Table1 یک رکورد ایجاد کند.
  2. شناسه رکورد ایجادشده را دریافت کند.
  3. از آن شناسه برای به‌روزرسانی رکوردی در Table2 استفاده کند.

در نگاه اول همه چیز ساده است. اما یک سؤال مهم وجود دارد:

اگر عملیات Repository اول با موفقیت انجام شود، اما Repository دوم با خطا مواجه شود، تکلیف تغییرات Repository اول چه می‌شود؟

اگر هر Repository از DbContext جداگانه‌ای استفاده کند و تغییرات را مستقل ذخیره کند، ممکن است بخشی از عملیات در دیتابیس ثبت شده باشد و بخش دیگر انجام نشده باشد.

اینجاست که Unit of Work می‌تواند راه‌حل مناسبی باشد.


مشکل از کجا شروع می‌شود؟

برای درک بهتر موضوع، فرض کنیم دو Repository داریم:

public interface IOrderRepository
{
    Task<Order> AddAsync(Order order);
}
public interface ICustomerRepository
{
   Task UpdateLastOrderAsync(int customerId, int orderId);
}

و سرویس ما قرار است ابتدا سفارش را ایجاد کند و سپس آخرین سفارش مشتری را به‌روزرسانی کند:

public async Task CreateOrderAsync(Order order)
{
    var createdOrder = await _orderRepository.AddAsync(order);
    await _customerRepository.UpdateLastOrderAsync(
        order.CustomerId,
       createdOrder.Id);
}

از نظر منطقی مشکلی وجود ندارد.

اما اگر Repositoryها به شکل زیر پیاده‌سازی شده باشند:

public class OrderRepository : IOrderRepository
{
    private readonly ApplicationDbContext _context;
    public OrderRepository(ApplicationDbContext context)
    {
        _context = context;
    }
    public async Task<Order> AddAsync(Order order)
    {
       _context.Orders.Add(order);
        await _context.SaveChangesAsync();
        return order;
    }
}

و:

public class CustomerRepository : ICustomerRepository
{
    private readonly ApplicationDbContext _context;
    public CustomerRepository(ApplicationDbContext context)
    {
        _context = context;
    }
    public async Task UpdateLastOrderAsync(int customerId, int orderId)
    {
        // Update customer...
        await _context.SaveChangesAsync();
    }
}

در این حالت، هر Repository ممکن است تغییرات خودش را جداگانه ذخیره کند.

فرض کنید این اتفاق رخ دهد:

OrderRepository
      │
      ├── Create Order
      │
      └── SaveChanges ✓
     
CustomerRepository
      │
      ├── Update Customer
      │
      └── Error ✗

حالا سفارش در دیتابیس ایجاد شده، اما اطلاعات مشتری به‌روزرسانی نشده است.

یعنی عملیات ما نیمه‌کاره باقی مانده است.


چرا DbContext مهم است؟

DbContext در Entity Framework Core نقش مهمی در مدیریت تغییرات موجودیت‌ها دارد.

وقتی چند Repository از یک DbContext مشترک استفاده کنند، تغییراتی که توسط Repositoryهای مختلف ایجاد شده‌اند می‌توانند در همان Context نگهداری شوند و در نهایت با یک SaveChangesAsync ذخیره شوند.

برای مثال:

ApplicationDbContext
│
┌──────────┴──────────┐
│                     │
OrderRepository     CustomerRepository
│                     │
Add Order           Update Customer
│                     │
└──────────┬──────────┘
│
SaveChangesAsync()
│
▼
Database

در این معماری، Repositoryها دیگر مالک چرخه عمر DbContext نیستند.

آن‌ها فقط مسئول انجام عملیات مربوط به داده‌های خودشان هستند.


Unit of Work چیست؟

Unit of Work یک الگوی طراحی است که مجموعه‌ای از عملیات مرتبط را به عنوان یک واحد کاری مدیریت می‌کند.

در سناریوی ما، Unit of Work می‌تواند Repositoryهای مختلف را در اختیار سرویس قرار دهد و یک DbContext مشترک بین آن‌ها داشته باشد.

ساختار کلی می‌تواند چیزی شبیه این باشد:

Service
│
▼
IUnitOfWork
│
┌─────────────┴─────────────┐
│                          │
▼                          ▼
OrderRepository            CustomerRepository
│                          │
└─────────────┬─────────────┘
│
▼
Shared DbContext
│
▼
Database

بنابراین سرویس به جای اینکه Repositoryهای مختلف را به صورت مستقیم مدیریت کند، با Unit of Work کار می‌کند.


تعریف IUnitOfWork

یک پیاده‌سازی ساده می‌تواند به شکل زیر باشد:

public interface IUnitOfWork
{
    IOrderRepository Orders { get; }
    ICustomerRepository Customers { get; }
    Task<int> SaveChangesAsync(
       CancellationToken cancellationToken = default);
}

نکته مهم این است که SaveChangesAsync در Unit of Work قرار دارد، نه در Repositoryها.


پیاده‌سازی UnitOfWork

حالا یک کلاس برای پیاده‌سازی آن ایجاد می‌کنیم:

public class UnitOfWork : IUnitOfWork
{
   private readonly ApplicationDbContext _context;
    public IOrderRepository Orders { get; }
    public ICustomerRepository Customers { get; }
    public UnitOfWork(
       ApplicationDbContext context,
        IOrderRepository orders,
       ICustomerRepository customers)
    {
        _context = context;
        Orders = orders;
        Customers = customers;
    }
    public async Task<int> SaveChangesAsync(
       CancellationToken cancellationToken = default)
    {
        return await _context.SaveChangesAsync(cancellationToken);
    }
}

در اینجا DbContext از طریق Dependency Injection به Unit of Work تزریق شده است.

Repositoryها نیز از Dependency Injection دریافت می‌شوند.


Repositoryها چه تغییری می‌کنند؟

یکی از نکات مهم این معماری این است که Repository دیگر نباید خودش تصمیم بگیرد چه زمانی تغییرات در دیتابیس ذخیره شوند.

برای مثال:

public class OrderRepository : IOrderRepository
{
    private readonly ApplicationDbContext _context;
    public OrderRepository(ApplicationDbContext context)
    {
        _context = context;
    }
    public async Task<Order> AddAsync(Order order)
    {
        await _context.Orders.AddAsync(order);
        return order;
    }
}

دقت کنید که دیگر این خط را نداریم:

await _context.SaveChangesAsync();

Repository فقط تغییر را در DbContext ثبت می‌کند.

همین موضوع برای Repository دوم نیز برقرار است:

public class CustomerRepository : ICustomerRepository
{
    private readonly ApplicationDbContext _context;
    public CustomerRepository(ApplicationDbContext context)
    {
        _context = context;
    }
    public async Task UpdateLastOrderAsync(
        int customerId,
        int orderId)
    {
        var customer = await _context.Customers
            .FirstAsync(x => x.Id == customerId);
       customer.LastOrderId = orderId;
    }
}

اینجا نیز Repository هیچ SaveChangesAsyncای اجرا نمی‌کند.


حالا سرویس چگونه کار می‌کند؟

به جای تزریق دو Repository به سرویس، Unit of Work را تزریق می‌کنیم:

public class OrderService
{
    private readonly IUnitOfWork _unitOfWork;
    public OrderService(IUnitOfWork unitOfWork)
    {
        _unitOfWork = unitOfWork;
    }
    public async Task CreateOrderAsync(Order order)
    {
        var createdOrder =
            await _unitOfWork.Orders.AddAsync(order);
        await _unitOfWork.Customers.UpdateLastOrderAsync(
           order.CustomerId,
           createdOrder.Id);
        await _unitOfWork.SaveChangesAsync();
    }
}

حالا چه اتفاقی افتاد؟

ابتدا:

_unitOfWork.Orders.AddAsync(order);

سفارش به DbContext اضافه می‌شود.

سپس:

_unitOfWork.Customers.UpdateLastOrderAsync(...);

اطلاعات مشتری نیز در همان DbContext تغییر می‌کند.

در نهایت:

await _unitOfWork.SaveChangesAsync();

تمام تغییرات با هم به دیتابیس ارسال می‌شوند.


اما یک نکته بسیار مهم!

اینجا باید بین دو مفهوم تفاوت قائل شویم:

Shared DbContext و Database Transaction

این دو مفهوم یکی نیستند.

اینکه چند Repository از یک DbContext استفاده کنند باعث می‌شود تغییرات آن‌ها در یک Context مدیریت شوند، اما اگر بخواهیم به صورت صریح و قطعی یک Transaction داشته باشیم، باید Transaction را نیز مدیریت کنیم.

برای مثال:

await using var transaction =
    await _context.Database.BeginTransactionAsync();
try
{
    // Repository 1
    // Repository 2
    await _context.SaveChangesAsync();
    await transaction.CommitAsync();
}
catch
{
    await transaction.RollbackAsync();
    throw;
}

بنابراین اگر هدف ما تضمین واقعی All or Nothing در سطح تراکنش دیتابیس باشد، Unit of Work می‌تواند مسئول مدیریت Transaction نیز باشد.


Unit of Work با Transaction

برای مثال می‌توانیم Interface را کمی کامل‌تر کنیم:

public interface IUnitOfWork
{
    IOrderRepository Orders { get; }
    ICustomerRepository Customers { get; }
    Task<int> SaveChangesAsync(
       CancellationToken cancellationToken = default);
    Task BeginTransactionAsync(
       CancellationToken cancellationToken = default);
    Task CommitTransactionAsync(
        CancellationToken cancellationToken = default);
    Task RollbackTransactionAsync(
       CancellationToken cancellationToken = default);
}

و سپس Transaction را در پیاده‌سازی Unit of Work مدیریت کنیم.

یک پیاده‌سازی ساده:

public class UnitOfWork : IUnitOfWork
{
    private readonly ApplicationDbContext _context;
    private IDbContextTransaction? _transaction;
    public IOrderRepository Orders { get; }
    public ICustomerRepository Customers { get; }
    public UnitOfWork(
        ApplicationDbContext context,
        IOrderRepository orders,
       ICustomerRepository customers)
    {
        _context = context;
        Orders = orders;
        Customers = customers;
    }
    public async Task<int> SaveChangesAsync(
        CancellationToken cancellationToken = default)
    {
        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 is null)
            throw new InvalidOperationException(
               "Transaction has not been started.");
        await _transaction.CommitAsync(cancellationToken);
        await _transaction.DisposeAsync();
        _transaction = null;
    }
    public async Task RollbackTransactionAsync(
       CancellationToken cancellationToken = default)
    {
        if (_transaction is null)
            return;
        await _transaction.RollbackAsync(cancellationToken);
        await _transaction.DisposeAsync();
        _transaction = null;
    }
}
حالا سرویس می‌تواند عملیات را به صورت یک واحد کاری انجام دهد:
public async Task CreateOrderAsync(Order order)
{
    await _unitOfWork.BeginTransactionAsync();
    try
    {
        var createdOrder =
            await _unitOfWork.Orders.AddAsync(order);
        await _unitOfWork.Customers.UpdateLastOrderAsync(
           order.CustomerId,
           createdOrder.Id);
        await _unitOfWork.SaveChangesAsync();
        await _unitOfWork.CommitTransactionAsync();
    }
    catch
    {
        await _unitOfWork.RollbackTransactionAsync();
        throw;
    }
}

اکنون سناریو به این شکل خواهد بود:

Begin Transaction
│
▼
┌──────────────────┐
│ Create Order     │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Update Customer  │
└────────┬─────────┘
│
┌────┴────┐
│         │
Success    Error
│         │
▼         ▼
Commit    Rollback
│         │
▼         ▼
Save      Undo
All       All

اگر هر دو عملیات موفق باشند:

Create Order ✓
+
Update Customer ✓
↓
Commit ✓
↓
Changes persisted

اما اگر عملیات دوم با خطا مواجه شود:

Create Order ✓
+
Update Customer ✗
↓
Rollback
↓
No partial changes

این همان مفهوم All or Nothing است.


 

آیا Unit of Work همیشه لازم است؟

خیر.

در پروژه‌هایی که مستقیماً از Entity Framework Core استفاده می‌کنند، باید توجه داشت که خود DbContext تا حد زیادی نقش Unit of Work را ایفا می‌کند.

EF Core خودش Change Tracking و SaveChanges را در اختیار ما قرار می‌دهد.

بنابراین اضافه کردن یک Unit of Work صرفاً برای اینکه یک لایه دیگر روی DbContext قرار دهیم، همیشه ارزشمند نیست.

اما اگر معماری پروژه شما از Repository Pattern استفاده می‌کند و یک عملیات تجاری باید چند Repository را هماهنگ کند، Unit of Work می‌تواند یک abstraction مشخص برای:

  • مدیریت Repositoryها
  • هماهنگ کردن تغییرات
  • کنترل زمان SaveChanges
  • و در صورت نیاز مدیریت Transaction

فراهم کند.


یک نکته مهم درباره طراحی Repository

یکی از اشتباهات رایج این است که Repository را مسئول Transaction بدانیم.

مثلاً:

public async Task AddAsync(Order order)
{
   _context.Orders.Add(order);
    await _context.SaveChangesAsync();
}

این طراحی در سناریوهای ساده ممکن است کار کند، اما وقتی یک Use Case به چند Repository نیاز دارد، مشکل ایجاد می‌کند.

بهتر است Repository بیشتر روی دسترسی و تغییر داده‌های مربوط به خودش تمرکز کند و تصمیم درباره زمان نهایی کردن عملیات در سطح بالاتری گرفته شود.

یعنی:

Repository
    ↓
Prepare / Modify Changes
    ↓
Unit of Work
    ↓
Save / Commit

نه اینکه:

Repository 1 → Save

Repository 2 → Save

Repository 3 → Save

چون در حالت دوم کنترل یکپارچه روی کل عملیات بسیار دشوارتر می‌شود.


جمع‌بندی

مشکلی که در ابتدا داشتیم این بود که یک عملیات تجاری از چند Repository استفاده می‌کرد، اما هر Repository به شکل مستقل تغییرات خودش را مدیریت می‌کرد.

راه‌حل اصلی این بود که:

  1. DbContext مشترکی داشته باشیم.
  2. Repositoryها از همان DbContext استفاده کنند.
  3. Repositoryها خودشان SaveChangesAsync را اجرا نکنند.
  4. یک Unit of Work مسئول هماهنگ کردن Repositoryها باشد.
  5. در صورت نیاز، Transaction نیز در سطح Unit of Work مدیریت شود.
  6. در نهایت تغییرات با یک نقطه کنترل مشخص ذخیره یا Commit شوند.

در ساده‌ترین شکل:

Service
│
▼
UnitOfWork
│
├── Repository A
│
├── Repository B
│
└── Repository C
│
▼
Shared DbContext
│
▼
Save / Transaction
│
▼

Database

بنابراین Unit of Work را می‌توان به عنوان یک واحد هماهنگ‌کننده برای مجموعه‌ای از عملیات مرتبط روی داده‌ها در نظر گرفت؛ مخصوصاً زمانی که یک Use Case به چند Repository وابسته است و می‌خواهیم کنترل مشخصی روی زمان ذخیره‌سازی و در صورت نیاز Transaction داشته باشیم.

و شاید مهم‌ترین نکته این باشد:

Unit of Work قرار نیست فقط یک «ظرف برای Repositoryها» باشد؛ هدف اصلی آن هماهنگ کردن تغییرات و مشخص کردن مرز یک واحد کاری است.