معماری تمیز (Clean Architecture)
قانونهای کسبوکار را در مرکز برنامه بگذار و جزئیات فنی مثل دیتابیس و وب را بیرون. همه وابستگیها فقط به سمت مرکز هستند. با این کار، منطق مهم برنامه بدون دیتابیس تست میشود و تغییر ابزارها به آن آسیب نمیزند.
نویسنده: bezzad
مشکل: منطق کسبوکار در همه جا
فروشگاه اینترنتی ما سه سال است کار میکند. قانون «جمع سفارش حداکثر ۲۰ میلیون تومان» کجای کد است؟ برنامهنویس جدید جستجو میکند و این را پیدا میکند:
- یک بار در کنترلر سفارش. قبل از ذخیره چک میشود.
- یک بار در یک Stored Procedure. کسی سال پیش اضافه کرده است.
- یک بار در کلاسی که با EF Core کار میکند. با یک عدد کمی متفاوت.
حالا چند مشکل داریم:
- تست قانون بدون دیتابیس و وب ممکن نیست. قانون به کنترلر و DbContext چسبیده است.
- تغییر ابزار، منطق را عوض میکند. اگر از EF Core به Dapper برویم، قانونها هم باید جابهجا شوند.
- هیچ کس جای درست را نمیداند. هر نفر قانون جدید را جایی که راحتتر است مینویسد.
معماری تمیز یک جواب ساده به این سؤال میدهد: هر چیز کجا باشد و به چه چیزی وابسته باشد؟
ایده: پیاز و قانون وابستگی
برنامه را مثل یک پیاز در چند حلقه ببین. مهمترین و پایدارترین کد در مرکز است. جزئیاتی که زیاد عوض میشوند، بیرون هستند.
یک قانون اصلی همه چیز را نگه میدارد. به آن قانون وابستگی (Dependency Rule) میگوییم:
چرا این قانون مفید است؟
- مرکز پایدار است. قانون «سقف ۲۰ میلیون» سالها تغییر نمیکند، ولی نسخه EF Core هر سال عوض میشود.
- تغییرهای بیرونی به داخل نمیرسند. وقتی دیتابیس عوض شود، فقط حلقه بیرونی عوض میشود.
- تست ساده میشود. دامنه و کاربرد را بدون دیتابیس و بدون وبسرور تست میکنی.
چهار لایه، با یک مثال
| لایه | چه چیزی داخلش است؟ | در فروشگاه ما |
|---|---|---|
| دامنه (Domain) | موجودیتها، Value Object ها و قانونهای کسبوکار | کلاس Order و قانون سقف ۲۰ میلیون |
| کاربرد (Application) | کارهایی که کاربر انجام میدهد، و اینترفیسهای مورد نیاز | ثبت سفارش، و اینترفیس IOrderRepository |
| زیرساخت (Infrastructure) | پیادهسازی اینترفیسها با ابزار واقعی | کلاس EfOrderRepository با EF Core، فرستادن ایمیل |
| ارائه (Presentation) | ورودی و خروجی برنامه | endpoint های Minimal API |
لایه دامنه همان جایی است که ایدههای درس DDD زندگی میکنند: Entity، Value Object و Aggregate. معماری تمیز میگوید این کد کجا باشد. DDD میگوید این کد چطور نوشته شود. این دو با هم خوب کار میکنند، ولی هر کدام بدون دیگری هم قابل استفاده است.
چطور دامنه به دیتابیس نیاز ندارد؟
سؤال مهم این است: لایه کاربرد باید سفارش را ذخیره کند. ولی اجازه ندارد EF Core را بشناسد. پس چطور؟
جواب همان وارونگی وابستگی در درس SOLID است:
- لایه کاربرد یک اینترفیس تعریف میکند. این اینترفیس میگوید «من به چیزی نیاز دارم که سفارش را بیاورد و ذخیره کند».
- لایه زیرساخت آن را پیادهسازی میکند. کلاس EfOrderRepository با EF Core کار میکند.
- لایه ارائه این دو را در DI به هم وصل میکند. فقط این لایه هر دو را میشناسد.
نکته جالب این است: مسیر اجرا و جهت وابستگی با هم فرق دارند. در زمان اجرا، درخواست از API به دیتابیس میرود. ولی در کد، زیرساخت به اینترفیس کاربرد وابسته است.
کد
ساختار پروژهها
هر لایه یک پروژه جدا است. ارجاعها همان قانون وابستگی را با کامپایلر اجرا میکنند:
<!-- 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();
قانونهای مهم
- دامنه هیچ ارجاعی ندارد. نه EF Core، نه ASP.NET Core، نه هیچ کتابخانه زیرساختی.
- اینترفیس مال کسی است که از آن استفاده میکند. اینترفیس مخزن در کاربرد است، نه در زیرساخت.
- قانون کسبوکار در دامنه است. نه در کنترلر، نه در Handler، نه در دیتابیس.
- فقط لایه ارائه همه را میشناسد. این لایه جایی است که همه چیز در DI به هم وصل میشود.
- مرز را با ابزار چک کن. ارجاع پروژهها بیشتر اشتباهها را میگیرد. برای قانونهای دقیقتر، تست معماری با کتابخانههایی مثل NetArchTest یا ArchUnitNET بنویس.
اشتباههای رایج
| اشتباه | چرا بد است؟ | راه درست |
|---|---|---|
| ویژگیهای EF Core روی کلاسهای دامنه | دامنه به جزئیات دیتابیس وابسته میشود. | تنظیم نگاشت با کلاسهای پیکربندی در زیرساخت. |
| قانون کسبوکار در کنترلر یا Handler | دامنه کمخون میشود و قانونها تکرار میشوند. | قانون داخل متد خود Entity. |
| اینترفیس مخزن کنار پیادهسازی، در زیرساخت | جهت وابستگی برعکس نشده است. | اینترفیس در لایه کاربرد. |
| یک Repository عمومی برای همه جدولها روی EF Core | یک لایه اضافه که امکانات EF Core را پنهان میکند. | یا مخزن مخصوص هر Aggregate، یا خود DbContext پشت یک اینترفیس. |
| DTO و Mapper بین هر دو لایه | برای هر فیلد جدید پنج فایل عوض میشود. | فقط در مرزهای واقعی، مثل ورودی و خروجی API، مدل جدا بساز. |
| چهار پروژه برای یک سرویس کوچک CRUD | ساختار سنگین، بدون منطق کسبوکاری که ارزش محافظت داشته باشد. | یک پروژه ساده، یا پوشهبندی بر اساس قابلیت (Vertical Slice). |
چه وقت معماری تمیز؟
مناسب
- منطق کسبوکار زیاد و مهم است.
- سیستم سالها زنده میماند و ابزارها در این مدت عوض میشوند.
- میخواهی قانونها را سریع و بدون دیتابیس تست کنی.
- چند تیم یا چند برنامهنویس روی یک سرویس کار میکنند.
نامناسب
- سرویس کوچک که بیشتر داده را میخواند و برمیگرداند.
- نمونه اولیه یا پروژه کوتاهمدت.
- تیم کوچک است و لایهها فقط کار را کند میکنند.
خلاصه در شش خط
- منطق کسبوکار در مرکز است. جزئیات فنی مثل دیتابیس و وب بیرون هستند.
- وابستگیها فقط به سمت مرکز هستند. دامنه هیچ چیز را نمیشناسد.
- لایه کاربرد اینترفیس تعریف میکند. زیرساخت آن را پیادهسازی میکند.
- مسیر اجرا به بیرون میرود، ولی وابستگی کد به داخل است.
- ارجاع پروژهها و تست معماری، قانون وابستگی را خودکار چک میکنند.
- برای سرویس کوچک CRUD، این ساختار سنگین است. ساده شروع کن.