سبکهای معماری
انتخاب بین Monolith، Modular Monolith، Microservices و معماری رویدادمحور یک معامله است، نه مسابقه. هر سبک مشکل خاصی را حل میکند و هزینه خاصی دارد. برای بیشتر تیمها، شروع با Modular Monolith و جدا کردن سرویس فقط با دلیل واقعی، انتخاب امنتری است.
نویسنده: bezzad
مشکل: «از اول ده میکروسرویس بسازیم»
یک تیم چهار نفره میخواهد یک فروشگاه اینترنتی جدید بسازد. یکی از اعضا میگوید: «از اول ده میکروسرویس جدا بسازیم: کاربر، محصول، انبار، سفارش، پرداخت، ارسال و بقیه. هر کدام با دیتابیس خودش.»
این پیشنهاد مدرن به نظر میرسد. ولی برای جواب دادن، اول باید بدانیم هر سبک معماری چه مشکلی را حل میکند و چه هزینهای دارد.
سه سبک اصلی
سبک Monolith
یک برنامه، یک deploy، یک دیتابیس. همه کد کنار هم است.
- مزیت. سادهترین حالت است. یک تراکنش دیتابیس میتواند سفارش را ثبت کند و موجودی را کم کند. یا هر دو انجام میشوند، یا هیچکدام. صدا زدن یک متد هم سریع است و از شبکه نمیگذرد.
- مشکل. اگر مرزی در کد نباشد، با گذشت زمان هر بخش به همه بخشها وابسته میشود. به این «توپ گلی بزرگ» (Big Ball of Mud) میگویند. تغییر یک جا، جای دیگری را خراب میکند.
مشکل اصلی Monolith «یک برنامه بودن» نیست. مشکل، نبودن مرز است.
سبک Modular Monolith
باز هم یک برنامه و یک deploy. ولی کد به ماژولهای جدا تقسیم شده است. هر ماژول یک بخش کسبوکار است: کاتالوگ، سفارش، انبار.
قانونهای یک ماژول:
- جدولهای خودش را دارد. مثلاً در یک schema جدا در همان دیتابیس.
- هیچ ماژول دیگری مستقیم به جدولهایش دست نمیزند. نه خواندن، نه نوشتن.
- فقط یک قرارداد عمومی دارد. بقیه کلاسهایش internal هستند.
نتیجه این است که امروز سادگی Monolith را داریم. اگر روزی یک ماژول واقعاً لازم شد جدا شود، مرزش از قبل روشن است و جدا کردنش آسان است.
سبک Microservices
هر سرویس یک برنامه جدا است. جدا deploy میشود، جدا scale میشود و دیتابیس خودش را دارد.
میکروسرویس در اصل یک مشکل سازمانی را حل میکند:
- فرض کن پنج تیم روی یک برنامه کار میکنند.
- هر تیم برای deploy باید منتظر بقیه بماند.
- یک باگ در کد یک تیم، deploy همه را متوقف میکند.
- با میکروسرویس، هر تیم سرویس خودش را مستقل میسازد و منتشر میکند.
ولی هزینه فنی آن زیاد است:
- تراکنش مشترک نداریم. ثبت سفارش و کم کردن موجودی دیگر در یک تراکنش نیست. پس پیامرسانی، Outbox و Saga لازم است.
- شبکه کند است و خطا دارد. هر صدا زدن ممکن است کند شود یا شکست بخورد. پس Retry، Timeout و Circuit Breaker لازم است.
- پیدا کردن باگ سخت است. یک درخواست از پنج سرویس میگذرد. پس tracing توزیعشده لازم است.
- کار زیرساخت چند برابر است. ده Pipeline، ده deploy، ده داشبورد.
بدترین حالت: Distributed Monolith
گاهی تیمی سرویسها را جدا میکند، ولی دیتابیس را مشترک نگه میدارد. یا سرویسها آنقدر به هم وابستهاند که هر تغییر به چند سرویس نیاز دارد.
- سرویس سفارش و انبار هر دو از یک جدول استفاده میکنند.
- تیم انبار یک ستون را عوض میکند. سرویس سفارش خراب میشود.
- پس دو سرویس باید همیشه با هم deploy شوند.
- یعنی استقلال نداریم، ولی هنوز شبکه کند و خطادار بین آنها هست.
به این Distributed Monolith میگویند. نشانهاش ساده است: اگر نمیتوانی یک سرویس را بدون بقیه deploy کنی، میکروسرویس نداری.
معماری رویدادمحور (Event-Driven)
این سبک بیشتر درباره شکل ارتباط است. میشود آن را با Modular Monolith یا Microservices ترکیب کرد.
در ارتباط همزمان (sync)، سرویس سفارش بقیه را صدا میزند و منتظر جواب میماند. در ارتباط رویدادمحور، سرویس سفارش فقط اعلام میکند «سفارش ثبت شد». هر کس به این خبر نیاز دارد، خودش آن را میخواند.
چرا زنجیره همزمان شکننده است؟
- فرض کن هر سرویس ۹۹.۹٪ وقت سالم است.
- یک درخواست از پنج سرویس پشت سر هم میگذرد.
- احتمالها در هم ضرب میشوند. پس احتمال موفقیت کل تقریباً ۹۹.۵٪ میشود.
- هرچه زنجیره بلندتر باشد، سیستم شکنندهتر است.
ولی رویداد هم هزینه دارد:
- سازگاری با تأخیر. چند ثانیه طول میکشد تا همه سرویسها از سفارش جدید باخبر شوند.
- پیام تکراری. یک رویداد ممکن است دو بار برسد. پس هر مصرفکننده باید idempotent باشد.
- جریان پنهان. کسی کل جریان را یک جا نمیبیند. فهمیدن اینکه «بعد از ثبت سفارش چه اتفاقی میافتد» سختتر است.
کد: یک ماژول در Modular Monolith
ماژول انبار فقط یک قرارداد عمومی دارد. پیادهسازی و دیتابیسش internal هستند.
// Project: Shop.Inventory.Contracts (the only thing other modules reference)
namespace Shop.Inventory.Contracts;
public sealed record ReserveItem(Guid ProductId, int Quantity);
public interface IInventoryApi
{
Task<bool> TryReserveAsync(Guid orderId, IReadOnlyList<ReserveItem> items, CancellationToken ct);
}
// Project: Shop.Inventory (internal details)
namespace Shop.Inventory;
internal sealed class InventoryDbContext(DbContextOptions<InventoryDbContext> options)
: DbContext(options)
{
public DbSet<StockItem> Stock => Set<StockItem>();
protected override void OnModelCreating(ModelBuilder b) =>
b.HasDefaultSchema("inventory"); // this module owns these tables
}
internal sealed class InventoryApi(InventoryDbContext db) : IInventoryApi
{
public async Task<bool> TryReserveAsync(
Guid orderId, IReadOnlyList<ReserveItem> items, CancellationToken ct)
{
// Business rules of the inventory live here, not in the order module.
// ...
await db.SaveChangesAsync(ct);
return true;
}
}
public static class InventoryModule
{
public static IServiceCollection AddInventoryModule(
this IServiceCollection services, string connectionString)
{
services.AddDbContext<InventoryDbContext>(o => o.UseSqlServer(connectionString));
services.AddScoped<IInventoryApi, InventoryApi>();
return services;
}
}
چند نکته:
- ماژول سفارش فقط پروژه Contracts را reference میکند. پس کامپایلر نمیگذارد به InventoryDbContext دست بزند.
- اگر روزی انبار سرویس جدا شد، فقط پیادهسازی IInventoryApi عوض میشود. مثلاً یک کلاینت HTTP یا پیام. کد ماژول سفارش ثابت میماند.
- برای اینکه کسی مرزها را نشکند، تست معماری بنویس. کتابخانههایی مثل NetArchTest این کار را آسان میکنند.
کی یک سرویس را جدا کنیم؟
فقط وقتی یک دلیل واقعی داریم:
- تیمهای مستقل. چند تیم جدا داریم که نمیخواهند برای deploy منتظر هم بمانند.
- بار متفاوت. یک بخش باید جدا scale شود. مثلاً جستجوی محصول صد برابر بقیه بار دارد.
- نیاز فنی متفاوت. یک بخش تکنولوژی دیگری لازم دارد، یا باید حتی وقتی بقیه خراباند کار کند.
- امنیت یا قانون. مثلاً بخش پرداخت باید از نظر دسترسی کاملاً جدا باشد.
مرز سرویس از مرز کسبوکار (Bounded Context در DDD) میآید، نه از جدولهای دیتابیس. «محصول» در کاتالوگ یعنی نام و عکس. در انبار یعنی تعداد و قفسه. اینها دو مدل جدا با دو مالک جدا هستند.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| میکروسرویس برای تیم کوچک و محصول جدید | تیم وقتش را صرف زیرساخت میکند، نه محصول. | شروع با Modular Monolith. |
| دیتابیس مشترک بین سرویسها | Distributed Monolith: باید همه با هم deploy شوند. | هر سرویس مالک داده خودش. |
| تقسیم بر اساس جدول یا لایه فنی | هر کار ساده به چند سرویس نیاز دارد. | تقسیم بر اساس بخش کسبوکار. |
| Monolith بدون هیچ مرزی | کد درهم که هیچ کس جرأت تغییرش را ندارد. | ماژولهای جدا با قرارداد عمومی. |
| زنجیره طولانی صدا زدن همزمان | خرابی یک سرویس همه را خراب میکند. | رویداد برای کارهایی که میتوانند صبر کنند. |
| رویداد بدون idempotency | پیام تکراری، کار را دو بار انجام میدهد. | چک تکراری بودن در هر مصرفکننده. |
کدام سبک؟
انتخاب پیشفرض
- سبک Modular Monolith برای تیم کوچک یا متوسط و محصول جدید.
- مرزها از روز اول روشن، ولی یک deploy.
- رویداد داخلی برای کارهایی که میتوانند صبر کنند.
فقط با دلیل
- سبک Microservices وقتی چند تیم مستقل یا نیاز scale و فنی واقعی داری.
- آماده بودن برای هزینهها: پیامرسانی، tracing، Pipeline های زیاد.
- جدا کردن تدریجی، یک ماژول در هر بار.
خلاصه در شش خط
- هر سبک معماری یک معامله است: مشکلی را حل میکند و هزینهای دارد.
- مشکل اصلی Monolith نبودن مرز است، نه یک برنامه بودن.
- سبک Modular Monolith سادگی یک برنامه را با مرزهای روشن ترکیب میکند.
- میکروسرویس یک مشکل سازمانی را حل میکند و هزینه فنی زیادی دارد.
- دیتابیس مشترک بین سرویسها یعنی Distributed Monolith، بدترین حالت.
- ارتباط همزمان برای جواب فوری، رویداد برای کاری که میتواند صبر کند.