مدیریت وضعیت موجودیت‌ها با EF Core و پیاده‌سازی یک State Machine عملی

یکی از بخش‌های حساس در طراحی سیستم‌های تجاری، نحوه‌ی مدیریت وضعیت موجودیت‌هاست؛ جایی که هر موجودیت (مثل سفارش، تیکت، وام یا قرارداد) در طول عمر خود از چند مرحله عبور می‌کند و منطق تغییر بین این مراحل مستقیماً روی درستی بیزنس، گزارش‌گیری و تجربه‌ی کاربر تأثیر می‌گذارد. اگر این منطق در گوشه و کنار کد با چند if و switch پیاده‌سازی شود، نگهداری آن در طول زمان بسیار سخت و پرریسک خواهد شد. هدف این نوشته ارائه‌ی یک رویکرد ساخت‌یافته بر پایه‌ی EF Core و یک State Machine شی‌گرا است تا وضعیت‌ها هم به‌شکل مناسب در دیتابیس ثبت شوند و هم منطق انتقال آن‌ها به‌صورت متمرکز و قابل‌گسترش مدیریت شود.

۱. چرا مدیریت وضعیت موجودیت‌ مهم است؟

تقریباً در هر سیستم واقعی، موجودیت‌ها چرخه‌ی حیات دارند؛ سفارش، تیکت پشتیبانی، وام، قرارداد، و… همگی از چند وضعیت مشخص عبور می‌کنند. برای مثال سفارش از Pending به Processing سپس Shipped و در نهایت Delivered می‌رسد یا در میانه راه به Canceled تغییر وضعیت می‌دهد.

اگر مدیریت این وضعیت‌ها با چند if و switch پخش‌شده در کل پروژه انجام شود:

  • کد به‌سرعت شلوغ و غیرقابل نگهداری می‌شود.
  • قوانین انتقال وضعیت‌ها در چند جای مختلف تکرار می‌شوند.
  • تغییر Flow بیزنس تبدیل به عملی پرریسک و پرهزینه می‌شود.

راه‌حل تمیزتر و قابل گسترش، ترکیب مدل‌سازی وضعیت در EF Core با یک State Machine شی‌گرا است که قوانین مجاز بودن تغییر وضعیت‌ها را در یک نقطه‌ی مرکزی مدیریت کند.

۲. مدل‌سازی موجودیت و وضعیت (Status) در لایه‌ی Domain

سناریو را بر پایه‌ی موجودیتی به نام Order در نظر می‌گیریم که چند وضعیت مشخص دارد. بهترین نقطه برای تعریف وضعیت‌ها، لایه‌ی Domain است و برای وضوح بیشتر از enum استفاده می‌شود.

public enum OrderStatus
{
    Pending   = 0,
    Processing = 1,
    Shipped    = 2,
    Delivered  = 3,
    Canceled   = 4
}

public class Order
{
    public int Id { get; set; }

    public string CustomerName { get; private set; } = default!;

    public OrderStatus Status { get; private set; } = OrderStatus.Pending;

    public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;

    public DateTime? LastStatusChangedAt { get; private set; }

    public Order(string customerName)
    {
        CustomerName = customerName;
    }

    // متد دامینی برای ست‌کردن وضعیت
    public void SetStatus(OrderStatus newStatus)
    {
        Status = newStatus;
        LastStatusChangedAt = DateTime.UtcNow;
    }
}
    

با محدود کردن setter وضعیت به صورت private set و استفاده از متد دامینی SetStatus تغییر وضعیت فقط از مسیر کنترل‌شده (State Machine / Service) انجام می‌شود و از تغییر مستقیم و بی‌قانون وضعیت در سایر بخش‌ها جلوگیری می‌شود.

۳. ثبت وضعیت در دیتابیس: int یا string؟

در EF Core دو رویکرد اصلی برای ذخیره‌ی enum وجود دارد:

  • ذخیره‌سازی به صورت عددی (int)
  • ذخیره‌سازی به صورت متنی (string) با تبدیل

انتخاب بین این دو حالت به نیازهای بیزنس و حجم داده بستگی دارد. اگر دیتابیس بسیار بزرگ و حساس به Performance است، حالت عددی منطقی‌تر است. اگر روی خوانایی دیتابیس، گزارش‌گیری و عیب‌یابی دستی حساسیت بیشتری وجود دارد، ذخیره‌ی متنی وضعیت می‌تواند انتخاب بهتری باشد.

۳.۱. تنظیم حالت پیش‌فرض (ذخیره‌ی enum به صورت int)

اگر برای پراپرتی وضعیت هیچ تنظیم خاصی انجام نشود، EF Core به صورت پیش‌فرض مقدار enum را به int در دیتابیس ذخیره می‌کند:

public class ApplicationDbContext : DbContext
{
    public DbSet<Order> Orders => Set<Order>();

    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        modelBuilder.Entity<Order>(builder =>
        {
            builder.HasKey(o => o.Id);

            builder.Property(o => o.CustomerName)
                   .IsRequired()
                   .HasMaxLength(200);

            // به صورت int ذخیره می‌شود (حالت پیش‌فرض EF Core)
            builder.Property(o => o.Status)
                   .IsRequired();

            builder.Property(o => o.CreatedAt)
                   .IsRequired();

            builder.Property(o => o.LastStatusChangedAt);

            // ایندکس روی Status برای کوئری‌های پرتکرار
            builder.HasIndex(o => o.Status);
        });
    }
}
    

۳.۲. ذخیره‌ی وضعیت به صورت string با استفاده از ValueConverter

برای خوانایی بیشتر در دیتابیس و گزارش‌ها، می‌توان وضعیت را به صورت متن ذخیره کرد. این کار با استفاده از تبدیل داخلی EF Core انجام می‌شود:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    base.OnModelCreating(modelBuilder);

    modelBuilder.Entity<Order>(builder =>
    {
        builder.HasKey(o => o.Id);

        builder.Property(o => o.CustomerName)
               .IsRequired()
               .HasMaxLength(200);

        builder.Property(o => o.Status)
               .IsRequired()
               .HasConversion<string>()   // تبدیل enum به string
               .HasMaxLength(50);

        builder.Property(o => o.CreatedAt)
               .IsRequired();

        builder.Property(o => o.LastStatusChangedAt);

        builder.HasIndex(o => o.Status);
    });
}
    

در این حالت، در جدول دیتابیس به جای عدد، مقادیری مثل Pending و Processing دیده می‌شود که تحلیل و Debug را ساده‌تر می‌کند.

۴. ثبت تاریخچه‌ی وضعیت‌ها (State History)

در سیستم‌های حرفه‌ای، فقط آخرین وضعیت مهم نیست؛ لازم است بدانیم موجودیت از چه وضعیتی به چه وضعیتی، توسط چه کسی و در چه زمانی تغییر کرده است. برای این کار یک موجودیت تاریخچه تعریف می‌شود:

public class OrderStatusHistory
{
    public long Id { get; set; }

    public int OrderId { get; set; }

    public OrderStatus FromStatus { get; set; }

    public OrderStatus ToStatus { get; set; }

    public DateTime ChangedAt { get; set; } = DateTime.UtcNow;

    public string? ChangedBy { get; set; }
}
    

تنظیمات EF Core برای این موجودیت می‌تواند به شکل زیر باشد. در اینجا وضعیت‌ها هم به صورت متن ذخیره شده‌اند تا گزارش‌گیری راحت‌تر باشد:

public class ApplicationDbContext : DbContext
{
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<OrderStatusHistory> OrderStatusHistories => Set<OrderStatusHistory>();

    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        modelBuilder.Entity<Order>(builder =>
        {
            builder.HasKey(o => o.Id);

            builder.Property(o => o.CustomerName)
                   .IsRequired()
                   .HasMaxLength(200);

            builder.Property(o => o.Status)
                   .IsRequired()
                   .HasConversion<string>()
                   .HasMaxLength(50);

            builder.Property(o => o.CreatedAt)
                   .IsRequired();

            builder.Property(o => o.LastStatusChangedAt);

            builder.HasIndex(o => o.Status);
        });

        modelBuilder.Entity<OrderStatusHistory>(builder =>
        {
            builder.HasKey(h => h.Id);

            builder.Property(h => h.OrderId)
                   .IsRequired();

            builder.Property(h => h.FromStatus)
                   .IsRequired()
                   .HasConversion<string>()
                   .HasMaxLength(50);

            builder.Property(h => h.ToStatus)
                   .IsRequired()
                   .HasConversion<string>()
                   .HasMaxLength(50);

            builder.Property(h => h.ChangedAt)
                   .IsRequired();

            builder.Property(h => h.ChangedBy)
                   .HasMaxLength(100);

            builder.HasIndex(h => h.OrderId);
        });
    }
}
    

با این ساختار، هر تغییر وضعیت در جدولی جداگانه ثبت می‌شود و بعداً می‌توان روی آن گزارش، آمار، داشبورد و تحلیل ریسک یا رفتار کاربر ساخت.

۵. طراحی State Machine شی‌گرا برای OrderStatus

هدف از State Machine این است که قوانین مجاز بودن انتقال وضعیت‌ها در یک نقطه‌ی متمرکز نگه‌داری شود و سرویس‌ها برای تغییر وضعیت، به‌جای if / switch از همین State Machine استفاده کنند.

یک مدل ساده برای تعریف انتقال‌های مجاز:

public class OrderStateTransition
{
    public OrderStatus FromStatus { get; init; }
    public OrderStatus ToStatus   { get; init; }
    public string TransitionName  { get; init; } = default!;
    public string? Description    { get; init; }
}
    

و یک State Machine ساده بر پایه‌ی Dictionary:

public class OrderStateMachine
{
    private readonly Dictionary<(OrderStatus From, OrderStatus To), OrderStateTransition> _transitions;

    public OrderStateMachine()
    {
        _transitions = new()
        {
            {
                (OrderStatus.Pending, OrderStatus.Processing),
                new OrderStateTransition
                {
                    FromStatus = OrderStatus.Pending,
                    ToStatus = OrderStatus.Processing,
                    TransitionName = "ProcessOrder",
                    Description = "Order is now being processed."
                }
            },
            {
                (OrderStatus.Processing, OrderStatus.Shipped),
                new OrderStateTransition
                {
                    FromStatus = OrderStatus.Processing,
                    ToStatus = OrderStatus.Shipped,
                    TransitionName = "ShipOrder",
                    Description = "Order has been shipped."
                }
            },
            {
                (OrderStatus.Shipped, OrderStatus.Delivered),
                new OrderStateTransition
                {
                    FromStatus = OrderStatus.Shipped,
                    ToStatus = OrderStatus.Delivered,
                    TransitionName = "DeliverOrder",
                    Description = "Order has been delivered."
                }
            },
            {
                (OrderStatus.Pending, OrderStatus.Canceled),
                new OrderStateTransition
                {
                    FromStatus = OrderStatus.Pending,
                    ToStatus = OrderStatus.Canceled,
                    TransitionName = "CancelOrder",
                    Description = "Order has been canceled."
                }
            }
        };
    }

    public bool CanTransition(OrderStatus from, OrderStatus to)
        => _transitions.ContainsKey((from, to));

    public OrderStateTransition GetTransition(OrderStatus from, OrderStatus to)
    {
        if (!_transitions.TryGetValue((from, to), out var transition))
        {
            throw new InvalidOperationException($"Invalid transition from {from} to {to}");
        }

        return transition;
    }

    public IReadOnlyCollection<OrderStateTransition> GetAllowedTransitionsFrom(OrderStatus from)
    {
        return _transitions
            .Where(t => t.Key.From == from)
            .Select(t => t.Value)
            .ToList()
            .AsReadOnly();
    }
}
    

مزیت این طراحی این است که:

  • همه‌ی قوانین انتقال در یک نقطه تعریف می‌شوند.
  • افزودن وضعیت یا تغییر Flow فقط با ویرایش همین کلاس انجام می‌شود.
  • به‌راحتی می‌توان از متد GetAllowedTransitionsFrom برای نمایش دکمه‌های مجاز در UI استفاده کرد.

۶. سرویس تغییر وضعیت: EF Core + State Machine + تاریخچه

حالا همه‌ی قطعات کنار هم قرار می‌گیرند: سرویس تغییر وضعیت باید:

  • سفارش را از دیتابیس بخواند.
  • با State Machine بررسی کند آیا انتقال مجاز است یا نه.
  • وضعیت موجودیت را از طریق متد دامینی تغییر دهد.
  • رکورد تاریخچه‌ی وضعیت را ثبت کند.
  • همه‌چیز را با یک SaveChanges ذخیره کند.
public class OrderService
{
    private readonly ApplicationDbContext _context;
    private readonly OrderStateMachine _stateMachine;

    public OrderService(ApplicationDbContext context, OrderStateMachine stateMachine)
    {
        _context = context;
        _stateMachine = stateMachine;
    }

    public async Task ChangeOrderStatusAsync(
        int orderId,
        OrderStatus newStatus,
        string? changedBy = null,
        CancellationToken cancellationToken = default)
    {
        var order = await _context.Orders
            .FirstOrDefaultAsync(o => o.Id == orderId, cancellationToken);

        if (order == null)
            throw new InvalidOperationException("Order not found.");

        var currentStatus = order.Status;

        if (currentStatus == newStatus)
            return; // نیازی به تغییر نیست

        if (!_stateMachine.CanTransition(currentStatus, newStatus))
        {
            throw new InvalidOperationException(
                $"Cannot transition order #{orderId} from {currentStatus} to {newStatus}.");
        }

        var transition = _stateMachine.GetTransition(currentStatus, newStatus);

        // تغییر وضعیت از طریق متد دامینی
        order.SetStatus(newStatus);

        // ثبت تاریخچه
        var history = new OrderStatusHistory
        {
            OrderId = order.Id,
            FromStatus = currentStatus,
            ToStatus = newStatus,
            ChangedAt = DateTime.UtcNow,
            ChangedBy = changedBy
        };

        _context.OrderStatusHistories.Add(history);

        await _context.SaveChangesAsync(cancellationToken);

        // در صورت نیاز، اینجا می‌توان یک Domain Event یا Message به Bus ارسال کرد
        // e.g. OrderStatusChangedEvent
    }
}
    

یک مثال ساده از استفاده از این سرویس در لایه‌ی API:

public class ChangeOrderStatusRequest
{
    public OrderStatus NewStatus { get; set; }
}

[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    private readonly OrderService _orderService;

    public OrdersController(OrderService orderService)
    {
        _orderService = orderService;
    }

    [HttpPost("{orderId:int}/status")]
    public async Task<IActionResult> ChangeStatus(
        int orderId,
        [FromBody] ChangeOrderStatusRequest request,
        CancellationToken cancellationToken)
    {
        // در حالت واقعی، مقدار changedBy را از User.Identity.Name می‌توان گرفت
        await _orderService.ChangeOrderStatusAsync(
            orderId,
            request.NewStatus,
            changedBy: User?.Identity?.Name,
            cancellationToken: cancellationToken);

        return NoContent();
    }
}
    

۷. نکات تکمیلی و مسیر توسعه

معماری معرفی‌شده قابل‌گسترش است و می‌توان آن را در چند جهت ارتقا داد:

  • State Machine جنریک: پیاده‌سازی یک State Machine عمومی که به‌جای OrderStatus روی هر enum قابل استفاده باشد (مثلاً TicketStatus، LoanStatus، InvoiceStatus و…).
  • خواندن قوانین از دیتابیس: به‌جای تعریف ثابت در کد، می‌توان انتقال وضعیت‌ها را در جدولی مثل StateTransitions نگه داشت تا بدون Deployment جدید، Flowها را تغییر داد.
  • Domain Events و Message Bus: بعد از هر تغییر وضعیت، یک رویداد مانند OrderStatusChangedEvent تولید و روی Kafka / RabbitMQ منتشر شود تا سرویس‌های دیگر (Notification، Accounting و…) واکنش نشان دهند.
  • قوانین بیزنسی پیشرفته: اضافه کردن اعتبارسنجی‌های پیچیده‌تر مثل چک کردن پرداخت، موجودی انبار، محدودیت زمانی و… قبل از تأیید تغییر وضعیت.

۸. جمع‌بندی

مدیریت وضعیت موجودیت‌ها اگر جدی گرفته نشود، در پروژه‌های واقعی خیلی سریع به یک نقطه‌ی شکننده تبدیل می‌شود. با ترکیب مدل‌سازی درست Status در EF Core، انتخاب مناسب بین ذخیره‌سازی عددی یا متنی، ثبت تاریخچه‌ی وضعیت‌ها و یک State Machine شی‌گرا می‌توان ساختاری ایجاد کرد که:

  • خوانا و قابل‌درک برای تیم توسعه باشد.
  • به‌راحتی توسعه و تغییر یابد.
  • امکان گزارش‌گیری و تحلیل دقیق روی وضعیت‌ها را فراهم کند.
  • ریسک بروز وضعیت‌های غیرمجاز و ناسازگار را به حداقل برساند.

همین الگو را می‌توان برای سایر موجودیت‌ها مثل تیکت، وام، فرآیندهای اعتباری، تسویه‌حساب و… نیز به‌کار گرفت؛ کافی است Status و State Machine متناسب با دامنه‌ی مسئله طراحی شود.