مقالات › معماری نرم‌افزار

معماری Clean Architecture در ASP.NET Core

پروژه‌های بزرگ ASP.NET Core را با Clean Architecture ساختار دهید: لایه‌های Domain، Application، Infrastructure و API، قانون وابستگی، Use Caseها، تست‌پذیری و اینکه کِی زیادی است.

وقتی پروژه بزرگ می‌شود، اولین چیزی که آسیب می‌بیند نظم کد است: کنترلرهایی پر از منطق بیزینس، سرویس‌هایی که مستقیم با دیتابیس حرف می‌زنند، موجودیت‌هایی که همه‌جا پخش شده‌اند و تغییری کوچک در یک جا که چیزی نامرتبط را خراب می‌کند. Clean Architecture روشی برای سازمان‌دهی کد است تا قوانین بیزینس مستقل، تست‌پذیر و قابل تغییر بمانند؛ فارغ از اینکه چه دیتابیس، رابط کاربری یا فریم‌ورکی دورشان باشد.

ایده‌ی اصلی: قانون وابستگی

دایره‌های هم‌مرکز را تصور کنید. درونی‌ترین دایره قوانین بیزینس را نگه می‌دارد و دایره‌های بیرونی جزئیات فنی را (دیتابیس، فریم‌ورک وب، سرویس‌های خارجی).

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

Domain به EF Core ارجاع نمی‌دهد. لایه‌ی Application به ASP.NET Core ارجاع نمی‌دهد. جزئیات به سیاست‌ها وابسته‌اند، هرگز برعکس.

چهار لایه

۱. Domain (مرکز)

موجودیت‌ها، Value Objectها، Domain Eventها، Enumها و قوانین بیزینس. هیچ وابستگی به فریم‌ورک یا پروژه‌های دیگر ندارد.

public class Order
{
    private readonly List<OrderItem> _items = [];
    public int Id { get; private set; }
    public int CustomerId { get; private set; }
    public OrderStatus Status { get; private set; } = OrderStatus.Draft;
    public IReadOnlyList<OrderItem> Items => _items;
    public decimal Total => _items.Sum(i => i.Quantity * i.UnitPrice);

    public void AddItem(int productId, int quantity, decimal unitPrice)
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException("Items can only be added to draft orders.");
        if (quantity <= 0)
            throw new DomainException("Quantity must be positive.");

        _items.Add(new OrderItem(productId, quantity, unitPrice));
    }

    public void Submit()
    {
        if (_items.Count == 0) throw new DomainException("Cannot submit an empty order.");
        Status = OrderStatus.Submitted;
    }
}

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

۲. Application (Use Caseها)

دامنه را برای انجام Use Caseها هماهنگ می‌کند: «ثبت سفارش»، «انتشار مقاله». برای چیزهایی که از دنیای بیرون لازم دارد — ذخیره‌سازی، ایمیل، ساعت — اینترفیس تعریف می‌کند اما پیاده‌سازی‌شان نمی‌کند.

public interface IOrderRepository
{
    Task<Order?> GetAsync(int id, CancellationToken ct);
    void Add(Order order);
}

public interface IUnitOfWork { Task SaveChangesAsync(CancellationToken ct); }
public interface IEmailSender { Task SendAsync(string to, string subject, string body, CancellationToken ct); }

public record SubmitOrderCommand(int OrderId);

public class SubmitOrderHandler(IOrderRepository orders, IUnitOfWork uow, IEmailSender email)
{
    public async Task<Result> HandleAsync(SubmitOrderCommand cmd, CancellationToken ct)
    {
        var order = await orders.GetAsync(cmd.OrderId, ct);
        if (order is null) return Result.Failure("Order not found.");

        order.Submit();
        await uow.SaveChangesAsync(ct);
        await email.SendAsync("sales@shop.ir", "New order", $"Order #{order.Id} submitted.", ct);
        return Result.Success();
    }
}

این همان اصل وارونگی وابستگی در مقیاس معماری است: لایه‌ی Application مالک انتزاع‌هاست.

۳. Infrastructure (جزئیات)

اینترفیس‌های Application را با تکنولوژی واقعی پیاده می‌کند: DbContext در EF Core، Repositoryها، کلاینت SMTP یا پیامک، ذخیره‌ی فایل و APIهای خارجی.

public class OrderRepository(AppDbContext db) : IOrderRepository
{
    public Task<Order?> GetAsync(int id, CancellationToken ct) =>
        db.Orders.Include(o => o.Items).FirstOrDefaultAsync(o => o.Id == id, ct);

    public void Add(Order order) => db.Orders.Add(order);
}

۴. Presentation / API (نقطه‌ی ورود)

کنترلرهای ASP.NET Core یا Minimal APIها. HTTP را به Use Case و نتیجه را دوباره به HTTP ترجمه می‌کنند؛ نه بیشتر.

[HttpPost("{id:int}/submit")]
public async Task<IActionResult> Submit(int id, SubmitOrderHandler handler, CancellationToken ct)
{
    var result = await handler.HandleAsync(new SubmitOrderCommand(id), ct);
    return result.IsSuccess ? NoContent() : BadRequest(result.Error);
}

پروژه‌ی API همچنین Composition Root است: همه‌چیز را در Program.cs به هم وصل می‌کند.

ساختار Solution

src/
  Shop.Domain/            → بدون ارجاع به پروژه‌ی دیگر
  Shop.Application/       → ارجاع به Domain
  Shop.Infrastructure/    → ارجاع به Application (و Domain)
  Shop.Api/               → ارجاع به Application و Infrastructure
tests/
  Shop.Domain.Tests/
  Shop.Application.Tests/
  Shop.Api.IntegrationTests/

ارجاع‌های پروژه قانون وابستگی را در زمان کامپایل تضمین می‌کنند: اگر کسی بخواهد داخل Shop.Domain از EF Core استفاده کند، Build شکست می‌خورد.

// Shop.Api/Program.cs
builder.Services
    .AddApplication()                         // Handlerها، Validatorها
    .AddInfrastructure(builder.Configuration); // DbContext، Repositoryها، ایمیل

چرا ارزشش را دارد

  1. تست‌پذیری — قوانین دامنه و Use Caseها بدون دیتابیس یا وب‌سرور و با نسخه‌های جعلی اینترفیس‌ها در چند میلی‌ثانیه تست می‌شوند.
  2. جزئیات قابل تعویض — مهاجرت از SQL Server به PostgreSQL یا از SMTP به یک API ایمیل فقط Infrastructure را درگیر می‌کند.
  3. مرزهای روشن — هر توسعه‌دهنده می‌داند کد جدید کجا باید برود.
  4. منطق بیزینس قابل دیدن — هسته‌ی سیستم زیر کنترلرها و SQL دفن نمی‌شود.

تست یک Use Case

[Fact]
public async Task Submitting_empty_order_fails()
{
    var order = new Order(customerId: 7);
    var repo = new FakeOrderRepository(order);
    var handler = new SubmitOrderHandler(repo, new FakeUnitOfWork(), new FakeEmailSender());

    await Assert.ThrowsAsync<DomainException>(() =>
        handler.HandleAsync(new SubmitOrderCommand(order.Id), default));
}

نسخه‌های رایج

  • CQRS — جدا کردن Commandها (نوشتن، از طریق مدل دامنه) از Queryها (خواندن، اغلب با Projection مستقیم EF Core یا Dapper برای سرعت).
  • MediatR / Pipeline Behavior — ارسال Command و Query از طریق یک Mediator و افزودن اعتبارسنجی، لاگ و تراکنش به‌عنوان رفتارهای سراسری.
  • Vertical Slice — سازمان‌دهی بر اساس قابلیت (Features/Orders/Submit) داخل لایه‌ی Application، به‌جای نوع فنی.

اشتباهات رایج

  1. دامنه‌ی کم‌خون (Anemic) — موجودیت‌هایی فقط با Getter/Setter و همه‌ی منطق در سرویس‌ها. قوانین ثابت را داخل دامنه بگذارید.
  2. نشت زیرساخت — DbContext یا IQueryable در قراردادهای عمومی لایه‌ی Application.
  3. تکرار بی‌دلیل مدل‌ها — لازم نیست هر لایه کپی خودش از هر کلاس را داشته باشد؛ برای خیلی از پروژه‌ها DTO در مرز API کافی است.
  4. مهندسی بیش از حد برای برنامه‌های کوچک — چهار پروژه، MediatR، Specification و Mapper برای یک برنامه‌ی CRUD با پنج جدول.

کِی Clean Architecture زیادی است؟

برای یک API کوچک با چند Endpoint، یک پروژه‌ی واحد با پوشه‌بندی (Endpoints، Data، Services) ساده‌تر و کاملاً قابل نگهداری است. قانون سرانگشتی من: وقتی پروژه بیش از چند ماه عمر می‌کند، قوانین بیزینس واقعی دارد و بیش از یک توسعه‌دهنده روی آن کار می‌کند، سرمایه‌گذاری روی Clean Architecture جواب می‌دهد.

جمع‌بندی

Clean Architecture قوانین بیزینس را در مرکز قرار می‌دهد و فریم‌ورک‌ها، دیتابیس‌ها و سرویس‌های خارجی را به لبه‌ها می‌برد و همه‌ی وابستگی‌ها به سمت داخل اشاره می‌کنند. Domain قوانین را نگه می‌دارد، Application از طریق اینترفیس‌هایی که مالکشان است Use Caseها را هماهنگ می‌کند، Infrastructure آن اینترفیس‌ها را پیاده می‌کند و API ترجمه‌ی HTTP را انجام می‌دهد. نتیجه کدی تست‌پذیر، انعطاف‌پذیر و قابل پیمایش است؛ به شرطی که آن را جایی به کار ببرید که پیچیدگی‌اش را توجیه کند.

پرسش‌های متداول

آیا Clean Architecture همان معماری پیازی (Onion) یا Hexagonal است؟

ایده‌ی اصلی مشترک است: منطق بیزینس در مرکز، وابستگی‌ها رو به داخل و Port و Adapter در لبه‌ها. نام‌گذاری و تأکیدها کمی متفاوت است.

DTOها کجا قرار می‌گیرند؟

DTOهای درخواست و پاسخ API در لایه‌ی API یا Application (به‌عنوان مدل Command و Query). دامنه هرگز به DTO وابسته نیست.

آیا لایه‌ی Application می‌تواند مستقیم از EF Core استفاده کند؟

طرفداران خلوص می‌گویند نه. خیلی از تیم‌های عمل‌گرا برای Queryها یک اینترفیس IAppDbContext در Application مجاز می‌دانند. آگاهانه تصمیم بگیرید و یکدست بمانید.