Levelwise
فارسی
طراحی کد

معماری تمیز (Clean Architecture)

قانون‌های کسب‌وکار را در مرکز برنامه بگذار و جزئیات فنی مثل دیتابیس و وب را بیرون. همه وابستگی‌ها فقط به سمت مرکز هستند. با این کار، منطق مهم برنامه بدون دیتابیس تست می‌شود و تغییر ابزارها به آن آسیب نمی‌زند.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۶ دقیقهمثال ثبت سفارش در فروشگاه اینترنتیکد C# و .NET 10

نویسنده: bezzad

مشکل: منطق کسب‌وکار در همه جا

فروشگاه اینترنتی ما سه سال است کار می‌کند. قانون «جمع سفارش حداکثر ۲۰ میلیون تومان» کجای کد است؟ برنامه‌نویس جدید جستجو می‌کند و این را پیدا می‌کند:

  • یک بار در کنترلر سفارش. قبل از ذخیره چک می‌شود.
  • یک بار در یک Stored Procedure. کسی سال پیش اضافه کرده است.
  • یک بار در کلاسی که با EF Core کار می‌کند. با یک عدد کمی متفاوت.

حالا چند مشکل داریم:

  1. تست قانون بدون دیتابیس و وب ممکن نیست. قانون به کنترلر و DbContext چسبیده است.
  2. تغییر ابزار، منطق را عوض می‌کند. اگر از EF Core به Dapper برویم، قانون‌ها هم باید جابه‌جا شوند.
  3. هیچ کس جای درست را نمی‌داند. هر نفر قانون جدید را جایی که راحت‌تر است می‌نویسد.

معماری تمیز یک جواب ساده به این سؤال می‌دهد: هر چیز کجا باشد و به چه چیزی وابسته باشد؟

ایده: پیاز و قانون وابستگی

برنامه را مثل یک پیاز در چند حلقه ببین. مهم‌ترین و پایدارترین کد در مرکز است. جزئیاتی که زیاد عوض می‌شوند، بیرون هستند.

دامنهDomainکاربردApplicationزیرساختInfrastructureارائهWeb APIمرکزقانون‌های کسب‌وکاربه هیچ لایه دیگری وابسته نیستلایه کاربردکارهای کاربر، مثل ثبت سفارشاینترفیس‌ها را تعریف می‌کندحلقه بیرونیدیتابیس، ایمیل، درخواست وبجزئیاتی که عوض می‌شوندقانون وابستگیفلش‌ها فقط به داخل
فلش یعنی «به این وابسته است». هیچ فلشی از داخل به بیرون نمی‌رود.

یک قانون اصلی همه چیز را نگه می‌دارد. به آن قانون وابستگی (Dependency Rule) می‌گوییم:

قانون وابستگی: کد هر حلقه فقط می‌تواند حلقه‌های داخلی‌تر را بشناسد. دامنه هیچ چیز از کاربرد نمی‌داند. کاربرد هیچ چیز از EF Core یا ASP.NET Core نمی‌داند.

چرا این قانون مفید است؟

  1. مرکز پایدار است. قانون «سقف ۲۰ میلیون» سال‌ها تغییر نمی‌کند، ولی نسخه EF Core هر سال عوض می‌شود.
  2. تغییرهای بیرونی به داخل نمی‌رسند. وقتی دیتابیس عوض شود، فقط حلقه بیرونی عوض می‌شود.
  3. تست ساده می‌شود. دامنه و کاربرد را بدون دیتابیس و بدون وب‌سرور تست می‌کنی.

چهار لایه، با یک مثال

لایه چه چیزی داخلش است؟ در فروشگاه ما
دامنه (Domain) موجودیت‌ها، Value Object ها و قانون‌های کسب‌وکار کلاس Order و قانون سقف ۲۰ میلیون
کاربرد (Application) کارهایی که کاربر انجام می‌دهد، و اینترفیس‌های مورد نیاز ثبت سفارش، و اینترفیس IOrderRepository
زیرساخت (Infrastructure) پیاده‌سازی اینترفیس‌ها با ابزار واقعی کلاس EfOrderRepository با EF Core، فرستادن ایمیل
ارائه (Presentation) ورودی و خروجی برنامه endpoint های Minimal API

لایه دامنه همان جایی است که ایده‌های درس DDD زندگی می‌کنند: Entity، Value Object و Aggregate. معماری تمیز می‌گوید این کد کجا باشد. DDD می‌گوید این کد چطور نوشته شود. این دو با هم خوب کار می‌کنند، ولی هر کدام بدون دیگری هم قابل استفاده است.

چطور دامنه به دیتابیس نیاز ندارد؟

سؤال مهم این است: لایه کاربرد باید سفارش را ذخیره کند. ولی اجازه ندارد EF Core را بشناسد. پس چطور؟

جواب همان وارونگی وابستگی در درس SOLID است:

  1. لایه کاربرد یک اینترفیس تعریف می‌کند. این اینترفیس می‌گوید «من به چیزی نیاز دارم که سفارش را بیاورد و ذخیره کند».
  2. لایه زیرساخت آن را پیاده‌سازی می‌کند. کلاس EfOrderRepository با EF Core کار می‌کند.
  3. لایه ارائه این دو را در DI به هم وصل می‌کند. فقط این لایه هر دو را می‌شناسد.
Shop.Apiلایه ارائهShop.InfrastructureEfOrderRepositoryفقط برای ثبت سرویس‌هاShop.ApplicationIOrderRepositoryShop.Domainپیاده‌سازی می‌کندهمه فلش‌ها به سمت دامنهدامنه هیچ ارجاعی نداردکامپایلر جلوی اشتباه را می‌گیرد
پروژه زیرساخت به پروژه کاربرد ارجاع دارد، نه برعکس. اگر کسی در دامنه از EF Core استفاده کند، کد کامپایل نمی‌شود.

نکته جالب این است: مسیر اجرا و جهت وابستگی با هم فرق دارند. در زمان اجرا، درخواست از API به دیتابیس می‌رود. ولی در کد، زیرساخت به اینترفیس کاربرد وابسته است.

POST /ordersارائهPlaceOrderHandlerکاربردorder.Place()دامنهIOrderRepositoryاینترفیس در لایه کاربردEfOrderRepositoryزیرساختمسیر اجراجهت وابستگی در کد

کد

ساختار پروژه‌ها

هر لایه یک پروژه جدا است. ارجاع‌ها همان قانون وابستگی را با کامپایلر اجرا می‌کنند:

<!-- Shop.Application.csproj -->
<ItemGroup>
  <ProjectReference Include="..\Shop.Domain\Shop.Domain.csproj" />
</ItemGroup>

<!-- Shop.Infrastructure.csproj -->
<ItemGroup>
  <ProjectReference Include="..\Shop.Application\Shop.Application.csproj" />
  <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0" />
</ItemGroup>

پروژه Shop.Domain هیچ ارجاعی ندارد. نه به EF Core و نه به هیچ پروژه دیگر.

دامنه

namespace Shop.Domain;

public sealed class Order
{
    private const decimal MaxTotal = 20_000_000;
    private readonly List<OrderLine> _lines = [];

    public Guid Id { get; } = Guid.NewGuid();
    public IReadOnlyList<OrderLine> Lines => _lines;
    public decimal Total => _lines.Sum(line => line.Price * line.Quantity);

    public void AddLine(Guid productId, decimal price, int quantity)
    {
        if (Total + price * quantity > MaxTotal)
            throw new DomainException("Order total is over the limit.");

        _lines.Add(new OrderLine(productId, price, quantity));
    }
}

کاربرد

namespace Shop.Application.Orders;

public interface IOrderRepository
{
    void Add(Order order);
    Task SaveChangesAsync(CancellationToken ct);
}

public sealed record PlaceOrderCommand(IReadOnlyList<CartItem> Items);

public sealed class PlaceOrderHandler(IOrderRepository orders)
{
    public async Task<Guid> HandleAsync(PlaceOrderCommand command, CancellationToken ct)
    {
        var order = new Order();
        foreach (var item in command.Items)
            order.AddLine(item.ProductId, item.Price, item.Quantity);

        orders.Add(order);
        await orders.SaveChangesAsync(ct);
        return order.Id;
    }
}

لایه کاربرد قانونی را خودش چک نمی‌کند. فقط کار را هماهنگ می‌کند: بساز، به دامنه بسپار، ذخیره کن.

زیرساخت

namespace Shop.Infrastructure;

internal sealed class EfOrderRepository(ShopDbContext db) : IOrderRepository
{
    public void Add(Order order) => db.Orders.Add(order);

    public Task SaveChangesAsync(CancellationToken ct) => db.SaveChangesAsync(ct);
}

public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services, string connectionString)
    {
        services.AddDbContext<ShopDbContext>(options => options.UseSqlServer(connectionString));
        services.AddScoped<IOrderRepository, EfOrderRepository>();
        return services;
    }
}

کلاس EfOrderRepository به شکل internal است. هیچ پروژه دیگری نمی‌تواند مستقیم از آن استفاده کند.

ارائه

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<PlaceOrderHandler>();
builder.Services.AddInfrastructure(builder.Configuration.GetConnectionString("Shop")!);

var app = builder.Build();

app.MapPost("/orders", async (PlaceOrderCommand command, PlaceOrderHandler handler, CancellationToken ct) =>
{
    var id = await handler.HandleAsync(command, ct);
    return TypedResults.Created($"/orders/{id}", id);
});

app.Run();
یک مصالحه رایج: بعضی تیم‌ها به جای Repository، یک اینترفیس برای خود DbContext در لایه کاربرد می‌سازند که DbSet ها را نشان می‌دهد. این کار کد را کوتاه‌تر می‌کند و همه امکانات EF Core را در دسترس نگه می‌دارد. ولی لایه کاربرد به پکیج EF Core وابسته می‌شود. هر دو راه رایج هستند. مهم این است که تیم آگاهانه انتخاب کند.

قانون‌های مهم

  1. دامنه هیچ ارجاعی ندارد. نه EF Core، نه ASP.NET Core، نه هیچ کتابخانه زیرساختی.
  2. اینترفیس مال کسی است که از آن استفاده می‌کند. اینترفیس مخزن در کاربرد است، نه در زیرساخت.
  3. قانون کسب‌وکار در دامنه است. نه در کنترلر، نه در Handler، نه در دیتابیس.
  4. فقط لایه ارائه همه را می‌شناسد. این لایه جایی است که همه چیز در DI به هم وصل می‌شود.
  5. مرز را با ابزار چک کن. ارجاع پروژه‌ها بیشتر اشتباه‌ها را می‌گیرد. برای قانون‌های دقیق‌تر، تست معماری با کتابخانه‌هایی مثل NetArchTest یا ArchUnitNET بنویس.
اسم‌های دیگر، همان ایده: معماری شش‌ضلعی (Hexagonal یا Ports and Adapters)، معماری پیازی (Onion) و معماری تمیز جزئیات متفاوتی دارند. ولی ایده اصلی هر سه یکی است: منطق کسب‌وکار در مرکز، و وابستگی‌ها فقط به سمت مرکز.

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

اشتباه چرا بد است؟ راه درست
ویژگی‌های EF Core روی کلاس‌های دامنه دامنه به جزئیات دیتابیس وابسته می‌شود. تنظیم نگاشت با کلاس‌های پیکربندی در زیرساخت.
قانون کسب‌وکار در کنترلر یا Handler دامنه کم‌خون می‌شود و قانون‌ها تکرار می‌شوند. قانون داخل متد خود Entity.
اینترفیس مخزن کنار پیاده‌سازی، در زیرساخت جهت وابستگی برعکس نشده است. اینترفیس در لایه کاربرد.
یک Repository عمومی برای همه جدول‌ها روی EF Core یک لایه اضافه که امکانات EF Core را پنهان می‌کند. یا مخزن مخصوص هر Aggregate، یا خود DbContext پشت یک اینترفیس.
DTO و Mapper بین هر دو لایه برای هر فیلد جدید پنج فایل عوض می‌شود. فقط در مرزهای واقعی، مثل ورودی و خروجی API، مدل جدا بساز.
چهار پروژه برای یک سرویس کوچک CRUD ساختار سنگین، بدون منطق کسب‌وکاری که ارزش محافظت داشته باشد. یک پروژه ساده، یا پوشه‌بندی بر اساس قابلیت (Vertical Slice).

چه وقت معماری تمیز؟

مناسب

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

نامناسب

  • سرویس کوچک که بیشتر داده را می‌خواند و برمی‌گرداند.
  • نمونه اولیه یا پروژه کوتاه‌مدت.
  • تیم کوچک است و لایه‌ها فقط کار را کند می‌کنند.
یک ضعف مهم: در معماری لایه‌ای، کد یک قابلیت در چند پروژه پخش می‌شود. برای اضافه کردن «لغو سفارش» باید به چهار پروژه سر بزنی. درس Vertical Slice راهی برای کم کردن این مشکل نشان می‌دهد. این دو روش می‌توانند با هم ترکیب شوند.

خلاصه در شش خط

  1. منطق کسب‌وکار در مرکز است. جزئیات فنی مثل دیتابیس و وب بیرون هستند.
  2. وابستگی‌ها فقط به سمت مرکز هستند. دامنه هیچ چیز را نمی‌شناسد.
  3. لایه کاربرد اینترفیس تعریف می‌کند. زیرساخت آن را پیاده‌سازی می‌کند.
  4. مسیر اجرا به بیرون می‌رود، ولی وابستگی کد به داخل است.
  5. ارجاع پروژه‌ها و تست معماری، قانون وابستگی را خودکار چک می‌کنند.
  6. برای سرویس کوچک CRUD، این ساختار سنگین است. ساده شروع کن.