معماری برش عمودی (Vertical Slice)
کد را بر اساس قابلیت سازمان بده، نه بر اساس لایه فنی. هر قابلیت، مثل لغو سفارش، همه کدش را در یک جا دارد. چیزهایی که با هم عوض میشوند کنار هم هستند و قابلیتها به هم وابسته نیستند.
نویسنده: bezzad
مشکل: یک تغییر کوچک، پنج پوشه
تیم فروش یک قابلیت تازه میخواهد: مشتری بتواند سفارش را لغو کند. پروژه ما بر اساس لایه پوشهبندی شده است. پس برنامهنویس باید:
- یک action به OrdersController اضافه کند. این کنترلر حالا ۱۵ action دارد.
- یک متد به OrderService و اینترفیس آن اضافه کند. این سرویس ۸۰۰ خط است.
- یک متد به OrderRepository اضافه کند.
- یک DTO جدید بسازد.
- همه را در DI ثبت کند و تست بنویسد.
حالا چند مشکل داریم:
- کد یک قابلیت پخش است. برای فهمیدن «لغو سفارش چطور کار میکند؟» باید پنج فایل را باز کنی.
- فایلهای مشترک بزرگ میشوند. هر قابلیت چیزی به OrderService اضافه میکند.
- تغییر یک قابلیت، قابلیت دیگر را میشکند. همه از یک سرویس و یک repository استفاده میکنند.
- تداخل (conflict) در Git زیاد است. دو نفر که دو قابلیت مختلف میسازند، روی یک فایل کار میکنند.
ایده: هر قابلیت، یک برش
لایهها را مثل طبقههای یک کیک ببین. ساختار لایهای کیک را افقی میبرد: همه کنترلرها یک جا، همه سرویسها یک جا. برش عمودی کیک را عمودی میبرد: هر برش یک قابلیت است و از همه طبقهها یک تکه دارد.
ایده اصلی در یک جمله: چیزهایی که با هم عوض میشوند، کنار هم باشند.
چرا این کار مفید است؟
- فهمیدن سریع. همه کد لغو سفارش در یک فایل یا یک پوشه است.
- تغییر امن. تغییر در لغو سفارش، ثبت سفارش را خراب نمیکند. چون کد مشترکی بین آنها کم است.
- کلاسهای بزرگ مشترک وجود ندارند. دیگر OrderService با ۸۰۰ خط نداریم.
- حذف آسان. قابلیتی که دیگر لازم نیست؟ پوشهاش را پاک کن.
هر برش، روش خودش
یک نکته مهم Vertical Slice این است: همه برشها لازم نیست یک شکل باشند. هر برش سادهترین روشی را انتخاب میکند که برای کار خودش کافی است.
- ثبت سفارش قانونهای زیادی دارد. پس از Aggregate سفارش در دامنه استفاده میکند، همان که در درس DDD دیدی.
- لغو سفارش یک قانون ساده دارد. فقط یک متد دامنه را صدا میزند.
- سفارشهای من فقط خواندن است. یک کوئری سبک مستقیم روی DbContext کافی است. لازم نیست از مدل دامنه عبور کند.
این همان ایده CQRS است: هر برش یا یک دستور است یا یک پرسوجو.
کد
ساختار پوشهها
Shop.Api/
Features/
Orders/
PlaceOrder.cs
CancelOrder.cs
GetMyOrders.cs
Catalog/
SearchProducts.cs
Domain/
Order.cs
Data/
ShopDbContext.cs
Program.cs
یک برش کامل: لغو سفارش
همه چیز در یک فایل است: ورودی، endpoint و منطق.
namespace Shop.Api.Features.Orders;
public static class CancelOrder
{
public sealed record Request(string Reason);
public static void Map(IEndpointRouteBuilder app) =>
app.MapPost("/orders/{id:guid}/cancel", HandleAsync);
private static async Task<IResult> HandleAsync(
Guid id, Request request, ShopDbContext db, CancellationToken ct)
{
var order = await db.Orders.FindAsync([id], ct);
if (order is null)
return TypedResults.NotFound();
order.Cancel(request.Reason); // the business rule lives in the domain
await db.SaveChangesAsync(ct);
return TypedResults.NoContent();
}
}
یک برش خواندنی: سفارشهای من
namespace Shop.Api.Features.Orders;
public static class GetMyOrders
{
public sealed record Item(Guid Id, DateTime PlacedAt, OrderStatus Status);
public static void Map(IEndpointRouteBuilder app) =>
app.MapGet("/customers/{customerId:guid}/orders", HandleAsync);
private static Task<List<Item>> HandleAsync(
Guid customerId, ShopDbContext db, CancellationToken ct) =>
db.Orders
.AsNoTracking()
.Where(o => o.CustomerId == customerId)
.Select(o => new Item(o.Id, o.PlacedAt, o.Status))
.ToListAsync(ct);
}
وصل کردن برشها
var app = builder.Build();
PlaceOrder.Map(app);
CancelOrder.Map(app);
GetMyOrders.Map(app);
app.Run();
به چند چیز دقت کن:
- هیچ OrderService یا OrderRepository مشترکی نداریم. هر برش مستقیم با DbContext کار میکند.
- قانون کسبوکار هنوز در دامنه است. متد Cancel در کلاس Order قانون «سفارش ارسالشده لغو نمیشود» را چک میکند. برش فقط هماهنگ میکند.
- کتابخانه MediatR لازم نیست. خیلی از تیمها برشها را با MediatR میسازند، ولی Minimal API به تنهایی کافی است.
تکرار و کد مشترک
اولین نگرانی تیمها این است: «کد تکرار میشود!» درست است. Vertical Slice کمی تکرار را قبول میکند، تا قابلیتها به هم گره نخورند.
- تکرار ظاهری را تحمل کن. دو کوئری که امروز شبیه هم هستند، شاید فردا به دلیلهای مختلف عوض شوند.
- قانون کسبوکار را تکرار نکن. اگر یک قانون در دو برش لازم است، جایش در دامنه است.
- کارهای فنی مشترک را یک بار بنویس. لاگ، اعتبارسنجی و مدیریت خطا را با middleware یا فیلتر endpoint انجام بده.
اشتباههای رایج
| اشتباه | چرا بد است؟ | راه درست |
|---|---|---|
| برشهایی که همدیگر را صدا میزنند | قابلیتها دوباره به هم گره میخورند. | منطق مشترک در دامنه یا یک کلاس کوچک مشترک. |
| یک پوشه Shared بزرگ برای همه چیز | همان OrderService بزرگ، فقط با اسم دیگر. | فقط چیزی مشترک شود که واقعاً همه جا یکی است. |
| قانون کسبوکار داخل برش، کپی در چند برش | قانون در یک جا عوض میشود و در جای دیگر نه. | قانون در دامنه، برش فقط هماهنگکننده. |
| همه برشها با یک قالب سنگین | برای یک کوئری ساده هم Command و Validator و Mapper. | هر برش سادهترین روش لازم. |
| برشهای خیلی بزرگ | یک برش «مدیریت سفارش» با ده کار، همان سرویس بزرگ است. | هر برش یک کار مشخص کاربر. |
چه وقت Vertical Slice؟
مناسب
- قابلیتهای زیاد و نسبتاً مستقل.
- قابلیتها نیازهای متفاوتی دارند: بعضی ساده، بعضی پر از قانون.
- تیم میخواهد قابلیت جدید را سریع و کمخطر اضافه کند.
- چند نفر همزمان روی قابلیتهای مختلف کار میکنند.
نامناسب یا با احتیاط
- تیم هنوز با مدل دامنه آشنا نیست و قانونها در برشها کپی میشوند.
- برنامه خیلی کوچک است و چند endpoint بیشتر ندارد. ساختار ساده کافی است.
- قابلیتها خیلی به هم وابسته هستند و مرز روشنی ندارند.
خلاصه در شش خط
- کد را بر اساس قابلیت پوشهبندی کن، نه بر اساس لایه فنی.
- چیزهایی که با هم عوض میشوند، کنار هم باشند.
- هر برش سادهترین روش لازم را انتخاب میکند: مدل دامنه برای قانون، کوئری مستقیم برای خواندن.
- برشها همدیگر را صدا نمیزنند. فقط دامنه و دسترسی به داده مشترک است.
- کمی تکرار ظاهری را قبول کن، ولی قانون کسبوکار را هیچ وقت تکرار نکن.
- این روش با معماری تمیز و CQRS قابل ترکیب است.