Case Studies · نمونه‌پروژه‌ها متوسطIntermediate ~53 دقیقه مطالعه~44 min read

مطالعهٔ موردی: معماریِ پروژه‌های واقعیCase Studies: Real-World Project Architectures

هشت مطالعهٔ موردیِ مرجع از سیستم‌های واقعی — از پلتفرمِ داخلی و بلعِ تله‌متری تا SaaSِ چندمستأجری، تجارت الکترونیک و شبکهٔ اعتماد صفر. هر سیستم با یک عدسیِ پنج‌بخشی (مسئله، معماری، انتخاب‌ها، سخت‌ترین چالش، مصالحه‌ها) شکافته می‌شود، با تشبیه، کد و دیاگرام، تا نشان دهد چنین سیستم‌هایی چطور طراحی می‌شوند.Eight reference case studies of real-world-style systems — from an internal platform and telemetry ingestion to a multi-tenant SaaS, e-commerce, and a zero-trust network. Each is dissected through a five-part lens (problem, architecture, choices, hardest challenge, trade-offs), with analogies, code, and diagrams, to show how such systems are actually designed.


بیایید از یک عادت بد شروع کنیم که باید کنارش گذاشت. وقتی بیشترِ آدم‌ها یک سیستم را توصیف می‌کنند، فقط فهرست ابزار می‌خوانند — «Spring Boot، Kafka، Redis، Kubernetes…» — انگار دارند رسید خرید می‌خوانند. اما فهرستِ ابزار هیچ چیزی را توضیح نمی‌دهد: نمی‌گوید چرا هر جزء وجود دارد، زیرِ بار چه چیزی اول می‌شکند، یا چه چیزی را می‌شد جورِ دیگری انجام داد. این فصل رویکردِ معکوس دارد. هشت مطالعهٔ موردیِ مرجع از سیستم‌هایی به‌سبکِ دنیای واقعی را می‌کاود و برای هرکدام مسئله، معماری، تصمیم‌ها، سخت‌ترین چالش و مصالحه‌ها را می‌شکافد — تا یاد بگیری چنین سیستم‌هایی چطور طراحی می‌شوند، نه فقط اینکه اتفاقاً از چه فناوری‌هایی استفاده کرده‌اند.

نقشهٔ راه این فصل

اول یک عدسیِ پنج‌بخشی برای خواندنِ هر طراحیِ سیستمی یاد می‌گیری (مسئله → معماری → انتخاب‌ها → سخت‌ترین چالش → مصالحه‌ها). بعد در «بخش صفر» چند واژهٔ کلیدی که در هر هشت مطالعهٔ موردی تکرار می‌شوند از صفر ساخته می‌شوند (خودتوانی، همگام/ناهمگام، فشار برگشتی، سازگاری، چندمستأجری، Saga، Outbox). سپس هشت سیستم یکی‌یکی بازخوانی می‌شوند؛ برای هرکدام کد و دیاگرام و سازوکارهای فنی نگه داشته و شکافته می‌شوند، و هر پرسشِ بازبینیِ طراحی در جعبهٔ جداگانه با پاسخ کامل می‌آید. آخرِ کار، تم‌های تکرارشونده‌ای که هر هشت سیستم را به هم گره می‌زنند.

بخش صفر — واژه‌هایی که باید بلد باشی

قبل از مطالعه‌های موردی، بیا چند اصطلاح را که مثل نخ قرمز از همهٔ هشت سیستم رد می‌شوند، با تشبیه بسازیم. اگر این‌ها را حالا جا بیندازی، بقیهٔ فصل روان می‌شود.

همگام در برابر ناهمگام: تلفن در برابر پیامک

تماس همگام (synchronous) مثل تلفن‌زدن است: تا طرف مقابل جواب ندهد، تماس‌گیرنده پشت خط منتظر می‌ماند و کار دیگری نمی‌کند. اگر او خواب باشد، تماس‌گیرنده هم گیر می‌کند. تماس ناهمگام (asynchronous) مثل پیامک است: فرستنده می‌فرستد و به کارش می‌رسد؛ طرف مقابل هر وقت توانست جواب می‌دهد. در نرم‌افزار، فراخوانی همگام یعنی سرویس A منتظر پاسخ سرویس B می‌ماند (اگر B بیفتد، A هم عملاً می‌افتد)، و ناهمگام یعنی A پیامی در صف می‌گذارد و می‌رود.

از این تشبیه یک قاعدهٔ طلایی درمی‌آید که در کل فصل به‌کار می‌رود:

قاعدهٔ طلایی جفت‌شدگی

فراخوانی‌های همگام، دسترس‌پذیری (availability) را جفت می‌کنند؛ فراخوانی‌های ناهمگام، شِما (schema) را. فراخوانیِ همگام، سرنوشتِ «بالا بودنِ» یک سرویس را به بالا بودنِ آن یکی گره می‌زند. فراخوانیِ ناهمگام (با پیام)، سرویس را از قید دسترس‌پذیری رها می‌کند، اما حالا هر دو طرف باید سرِ شکلِ پیام (فیلدها، نسخهٔ رویداد) توافق داشته باشند و با تغییرش هماهنگ بمانند. تقریباً همهٔ این هشت مطالعهٔ موردی، تمرینِ انتخابِ «کدام جفت‌شدگی را بپردازیم» هستند.

خودتوانی (idempotence): دکمهٔ آسانسور

دکمهٔ آسانسور خودتوان (idempotent) است: یک‌بار بزنی یا ده‌بار، نتیجه یکی است — آسانسور یک‌بار می‌آید. یک عملیات خودتوان یعنی تکرارش هیچ اثر اضافه‌ای ندارد. چرا مهم است؟ چون در سیستم‌های توزیع‌شده پیام‌ها دوباره می‌رسند (تلاش مجدد، قطعی شبکه). اگر «کسر موجودی» خودتوان نباشد، یک پیام تکراری دو بار از انبار کم می‌کند. راهش این است که هر پیام یک شناسهٔ یکتا داشته باشد و مصرف‌کننده تکراری‌ها را بشناسد و نادیده بگیرد.

فشار برگشتی (backpressure): شیر آب و لیوان

یک شیر آب پرفشار را روی یک لیوان کوچک باز کن؛ لیوان سرریز می‌کند. فشار برگشتی یعنی مکانیزمی که به فرستنده می‌گوید یواش‌تر، چون گیرنده دارد پُر می‌شود. در نرم‌افزار وقتی تولیدکنندهٔ داده تندتر از مصرف‌کننده کار می‌کند، بدون فشار برگشتی حافظه (heap) سرریز می‌شود و برنامه می‌ترکد. راه‌حل‌ها: صف کران‌دار، انداختنِ دادهٔ کهنه، یا سیگنال «فعلاً نفرست».

سازگاری قوی در برابر نهایی: تابلوی نتایج زنده در برابر آمار رسمی

سازگاری قوی (strong consistency) مثل صندوق بانک است: لحظه‌ای که برداشت انجام می‌شود، موجودی همان لحظه و همه‌جا درست است؛ هیچ‌کس نمی‌تواند همان سکه را دو بار خرج کند. سازگاری نهایی (eventual consistency) مثل شمارندهٔ بازدید یک ویدیو است: چند ثانیه عقب می‌افتد اما بالاخره درست می‌شود، و این تأخیر مهم نیست. هنر مهندسی این است که برای هر چیز، سازگاریِ درخور را انتخاب کنی: قوی برای پول و موجودی انبار، نهایی برای جست‌وجو و آمار.

چندمستأجری (multi-tenancy): ساختمان آپارتمانی

یک ساختمان آپارتمانی، مستأجرهای زیادی دارد که زیرساخت مشترک (لوله، برق، پله) را به‌اشتراک می‌گذارند اما هرکس باید در واحد خودش امنیت و حریم داشته باشد. چندمستأجری یعنی یک نمونهٔ نرم‌افزاری به چند مشتری (tenant) سرویس می‌دهد. چالش همیشگی: چطور بدون ساختنِ یک زیرساخت جدا برای هر مستأجر، آن‌ها را از هم ایزوله کنی تا دادهٔ یکی به دیگری نشت نکند و یک مستأجر پرسروصدا بقیه را گرسنه نگذارد.

الگوی Outbox و Saga: پست‌خانه و سفر چندپروازه

Outbox را با پست‌خانه بفهم: به‌جای اینکه هم‌زمان نامه را «بنویسی» و «بیندازی در صندوق پست» (که ممکن است وسطش برق برود)، نامه را داخل یک دفترچهٔ حسابداری یادداشت می‌کنی و بعد یک پیک، مرتب سراغ دفترچه می‌رود و نامه‌ها را واقعاً پست می‌کند — پس هیچ نامه‌ای گم نمی‌شود. Saga مثل یک سفر با چند پرواز جداست: هر پرواز بلیت خودش را دارد؛ اگر پرواز سوم کنسل شد، بلیت‌های قبلی پس گرفته می‌شوند (جبران) به‌جای اینکه بخواهی کل سفر را در یک تراکنش اتمی رزرو کنی. جزئیاتشان در مطالعه‌های موردی می‌آید.

کالبدشناسیِ یک مطالعهٔ موردی

حالا عدسیِ اصلی. مطالعهٔ یک سیستم دربارهٔ فهرست‌کردنِ چیزهایی که دارد نیست — دربارهٔ فهمِ چیزهایی است که تصمیم می‌گیرد: چرا هر جزء وجود دارد، زیرِ بار چه چیزی اول می‌شکند، و چه چیزی را به‌طور منطقی می‌شد جورِ دیگری انجام داد. هر مطالعهٔ موردیِ زیر دقیقاً به این ترتیبِ پنج‌بخشی ارائه می‌شود، و همین ترتیب ارزشِ آن را دارد که روی هر سیستمی که در دنیای واقعی می‌بینی به‌کار ببری:

۱. مسئله و دامنه (scope) — یک جمله دربارهٔ مسئلهٔ کسب‌وکار، یک جمله دربارهٔ قیدهای غیرکارکردی (توان عملیاتی، تأخیر، چندمستأجری، سازگاری). قیدها همان چیزی‌اند که معماری را تحمیل می‌کنند؛ بدون قید، هر معماری‌ای «درست» است. ۲. معماری و جریان داده — جعبه‌ها و جهت داده. یال‌های همگام و ناهمگام را نام ببر. ۳. انتخاب‌های فنی کلیدی و چرایی — هرگز فقط «از Kafka استفاده می‌کند»؛ همیشه «از Kafka استفاده می‌کند چون به توزیع رویدادِ بادوام، قابل‌بازپخش و مرتب‌شده به‌ازای کلید بین تیم‌ها نیاز دارد». ۴. سخت‌ترین چالش — یک مسئلهٔ واقعی و عمیق و سازوکاری که آن را حل می‌کند. سیگنالِ واقعیِ طراحیِ سطح‌ارشد دقیقاً اینجاست. ۵. مصالحه‌ها (trade-offs) — هر انتخاب هزینه‌ای دارد. نام‌بردنِ هزینه همان چیزی است که ثابت می‌کند انتخاب واقعاً فهمیده شده.

یک جملهٔ جادویی برای هر جزء

برای هر تکه از معماری، این را آماده داشته باش: «زیرِ ۱۰ برابر بار، اول چه چیزی می‌شکند و چه چیزی جمعش می‌کند؟» طراحی‌ای که بتواند این را برای هر جعبه جواب بدهد، دیگر یک طراحیِ معمولی نیست؛ مثل کارِ یک مهندس اصلی (principal) خوانده می‌شود.


مطالعهٔ موردیِ ۱ — چارچوب پلتفرمِ داخلی و اسکافولدِ سرویس

پشته: Spring Boot ماژولار، تولیدکنندهٔ کد CLI، Maven چندماژولی (BOM + ۲۰+ ماژول)، OAuth2/OIDC، Redis، RabbitMQ، Resilience4j، Camunda.

مسئله و دامنه

تصور کن یک شرکت پیتزاسازی زنجیره‌ای که هر شعبه خودش خمیر، سُس و جعبه را از نو اختراع می‌کند — یکی شور، یکی بی‌نمک، هیچ‌کدام مثل هم. در یک سازمانِ نرم‌افزاریِ بزرگ هم دقیقاً همین اتفاق می‌افتد: هر تیم همان دغدغه‌های عرضی (cross-cutting concerns) — یعنی کارهایی که به منطق کسب‌وکار ربطی ندارند ولی همه‌جا لازم‌اند: احراز هویت، صفحه‌بندی، ممیزی، پاکت خطا، الگوی Outbox، تاب‌آوری — را در هر سرویس جداگانه و ناهمگون بازپیاده‌سازی می‌کند. هدفِ چارچوبِ پلتفرمی از این دست: یک کتابخانهٔ «همه‌چیز درون‌ساخت» به‌همراه یک تولیدکنندهٔ CLI، تا سرویس جدید در چند دقیقه به‌شکلِ محصولی و با حاکمیت (governance) درون‌تنیده شروع شود.

معماری و جریان داده

 تولیدکنندهٔ CLI ──اسکافولد──> سرویس جدید (BOM را import می‌کند)
                                   │
        ┌──────────── ماژول‌های چارچوب (starterها) ────────────┐
        │ security(OIDC) · web(پاکت خطا) · data(auditing)       │
        │ messaging(RabbitMQ+outbox) · resilience(R4j) · process(Camunda) │
        └───────────────────────────────────────────────────────┘
                                   │
   Redis (کش/نرخ‌گیری) ── RabbitMQ (رویدادها) ── Camunda (گردش‌کارها)

دو واژهٔ کلیدی این دیاگرام ارزشِ شکافتن دارند.

BOM چیست: لیست خرید هماهنگِ آشپز

BOM مخفف Bill of Materials یعنی «فهرست مواد». مثل یک دستور پختِ رسمی است که می‌گوید «دقیقاً این برند آرد نسخهٔ ۲، این سُس نسخهٔ ۵» — یک مجموعهٔ نسخهٔ هماهنگ که معلوم است با هم جور درمی‌آیند. در Maven، BOM نسخهٔ همهٔ کتابخانه‌ها را پین می‌کند؛ سرویس‌های پایین‌دستی فقط BOM را وارد می‌کنند و هرگز خودشان نسخه نمی‌نویسند.

Starter و پیکربندی خودکار: پریز برق آماده

یک starter یعنی ماژولی که با یک بار وارد شدنش، خودش را به‌طور خودکار سیم‌کشی می‌کند — مثل پریزی که همین‌که چیزی به آن بزنی روشن می‌شود، بدون اینکه لازم باشد دیوار را بشکافی. در Spring Boot این را پیکربندی خودکار (auto-configuration) می‌گویند. هر ماژولِ اینجا یک starter است که با شرط‌هایی مثل @ConditionalOnProperty (اگر این تنظیم روشن بود) یا @ConditionalOnClass (اگر این کلاس در classpath بود) فعال می‌شود. پس وارد کردنِ یک ماژول، رفتاری انتخابی است، نه اجباری.

انتخاب‌های فنی کلیدی و چرایی

  • Maven چندماژولی + BOM — BOM تنها اهرمِ حاکمیت نسخه است. سرویس‌های پایین‌دستی آن را در <dependencyManagement> وارد می‌کنند و هرگز نسخه تعیین نمی‌کنند؛ این کار، رانشِ وابستگیِ الماسی (diamond dependency drift) را از بین می‌برد. (رانشِ الماسی یعنی وقتی دو مسیرِ وابستگی به یک کتابخانه با دو نسخهٔ متفاوت می‌رسند و معلوم نیست کدام برنده می‌شود — دردِ همیشگیِ سازمان‌های بزرگ.)
  • پیکربندی خودکار به‌جای وراثت — چارچوبِ مبتنی بر یک کلاسِ پایهٔ مشترک، جفت‌شدگی تحمیل می‌کند (همه مجبورند از یک والد ارث ببرند)؛ اما starterها ترکیب می‌شوند مثل قطعات لِگو. این دقیقاً همان‌طوری است که خودِ Spring Boot ساخته شده.
  • Resilience4j به‌جای Hystrix — Hystrix در حالتِ نگه‌داری (maintenance) است، یعنی دیگر ویژگی جدید نمی‌گیرد. R4j سبک، تابعی و یکپارچه با Micrometer است، پس قطعِ مدار (circuit breaking) را مبتنی بر متریک انجام می‌دهد. (قطع مدار مثل فیوز برق است: اگر سرویسِ پایین‌دستی مدام خطا داد، مدار را «باز» می‌کند تا فراخوان‌ها موقتاً دیگر صدایش نزنند و فرصت ترمیم بدهند.)
  • Camunda — گردش‌کارهای طولانی و انسان‌در‌حلقه (human-in-the-loop) به حالتِ بادوام و جبران (compensation) نیاز دارند؛ کدنویسیِ امریِ (imperative) این‌ها غیرقابل‌نگه‌داری است.

سخت‌ترین چالش فنی

ترتیبِ پیکربندی خودکار و درستیِ شرط‌ها. یک starter باید وقتی خودِ اپ beanی را تعریف کرده، مؤدبانه عقب بکشد، و beanهای سنگین را زودهنگام نسازد. راه‌حل چند لایه دارد:

  • @ConditionalOnMissingBean روی هر beanِ صادرشده (یعنی «فقط اگر کاربر خودش نساخته، تو بساز»).
  • @AutoConfigureAfter/@AutoConfigureBefore برای مرتب‌کردنِ ترتیب: security پیش از web، web پیش از data.
  • ثبتِ starterها از طریق فایل META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports — که در Boot 3 جایگزینِ روشِ قدیمیِ spring.factories شده است.
  • یک هارنسِ تست با ApplicationContextRunner که اثبات می‌کند هر starter زیرِ ماتریسِ درستِ propertyها درست فعال یا غیرفعال می‌شود — تنها راهِ صادق نگه‌داشتنِ ۲۰ ماژول.
جادو تا وقتی کار می‌کند، جادوست

پیکربندی خودکارِ سنگین یعنی «خودش کار می‌کند»… تا روزی که نکند. آن روز، یک مهندسِ تازه‌کار نمی‌تواند بفهمد چرا یک bean اصلاً وجود دارد. مسکّن: گزارشِ پیکربندی خودکار با فلگِ --debug (که می‌گوید چه چیزی فعال شد و چرا) و مستندسازیِ کامل. اگر سیستمی جادو می‌سازد، حتماً باید چراغ‌قوهٔ دیباگش را هم بسازد.

مصالحه‌ها

  • قدرت در برابر جادو. همان‌طور که گفتیم، «خودش کار می‌کند» هزینهٔ ردیابی‌پذیری دارد.
  • رانشِ تولیدکننده (generator drift). کدِ تولیدشده پس از ویرایشِ انسانی از قالب واگرا می‌شود؛ پس بازتولید نمی‌تواند بازنویسیِ سخت (overwrite) باشد. رویکردِ درست: یک‌بار تولید می‌شود و برای تغییراتِ بعدی به ارتقای کتابخانه (بالا بردنِ نسخهٔ BOM) تکیه می‌شود، نه اسکافولدِ دوباره.
چرا BOM به‌جای parent POM؟

یک parent POM فقط یک‌بار قابل وراثت است — چون هر پروژهٔ Maven تنها یک <parent> دارد. اما BOM در dependencyManagement وارد (import) می‌شود و می‌تواند در کنارِ parentِ خودِ سازمان بنشیند. پس BOM نسخه‌ها را حاکمیت می‌کند بدون اینکه پیکربندیِ ساخت (build) را دیکته کند. خلاصه: parent = وراثتِ یگانه، BOM = ترکیب‌پذیر.

یک starter در Boot 3 چطور کشف می‌شود؟

از طریقِ فایلِ META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports. نکتهٔ مهمِ نسخه: کلیدهای auto-config که قبلاً در spring.factories بودند، در Spring Boot 3 حذف شدند و باید به این فایلِ جدید مهاجرت کنند.

چطور جلوی خراب‌کردنِ beanهای کاربر را می‌گیری؟

با @ConditionalOnMissingBean. beanهای کاربر برنده‌اند چون پیکربندی خودکار آخر از همه اجرا می‌شود؛ پس اگر کاربر خودش beanی تعریف کرده باشد، شرطِ «اگر وجود نداشت» برقرار نمی‌شود و starter عقب می‌کشد.

Camunda کِی ابزارِ اشتباه است؟

برای جریان‌های کوتاه، بدون‌حالت و پرتوانِ درخواست/پاسخ. Camunda برای هر تسک حالت را در دیتابیس پایدار می‌کند و رفت‌وبرگشت به DB دارد؛ این سربار آن را از یک ماشینِ حالتِ سادهٔ درون‌حافظه بسیار سنگین‌تر می‌کند. Camunda را برای گردش‌کارِ طولانی و بادوام بگذار، نه برای یک API پرترافیک.


مطالعهٔ موردیِ ۲ — سیستمِ بلعِ تله‌متری و ردیابیِ بلادرنگ

پشته: Netty (تله‌متری TCP/UDP)، پخش زنده، ژئوفنسینگ PostGIS، هشدارِ قاعده‌محور، WebSocket، MinIO، MapLibre، React/Next.

مسئله و دامنه

یک سیستمِ ردیابیِ ناوگان را در نظر بگیر که در آن هزاران دستگاه موقعیت و تله‌متری (یعنی داده‌های حسگر مثل سرعت، سوخت، دما) را روی TCP/UDP خام و با پروتکل‌های دودویی مخصوص هر دستگاه می‌فرستند. سیستم باید در نرخِ خط (line rate) — یعنی به همان سرعتی که داده می‌رسد، بدون عقب‌افتادن — رمزگشایی کند، هشدارهای ژئوفنس (حصار جغرافیایی) و قاعده را نزدیک‌به‌بلادرنگ ارزیابی کند، و موقعیت‌های زنده را به داشبورد بریزد.

معماری و جریان داده

 دستگاه‌ها ──دودویی TCP/UDP──> بلعِ Netty (codec به‌ازای پروتکل)
                                  │ نرمال‌سازی → رویداد Position
                                  ▼
        موتور قاعده/ژئوفنس (PostGIS: ST_Contains, ST_DWithin)
                                  │ هشدارها
        ┌─────────────┬──────────┴───────────┐
        ▼             ▼                        ▼
    هابِ WebSocket   انبار سری‌زمانی        MinIO (فریم خام)
        │
   داشبورد MapLibre / Next.js (نشانگر زنده)
مدلِ حلقهٔ رویدادِ Netty: یک پیشخدمت زبردست در برابر یک پیشخدمت به‌ازای هر میز

مدلِ ساده این است که برای هر مشتری (اتصال) یک پیشخدمت (نخ/thread) بگذاری؛ با هزاران مشتری، هزاران پیشخدمت داری که بیشترشان بی‌کار منتظرند و حقوقِ حافظه می‌گیرند. Netty مثل یک پیشخدمتِ زبردست است که با حلقهٔ رویداد (event loop) کار می‌کند: تعداد کمی نخ (تقریباً به‌اندازهٔ هسته‌های CPU) که مدام بین همهٔ میزها می‌چرخند و فقط وقتی کاری آماده است به آن می‌پردازند. این یعنی ورودی/خروجیِ نابلاک (non-blocking): هیچ نخی بی‌خود پشتِ یک اتصالِ کند معطل نمی‌ماند.

در این خط، فریم‌های خام از یک pipeline رد می‌شوند: رمزگشای فریم → codec پروتکل → handler. خروجی، رویدادِ تمیزِ Position است که هم به موتورِ قاعده و هم به هابِ WebSocket پخش می‌شود.

انتخاب‌های فنی کلیدی و چرایی

  • Netty — پروتکل‌های دستگاه دودویی، فریم‌بندی‌شده و حالت‌دار (stateful) روی سوکت‌های بلندعمرند. یک کانتینرِ servlet (که برای درخواست‌های کوتاهِ HTTP ساخته شده) مدلِ اشتباهی است. Netty ورودی/خروجیِ نابلاک با استخرِ نخِ کوچکِ ثابت و کنترلِ دقیقِ pipeline می‌دهد.
  • PostGIS — گزاره‌های مکانی مثل ST_Contains (آیا این نقطه داخل این چندضلعی است؟) و ST_DWithin (آیا در فاصلهٔ مشخصی هست؟) با ایندکسِ GiST عضویتِ ژئوفنس را به یک پرس‌وجوی ایندکس‌شده تبدیل می‌کنند، نه ریاضیِ چندضلعیِ سنگین سمتِ اپ.
  • MapLibre به‌جای Google Maps — متن‌باز، کاشی‌های خودمیزبان، بدون لایسنس به‌ازای هر بارگذاری — که برای محصولِ ناوگانِ آفلاین/درون‌سازمانی حیاتی است.
  • MinIO — انبارِ شیءِ سازگار با S3 برای نگه‌داریِ فریم‌های خامِ دستگاه (برای ممیزی و بازپخش) بدونِ قفل‌شدن به ابرِ خاص.

سخت‌ترین چالش فنی

فشار برگشتی و مصرف‌کننده‌های کند. دستگاه‌ها هرگز فرستادن را قطع نمی‌کنند؛ اما یک کلاینتِ WebSocketِ کند یا یک نوشتنِ گیرکردهٔ DB نباید heap را بترکاند (یادت هست: شیر آب و لیوان). راه‌حل چند بخش دارد:

  • Channel.isWritable() در Netty به‌همراه خط‌های آبِ بالا/پایین (high/low watermarks) — یعنی آستانه‌هایی که وقتی بافرِ یک اتصال پُر شد، سیگنال می‌دهند که دیگر ننویس؛ پس به‌ازای هر اتصال یا بافر می‌شود یا داده انداخته می‌شود.
  • جداسازیِ بلع از مصرف‌کننده با یک صفِ کران‌دار (bounded queue).
  • انداختنِ موقعیت‌های کهنه با قاعدهٔ آخرین‌نوشته‌برنده (last-write-wins) به‌ازای هر خودرو، به‌جای صف‌کردنِ تک‌تکِ نقاط برای یک داشبوردِ عقب‌مانده. برای نمای زنده، موقعیتِ جدیدتر همیشه قدیمی را باطل می‌کند.
  • انتقالِ ارزیابیِ ژئوفنس از حلقهٔ رویداد به یک استخرِ کارگرِ کران‌دار، تا یک پرس‌وجوی کندِ PostGIS هرگز حلقهٔ I/O را متوقف نکند.

مصالحه‌ها

  • UDP بسته گم می‌کند؛ TCP تأخیر و حالتِ اتصال اضافه می‌کند. هر دو پشتیبانی می‌شوند چون برخی ردیاب‌ها فقط UDP بلدند. مسیرِ UDP گم‌شدن را تحمل می‌کند (چون موقعیت‌ها اسنپ‌شاتِ خودتوان‌اند و بعدی جای قبلی را می‌گیرد)؛ مسیرِ TCP تحویلِ مرتب می‌گیرد.
  • آخرین‌نوشته‌برنده نقاطِ میانی را برای نمای زنده می‌اندازد، اما مسیرِ کامل جداگانه پایدار می‌شود — چون نمای زنده و تاریخی، نیازهای سازگاریِ متفاوتی دارند.
چرا Netty و نه Spring MVC/WebFlux برای بلع؟

چون دستگاه‌ها پروتکلِ دودوییِ خام و فریم‌بندی‌شده روی سوکتِ پایدار حرف می‌زنند، نه HTTP. Netty کنترلِ codec در سطحِ pipeline و ورودی/خروجیِ نابلاکِ تنظیم‌شده برای تعداد اتصالِ بالا می‌دهد؛ چیزی که یک استکِ HTTPِ عمومی نمی‌دهد.

چطور ژئوفنسینگ را به هزاران فنس مقیاس می‌دهی؟

ایندکسِ مکانیِ GiST روی هندسهٔ فنس‌ها بساز، بعد با ST_DWithin/ST_Contains و نقطه به‌عنوان پارامتر پرس‌وجو کن. ایندکس، آزمونِ خطیِ همهٔ چندضلعی‌ها (که با تعدادِ فنس‌ها بزرگ می‌شود) را به یک جست‌وجوی زمانِ‌لگاریتمی تبدیل می‌کند.

کلاینتِ داشبوردِ کند را چطور مدیریت می‌کنی؟

با خطِ آب (watermark) و بررسیِ نوشتنی‌بودنِ کانال؛ به‌روزرسانیِ کهنه را بینداز و هرگز نگذار یک کلاینتِ کند، حلقهٔ بلع را با فشارِ برگشتیِ خودش متوقف کند. سلامتِ کلِ سیستم مهم‌تر از کاملِ‌بودنِ یک داشبوردِ عقب‌مانده است.

UDP در برابر TCP برای تله‌متری — مصالحه؟

UDP: سربارِ کمتر و تحملِ گم‌شدن، مناسب برای اسنپ‌شاتِ خودتوان (یک نمونهٔ افتاده فوراً با نمونهٔ بعدی جایگزین می‌شود). TCP: ترتیب و تحویلِ تضمینی، اما به قیمتِ نگه‌داریِ حالت به‌ازای هر اتصال و مسدودیِ سرصف (head-of-line blocking) — یعنی یک بستهٔ گم‌شده جلوی رسیدنِ بسته‌های بعدی را تا وقتِ ارسالِ مجدد می‌گیرد.

(تله) قاعده‌ها کجا باید ارزیابی شوند — روی حلقهٔ رویداد؟

نه، هرگز. حلقهٔ رویداد را با پرس‌وجوی DB یا مکانی بلاک نکن. آن را به یک استخرِ کارگرِ کران‌دار واگذار کن. بلاک‌کردن روی حلقهٔ رویداد، همان چند نخِ ارزشمند را گیر می‌اندازد و کلِ توانِ بلعِ سیستم فرومی‌ریزد. این تلهٔ کلاسیکِ برنامه‌نویسیِ رویداد-محور است.


مطالعهٔ موردیِ ۳ — یک SaaSِ طراحیِ مشارکتی (میکروسرویسِ رویداد-محور)

پشته: میکروسرویسِ قرارداد-محور (contract-first)، OpenAPI/gRPC، Kafka (Outbox/Saga)، PostgreSQL با دیتابیس-به‌ازای-سرویس، ClickHouse، Elasticsearch، K8s/Helm.

مسئله و دامنه

یک ویرایشگرِ سبکِ Canva/Figma: کاربران طرح‌ها را از لایه‌ها، دارایی‌ها و قالب‌ها می‌سازند. بک‌اند باید هم‌زمان یک ویرایشگرِ مشارکتی را سرویس دهد، دارایی‌ها را برای جست‌وجو ایندکس کند، تحلیل را در مقیاسِ عظیم ردیابی کند، و عملیاتِ چندگامی (خروجی‌گرفتن، انتشار) را بین سرویس‌ها هماهنگ کند.

معماری و جریان داده

 کلاینت ──gRPC/REST(OpenAPI)──> API gateway
        ┌──────────┬──────────┬──────────┐
        ▼          ▼          ▼          ▼
   design-svc  asset-svc  export-svc  analytics-svc
   (PG own DB)(PG own DB)(PG own DB) (ClickHouse)
        │ outbox tables
        ▼
   Kafka (domain events) ──> Elasticsearch indexer (asset search)
                          └─> ClickHouse ingest (events/metrics)
   Saga orchestrator coordinates export/publish across services
دیتابیس-به‌ازای-سرویس: هر مغازه صندوقِ خودش

تصور کن یک بازارِ بزرگ که هر مغازه صندوق و دفترِ حسابِ خودش را دارد و هیچ مغازه‌ای اجازه ندارد دست در کشویِ مغازهٔ بغلی ببرد. این یعنی دیتابیس-به‌ازای-سرویس (db-per-service): هر سرویس صاحبِ شِمای خودش است و هیچ SQLِ بین‌سرویسی وجود ندارد. برای رد و بدلِ اطلاعات، سرویس‌ها فقط از درِ جلو (API) یا با فرستادنِ رسید (رویدادِ Kafka) این کار را می‌کنند.

قرارداد-محور: نقشهٔ ساختمان پیش از آجر

قرارداد-محور (contract-first) یعنی اول قرارداد نوشته می‌شود — یک سندِ رسمی که می‌گوید هر API چه ورودی/خروجی‌ای دارد (با OpenAPI برای REST یا با gRPC) — و تیم‌ها قبل از اینکه حتی یک خط کد نوشته شود بر اساسِ همان قرارداد به هم وصل می‌شوند. مثل اینکه پیمانکارِ لوله‌کشی و برق‌کار هر دو از روی یک نقشهٔ واحد کار کنند، نه سلیقهٔ خودشان.

انتخاب‌های فنی کلیدی و چرایی

  • قرارداد-محور (OpenAPI/gRPC) — تیم‌ها بر اساسِ یک قراردادِ نسخه‌دار پیش از وجودِ کد یکپارچه می‌شوند. gRPC برای ارتباطِ داخلیِ سرویس‌به‌سرویس با تأخیرِ کم، و OpenAPI/REST در لبه برای کلاینت‌های مرورگر.
  • الگوی Outbox — این معضلِ کلاسیکِ نوشتنِ دوگانه (dual-write) را حل می‌کند: «commit در دیتابیس» و «انتشار در Kafka» نمی‌توانند اتمی باشند (اگر بینشان برق برود، یکی انجام شده و دیگری نه). راه‌حل: رویداد در همان تراکنش در یک جدولِ outbox نوشته می‌شود، بعد یک رله (relay) — مثل Debezium یا یک poller — آن را از جدول برمی‌دارد و به Kafka می‌فرستد. تضمین: حداقل‌یک‌بار (at-least-once)، هیچ رویدادی گم نمی‌شود.
  • Saga به‌جای 2PC — عملیاتِ خروجی/انتشار چند سرویس را دربر می‌گیرد. 2PC (تعهدِ دوفازی) روی میکروسرویس‌ها یک تلهٔ مقیاس‌پذیری و دسترس‌پذیری است (چون همه باید قفل بمانند تا همه آماده شوند). Saga از تراکنش‌های محلی + اقداماتِ جبرانی استفاده می‌کند.
  • ClickHouse برای تحلیل، Elasticsearch برای جست‌وجو، PostgreSQL برای OLTP — سه موتور، چون الگوهای دسترسی‌شان با هم ناسازگارند: نوشتنِ ردیفیِ تراکنشی (OLTP)، اسکنِ تحلیلیِ ستونی، و ایندکسِ معکوسِ متنِ کامل.

سخت‌ترین چالش فنی

اثرِ دقیقاً‌یک‌بار (exactly-once effect) در زنجیرهٔ Outbox+Kafka+مصرف‌کننده. حمل‌ونقل حداقل‌یک‌بار است، پس مصرف‌کننده‌ها حتماً تکراری می‌بینند (تلاشِ مجددِ رله، توزیعِ دوبارهٔ پارتیشن). راه‌حل: مصرف‌کننده‌های خودتوان که بر اساسِ شناسهٔ یکتای رویداد کار می‌کنند (جدولِ دیدوپ یا upsert)، و عملیاتِ پایین‌دستیِ خودتوان (دیدوپِ ClickHouse با موتورِ ReplacingMergeTree، upsertِ Elasticsearch بر اساسِ شناسهٔ سند). طراحی، دستِ‌کشیدن از تعقیبِ تحویلِ دقیقاً‌یک‌بار (که ناممکن است) و مهندسیِ پردازشِ دقیقاً‌یک‌بار از راهِ خودتوانی است.

دقیقاً‌یک‌بار: تحویل در برابر پردازش

این تمایز را در ذهنت حک کن: تحویلِ (delivery) دقیقاً‌یک‌بار در عمل ناممکن است — شبکه همیشه ممکن است پیام را دوباره برساند. اما پردازشِ (processing) دقیقاً‌یک‌بار شدنی است: بگذار پیام هرچقدر می‌خواهد تکراری بیاید، ولی مصرف‌کننده را طوری بساز که تکراری‌ها اثرِ اضافه نگذارند (یعنی خودتوان باشد). این همان تفاوتِ یک مهندسِ ساده و یک مهندسِ ارشد در طراحیِ سیستمِ توزیع‌شده است.

مصالحه‌ها

  • دیتابیس-به‌ازای-سرویس، join را می‌کُشد. خواندنِ بین‌سرویسی تبدیل به فراخوانیِ API یا پروجکشنِ مدلِ خواندنی (read-model projection) می‌شود — قطعاتِ متحرکِ بیشتر و سازگاریِ نهایی. در عوض: استقرار و مقیاسِ مستقلِ هر سرویس به دست می‌آید.
  • پیچیدگیِ Saga. جبران‌ها منطقِ کسب‌وکارند («لغوِ انتشار»، «بازگرداندنِ اعتبار») و باید طراحی، تست و پایش شوند. هیچ rollbackِ مجانی وجود ندارد.
  • سه انبارِ داده بارِ عملیاتی را چند برابر می‌کنند و به CDC/استریمینگ نیاز دارند تا هماهنگ بمانند.
چرا Outbox به‌جای انتشارِ مستقیم در Kafka بعد از commit؟

چون یک کرش بینِ commit و publish رویداد را گم می‌کند. Outbox رویداد را بخشی از همان تراکنشِ ACID می‌کند: یا هم رکورد و هم رویداد نوشته می‌شوند یا هیچ‌کدام. بعداً رله با خیالِ راحت آن را به Kafka می‌فرستد.

دقیقاً‌یک‌بار با Kafka — واقعی یا افسانه؟

تحویلِ دقیقاً‌یک‌بار به‌صورتِ سرتاسری (end-to-end) عملاً ناممکن است. اما پردازشِ دقیقاً‌یک‌بار با مصرف‌کننده‌های خودتوان و کلیدهای دیدوپ به دست می‌آید. پس پاسخِ درست این است: «تحویل نه، ولی اثرِ پردازش بله — از راهِ خودتوانی».

چرا ClickHouse و نه Postgres برای تحلیل؟

چون ذخیره‌سازیِ ستونی (columnar) به‌همراه اجرای برداری (vectorized) میلیاردها ردیف را برای تجمیع (aggregation) بسیار سریع‌تر اسکن می‌کند. موتورِ MergeTree برای دادهٔ رویدادیِ افزایشی و تغییرناپذیر ساخته شده — دقیقاً الگوی داده‌های تحلیلی. Postgres برای نوشتنِ ردیفیِ تراکنشی عالی است اما برای اسکنِ تحلیلیِ عظیم نه.

Saga چطور شکستِ گامِ ۳ از ۴ را مدیریت می‌کند؟

تراکنش‌های جبرانیِ گام‌های ۱ و ۲ را به ترتیبِ معکوس اجرا می‌کند؛ هر سرویس یک عملیاتِ «واگرد (undo)» ارائه می‌دهد. حالتِ کلِ Saga یا با یک ارکستریتور ردیابی می‌شود، یا با کوریوگرافی (choreography) — یعنی سرویس‌ها با رد و بدلِ رویداد خودشان هماهنگ می‌شوند.

(تله) Kafka ترتیب را تضمین می‌کند — به‌صورتِ سراسری؟

نه. فقط به‌ازای هر پارتیشن (per partition). رویدادهای حساس‌به‌ترتیب باید یک کلیدِ پارتیشنِ مشترک داشته باشند (مثلاً designId) تا در یک پارتیشن بیفتند؛ وگرنه ترتیبشان بین پارتیشن‌ها از دست می‌رود. این پرتکرارترین سوءتفاهم دربارهٔ Kafka است.


مطالعهٔ موردیِ ۴ — یک SaaSِ چندمستأجری با تحلیلِ پُرنوشت

پشته: چندمستأجری، Spring Cloud Gateway، PostgreSQL/PostGIS، ScyllaDB، Redis، RabbitMQ، Camunda BPM، Python/FastAPI.

مسئله و دامنه

زمان‌بندی و انتشارِ پست در کانال‌های اجتماعیِ متعدد برای مستأجرهای متعدد، و بعد بلع و تحلیلِ حجمِ بالای دادهٔ تعامل (لایک، دسترسی، کامنت) به‌ازای هر مستأجر. دو چیز کلِ طراحی را قبضه می‌کنند: چندمستأجری و تحلیلِ پُرنوشت (write-heavy).

معماری و جریان داده

 tenant ──> Spring Cloud Gateway (auth, tenant routing, rate-limit)
                 │
   scheduling-svc ──Camunda(publish workflow, retries/backoff)──> channel adapters (FastAPI)
                 │                                                     │ external APIs
   RabbitMQ (jobs/events)                                    ScyllaDB (engagement time-series)
                 │                                                     │
   PostgreSQL/PostGIS (tenant/config/geo)          Redis (rate-limit, token cache, hot counters)

انتخاب‌های فنی کلیدی و چرایی

  • ScyllaDB — متریک‌های تعامل عظیم، پُرنوشت و سری‌زمانی‌اند و با کلیدِ (مستأجر، کانال، زمان) پرس‌وجو می‌شوند. یک انبارِ ستون‌پهن (wide-column) با سازگاریِ قابل‌تنظیم و بدونِ مسترِ یگانه، برای این الگو از Postgres بهتر مقیاس می‌گیرد. Scylla یک موتورِ سازگار با Cassandra است که با ++C نوشته شده و تأخیرِ کمتر و نخ‌بندیِ به‌ازای هر شارد دارد.
  • Spring Cloud Gateway — لبهٔ واکنشی و نابلاک؛ جای مرکزی برای تشخیصِ مستأجر، اعتبارسنجیِ JWT و نرخ‌گیریِ (rate limiting) به‌ازای هر مستأجر (پشتیبانی‌شده با Redis).
  • Camunda BPM — انتشار یک گردش‌کارِ طولانی و پرشکست است (محدودیتِ نرخ، تازه‌سازیِ توکن، تلاشِ مجدد با backoff، تأییدِ انسانی). مدل‌کردنِ آن به‌صورتِ BPMN دیدپذیری و حالتِ بادوامِ تلاشِ مجدد می‌دهد.
  • آداپترهای Python/FastAPI — ابزار و SDKهای پلتفرم‌های اجتماعی عمدتاً Python-محورند. FastAPI این یکپارچه‌سازی‌های بی‌ثبات با اشخاصِ ثالث را از هستهٔ JVM جدا می‌کند.

سخت‌ترین چالش فنی

ایزوله‌سازیِ مستأجرها بدونِ انفجارِ زیرساخت به‌ازای هر مستأجر. (یادت هست: ساختمانِ آپارتمانی.) یک مدلِ تفکیک‌گر (discriminator) خوب کار می‌کند: شِمای مشترک با ستونِ tenant_id، به‌همراه اجرای سطح‌ردیف در Postgres، کلیدِ پارتیشنِ ScyllaDB که با مستأجر پیشوند می‌خورد، و فضای‌نامِ کلیدهای Redis — به‌علاوهٔ نرخ‌گیریِ به‌ازای هر مستأجر در gateway تا یک مستأجرِ پرسروصدا بقیه را گرسنه نکند (مشکلِ همسایهٔ پرسروصدا / noisy neighbor). نکتهٔ ظریف: نرخ‌گیریِ سطلِ توکن (token bucket) باید توزیع‌شده باشد (یک اسکریپتِ Lua در Redis برای بررسی‌وکاهشِ اتمی) وگرنه هر نسخهٔ gateway یک سطلِ کامل جدا می‌دهد و محدودیت بی‌اثر می‌شود.

تلهٔ نرخ‌گیریِ محلی

اگر هر نسخهٔ gateway سطلِ توکنِ خودش را نگه دارد، با پنج نسخه، مشتری عملاً پنج برابرِ محدودیت می‌گیرد. نرخ‌گیری در سیستمِ چندنسخه‌ای باید مرکزی و اتمی باشد — به همین خاطر سطل در Redis می‌نشیند و با اسکریپتِ Lua به‌صورتِ اتمی بررسی‌وکاهش می‌شود.

مصالحه‌ها

  • چندمستأجریِ شِمای مشترک ارزان و مقیاس‌پذیر است، اما یک باگِ واحد می‌تواند دادهٔ یک مستأجر را به دیگری نشت دهد؛ پس هر پرس‌وجو باید مستأجر-محدود باشد، هم در کد و هم ترجیحاً در سطحِ DB.
  • ScyllaDB مدل‌سازیِ داده‌ای پرس‌وجو-اول (query-first) می‌طلبد — جدول به‌ازای هر پرس‌وجو، denormalizeِ شدید، و از دست دادنِ joinِ فی‌البداهه.
  • سازگاریِ نهایی روی شمارنده‌های تحلیلی — شمارنده‌های داغ در Redis سریع‌اند اما به‌صورتِ ناهمگام با Scylla تطبیق داده می‌شوند.
مدل‌های چندمستأجری — کدام و چرا؟

سه مدل: Silo (دیتابیس-به‌ازای-مستأجر، قوی‌ترین ایزوله ولی گران)، Bridge (شِما-به‌ازای-مستأجر، حدِ وسط)، Pool (شِمای مشترک + tenant_id، ارزان‌ترین و مقیاس‌پذیرترین). Pool انتخابِ رایج برای مقیاس است و ضعفِ ایزوله‌اش با محدودسازیِ سختگیرانهٔ مستأجر جبران می‌شود.

چرا ScyllaDB به‌جای Cassandra؟

سازگار با همان API است، اما به‌جای JVM با ++C و معماریِ شارد-به‌ازای-هسته (shard-per-core) روی چارچوبِ seastar ساخته شده. نتیجه: تأخیرِ p99 کمتر و پیش‌بینی‌پذیرتر، و استفادهٔ بهترِ سخت‌افزار — چون مکثِ GC (زباله‌روب) ندارد (که در Cassandraی JVM‌محور آفتِ تأخیر است).

چرا Camunda برای انتشار به‌جای صف + cron؟

چون Camunda حالتِ بادوامِ به‌ازای هر نمونه، تلاشِ مجدد/جبرانِ بصری، تایمرهای SLA و تسک‌های انسانی می‌دهد. یک صفِ ساده + cron هیچ‌کدام از این دیدپذیریِ گردش‌کار یا حالتِ بلندعمر را نمی‌دهد؛ فقط پیام را جابه‌جا می‌کند.


مطالعهٔ موردیِ ۵ — یک پلتفرمِ تحویلِ محتوای رویداد-محور

پشته: رویداد-محور، Spring Cloud (Eureka/Config/Gateway)، PostgreSQL، RabbitMQ، MinIO، JWT/RBAC، Docker.

مسئله و دامنه

یک پلتفرمِ آموزش آنلاین: دوره‌ها، ثبت‌نام‌ها، تحویلِ محتوا، ردیابیِ پیشرفت. میکروسرویسِ کلاسیک با کشفِ سرویس (service discovery) و پیکربندیِ متمرکز؛ محتوا (ویدیو/PDF) به‌صورتِ شیء ذخیره می‌شود؛ دسترسی با نقش کنترل می‌شود.

معماری و جریان داده

 client ──> Gateway ──(discovery via Eureka)──> {course, enroll, content, user}-svc
                            │                          │
                    Config Server (central)     PostgreSQL per concern
                            │
                    RabbitMQ (enrolled → grant-access, progress events)
                            │
                    MinIO (course assets, pre-signed URLs)
کشفِ سرویس و Eureka: دفترچهٔ تلفنِ زنده

وقتی سرویس‌ها مدام بالا و پایین می‌شوند و آدرسشان عوض می‌شود، از کجا معلوم است الان کجا هستند؟ Eureka مثل یک دفترچهٔ تلفنِ زنده است: هر سرویس وقتی روشن می‌شود خودش را ثبت (register) می‌کند و وقتی خاموش می‌شود حذف می‌شود. Config Server هم مثل یک تابلوی اعلاناتِ مرکزی است که تنظیماتِ همه را یک‌جا نگه می‌دارد، تا با تغییرِ تنظیم لازم نباشد سرویس دوباره مستقر شود (با scopeی به‌نامِ refresh).

انتخاب‌های فنی کلیدی و چرایی

  • Eureka + Config Server — کشفِ سرویسِ سمتِ کلاینت و پیکربندیِ برون‌سپاری‌شده، پایهٔ Spring Cloud Netflix‌اند؛ سرویس‌ها پویا ثبت/حذف می‌شوند و تنظیمات بدونِ استقرارِ دوباره تغییر می‌کند.
  • RabbitMQ به‌جای Kafka — برای این مقیاس، مسیریابیِ به‌ازای پیام (صرافیِ topic/direct) و معناشناسیِ سادهٔ صفِ کار با ack و DLQ، RabbitMQ مناسب‌تر از مدلِ لاگ‌واِفستِ Kafka است. اینجا نه به بازپخش نیاز است نه به توانِ عظیم. (DLQ یعنی Dead-Letter Queue، صفِ نامه‌های مرده — جایی که پیامی که چند بار شکست خورده می‌رود تا بعداً بررسی شود.)
  • MinIO + URLهای پیش‌امضا (pre-signed) — اپ هرگز بایت‌های بزرگِ ویدیو را پراکسی نمی‌کند؛ یک URLِ پیش‌امضای زمان‌دار صادر می‌کند و کلاینت مستقیم از انبارِ شیء می‌گیرد، پس بارِ JVM سبک می‌ماند.
  • JWT/RBAC — احرازِ هویتِ بی‌حالت (stateless) در gateway؛ نقش‌های داخلِ توکن، @PreAuthorize در سطحِ متد را هدایت می‌کنند. (RBAC = کنترلِ دسترسیِ مبتنی بر نقش.)

سخت‌ترین چالش فنی

اعطای دسترسیِ سازگار در یک جریانِ رویداد-محور. ثبت‌نام (در enroll-svc) باید دسترسیِ محتوا (در content-svc) را بدونِ یک نوشتنِ همگامِ بین‌سرویسی اعطا کند. طراحی یک رویدادِ EnrollmentCompleted منتشر می‌کند؛ content-svc آن را مصرف می‌کند و یک اعطای دسترسی می‌سازد. مسابقهٔ خطرناک: کاربر پیش از پردازشِ رویداد به محتوا می‌زند. راه‌حل: پاسخِ ثبت‌نام فقط بعد از commitِ تراکنشِ محلی ۲۰۰ می‌شود، UI حالتِ «در حالِ پردازش» را نشان می‌دهد، مصرف‌کننده‌های content-svc خودتوان‌اند، و یک بررسیِ استحقاقِ (entitlement) همگامِ پشتیبان برای اولین دسترسی وجود دارد. سازگاریِ نهایی، که با UX و خودتوانی قابل‌قبول شده.

مصالحه‌ها

  • Eureka/Config زیرساخت اضافه می‌کنند — و ضمناً Spring Cloud Netflix عمدتاً در حالتِ نگه‌داری است؛ جایگزینِ مدرن، کشفِ بومیِ Kubernetes است. پس این انتخاب وقتی موجه است که اجرا خارج از K8s باشد.
  • اعطای رویداد-محور سازگاریِ فوری را با جداشدگی معامله می‌کند؛ به مصرف‌کنندهٔ خودتوان و مدیریتِ DLQ نیاز دارد.
RabbitMQ در برابر Kafka — کِی RabbitMQ؟

وقتی مسیریابیِ غنی، ack/تحویلِ مجددِ به‌ازای پیام، اولویت/DLQ و توانِ متوسط بدونِ بازپخش خواسته شود. Kafka برای استریمِ لاگِ پرتوان، قابل‌بازپخش و مرتب برنده است. یعنی RabbitMQ = صفِ کارِ هوشمند، Kafka = لاگِ رویدادِ ماندگار.

چرا URLهای پیش‌امضا؟

تا بایت‌های شیءِ بزرگ از لایهٔ اپلیکیشن دور بمانند؛ انبارِ شیء کارِ انتقال را می‌کند و اپ فقط مجوز می‌دهد. این بارِ CPU و پهنای‌باندِ سرور را به‌شدت کم می‌کند.

JWT — چطور باطلش می‌کنی؟

JWTِ خالص به‌سختی باطل‌شدنی است (چون خودبسنده و بی‌حالت است). راهش: TTL کوتاه + توکنِ تازه‌سازی (refresh)، یا یک لیستِ ابطال (blocklist) در Redis که در gateway چک شود. یعنی یا زودگذرش کن یا یک حالتِ کوچکِ ابطال نگه دار.

Eureka در ۲۰۲۶ — هنوز؟

روی Kubernetes، Serviceها و DNSِ بومی را ترجیح بده. Eureka برای استقرارِ VM/غیر-K8s مناسب است. و بدان که اجزای Spring Cloud Netflix فقط در حالتِ نگه‌داری‌اند (فقط رفعِ باگ، نه ویژگیِ جدید).


مطالعهٔ موردیِ ۶ — یک پلتفرمِ سفارش و تسویهٔ تجارت الکترونیک

پشته: میکروسرویس‌های Spring Boot پشتِ یک API gateway؛ سطوحِ storefront + panel + admin.

مسئله و دامنه

کاتالوگ، سبد، تسویه، سفارش و پرداخت برای یک خرده‌فروشِ تخصصی، با سه فرانت‌اند (storefrontِ مشتری، panelِ فروشنده، adminِ داخلی) که پشتِ یک gateway نشسته‌اند.

معماری و جریان داده

 storefront ┐
 panel      ┼──> API Gateway (auth, routing, rate-limit, aggregation)
 admin      ┘         │
        ┌────────┬────┴────┬─────────┬──────────┐
     catalog   cart      order    payment    inventory
        │        │          │         │          │
      each owns its data; order orchestrates checkout

انتخاب‌های فنی کلیدی و چرایی

  • API Gateway به‌عنوانِ لبهٔ واحد برای سه کلاینت — احراز هویت، CORS، نرخ‌گیری و تجمیعِ backend-for-frontend را متمرکز می‌کند، تا هر فرانت‌اند فقط با یک مبدأ حرف بزند.
  • سرویس-به‌ازای-حوزهٔ‌کرانه‌دار (bounded context) — کاتالوگ، سبد، سفارش، پرداخت و انبار نیازهای سازگاری و مقیاسِ متفاوتی دارند (کاتالوگ پُرخوان و کش‌شدنی است؛ انبار به سازگاریِ قوی نیاز دارد تا بیش‌فروشی (overselling) رخ ندهد).
  • سفارش به‌عنوانِ ارکستریتور — تسویه از سبد → رزروِ انبار → پرداخت → ساختِ سفارش امتداد دارد؛ یک ارکستراسیونِ سبکِ Saga با جبران (آزادسازیِ رزرو، بازپرداخت) بدونِ 2PC آن را سازگار نگه می‌دارد.

سخت‌ترین چالش فنی

جلوگیری از بیش‌فروشی زیرِ هم‌روندی. دو خریدار برای آخرین واحد مسابقه می‌دهند. روشِ ساده‌لوحانهٔ «بخوان-بعد-بنویس» بیش‌فروشی می‌کند. راه‌حل: رزروِ انبار با یک به‌روزرسانیِ شرطیِ اتمی:

UPDATE inventory SET qty = qty - 1 WHERE sku = ? AND qty >= 1

این کوئری اتمی است و تعدادِ ردیف‌های تأثیرگرفته را برمی‌گرداند (اگر صفر بود، یعنی موجودی نبود). جایگزین: قفلِ خوش‌بینانه (optimistic locking) با ستونِ @Version و تلاشِ مجدد. رزرو زمان‌دار است؛ اگر پرداخت شکست بخورد یا تایم‌اوت شود، یک اقدامِ جبرانی آن را آزاد می‌کند. این کار، یک مسئلهٔ درستیِ توزیع‌شده را به یک نگهبانِ اتمیِ تک‌ردیفی + جبرانِ Saga تبدیل می‌کند.

قفلِ خوش‌بینانه در برابر بدبینانه: ویرایشِ سندِ مشترک

قفلِ بدبینانه یعنی قبل از ویرایش، درِ اتاق را قفل می‌کنی تا کسی دیگر وارد نشود (منتظر می‌مانند). قفلِ خوش‌بینانه یعنی همه آزادانه ویرایش می‌کنند، اما موقعِ ذخیره، سیستم شمارهٔ نسخه (@Version) را چک می‌کند؛ اگر کسی زودتر ذخیره کرده بود، می‌گوید «نسخه‌ات کهنه شد، دوباره تلاش کن». برای دادهٔ کم‌تعارض، خوش‌بینانه سریع‌تر است چون هیچ‌کس بی‌خود منتظر نمی‌ماند.

مصالحه‌ها

  • سازگاریِ قویِ انبار در برابر توان. نگهبانِ اتمی روی SKUهای داغ سریالی می‌شود (همه پشتِ یک ردیف صف می‌کشند)؛ برای مقیاسِ فروشِ‌فوری باید موجودی در سطل‌ها شارد شود یا به یک صفِ رزرو رفت.
  • gateway به‌عنوانِ تجمیع‌کننده خطرِ تبدیل‌شدن به یک لایهٔ چاق و جفت‌شده دارد؛ تجمیع را نازک نگه دار یا برای هر کلاینت یک BFFِ اختصاصی بگذار.
چطور جلوی دوبار-شارژشدن در تلاشِ مجددِ تسویه را می‌گیری؟

با یک کلیدِ خودتوانی (idempotency key) به‌ازای هر تلاشِ تسویه؛ سرویسِ پرداخت بر اساسِ آن دیدوپ می‌کند، پس یک درخواستِ تلاش‌مجدد هرگز دو بار شارژ نمی‌کند. این دقیقاً همان خودتوانی است که در بخش صفر ساخته شد.

سبد را کجا ذخیره می‌کنی؟

معمولاً در Redis (سریع، با TTL برای رهاشدنِ سبد)، و منبعِ حقیقت هنگامِ ساختِ سفارش. سبد زودگذر است، سفارش بادوام. یعنی سازگاریِ درخور: سبد قویاً سازگار نگه داشته نمی‌شود چون از دست‌رفتنش فاجعه نیست.

چطور از بیش‌فروشی جلوگیری می‌کنی؟

کاهشِ شرطیِ اتمی (همان UPDATE ... WHERE qty >= 1) یا @Versionِ خوش‌بینانه با تلاشِ مجدد، به‌علاوهٔ رزروِ زمان‌دار و آزادسازیِ جبرانی. مسئلهٔ توزیع‌شده به یک نگهبانِ تک‌ردیفی فروکاسته می‌شود.

کجای تجارت الکترونیک سازگاریِ نهایی گاز می‌گیرد؟

ایندکسِ کاتالوگ/جست‌وجو، توصیه‌گر، تحلیل — این‌ها سازگاریِ نهایی را تحمل می‌کنند و مشکلی نیست. اما هرگز در ثبتِ پرداخت یا کسرِ موجودی که باید قویاً سازگار باشند. انتخابِ سازگاریِ درخور، مغزِ این سیستم است.


مطالعهٔ موردیِ ۷ — یک صفحهٔ کنترلِ دسترسیِ شبکه با اعتماد صفر (Zero-Trust)

پشته: Python/FastAPI، WireGuard، nftables، CoreDNS، حلقهٔ آشتی از حالتِ DB، React/Electron.

مسئله و دامنه

جایگزینیِ دسترسیِ تختِ VPN با اعتمادِ صفر (zero-trust): هر کاربر/دستگاه کمترین‌دسترسیِ ممکن را می‌گیرد، که با برنامه‌ریزیِ پویای peerهای WireGuard، قواعدِ فایروالِ nftables و CoreDNS اعمال می‌شود — همه مشتق‌شده از حالتِ مطلوب (desired state) در یک دیتابیس. یک کلاینتِ Electron تونل را مدیریت می‌کند.

معماری و جریان داده

 admin/UI (React/Electron) ──> FastAPI control plane ──> Postgres (desired state)
                                        │
                            reconcile loop (observe DB → converge host)
                                        │
        ┌──────────────┬───────────────┼───────────────┐
        ▼              ▼                ▼               ▼
   WireGuard peers  nftables rules   CoreDNS zones   audit log
   (per-device key) (least-priv ACL) (name policy)
حلقهٔ آشتی: ترموستات، نه کلید چراغ

یک کلیدِ چراغ امری است: «الان روشن شو». اما اگر وسطِ کار برق نوسان کند، کلید نمی‌داند و چراغ خاموش می‌ماند. یک ترموستات فرق دارد: دمای مطلوب را تنظیم می‌کنی (مثلاً ۲۲ درجه) و ترموستات مدام دمای واقعی را می‌سنجد و به‌سمتِ مطلوب همگرا می‌کند — اگر پنجره باز شود و سرد شود، خودش دوباره گرم می‌کند. حلقهٔ آشتی (reconcile loop) همان ترموستات است: control plane هرگز مستقیم و بر اثرِ درخواست، مسیرِ داده را دست نمی‌زند؛ فقط حالتِ مطلوب را می‌نویسد، و حلقهٔ آشتی مدام host را به‌سمتِ آن حالت می‌راند. این دقیقاً الگوی کنترلرِ Kubernetes است.

انتخاب‌های فنی کلیدی و چرایی

  • حلقهٔ آشتی به‌جای اعمالِ امری — حالتِ مطلوبِ اعلانی + همگرایی، خودتوان و خودترمیم است؛ اگر host رانش کند (ری‌بوت، تغییرِ دستی)، حلقهٔ بعدی ترمیمش می‌کند. «این قاعده را همین الان اعمال کن»ِ امری، host را بعد از هر شکستِ نیمه‌کاره ناسازگار رها می‌کند.
  • WireGuard — تونلِ مدرن، سریع و با سطحِ حملهٔ کوچک؛ جفت‌کلیدِ به‌ازای هر دستگاه، هویتِ رمزنگارانه می‌دهد — که اتمِ اعتمادِ صفر است.
  • nftables به‌جای iptables — جایگزینیِ اتمیِ کلِ مجموعه‌قواعد، کارایی بهتر، و مجموعه‌های بومی برای ACLهای کارآمد.
  • CoreDNS — DNSِ برنامه‌پذیر برای اعمالِ سیاستِ نامْ‌گشایی (یک دستگاه فقط چیزی را resolve می‌کند که مجاز به رسیدن به آن است).
  • FastAPI — control planeِ ناهمگام، بومیِ اکوسیستمِ سیستم/شبکهٔ Python.

سخت‌ترین چالش فنی

همگراییِ اتمی و بدونِ‌رانشِ مسیرِ داده. برنامه‌ریزیِ WireGuard + nftables + CoreDNS چندگامی است و می‌تواند وسطِ راه شکست بخورد و host را در حالتِ مخلوط رها کند — که یک حفرهٔ امنیتی است (دسترسی داده‌شده اما فایروال هنوز باز). راه‌حل: کلِ مجموعه‌قواعدِ مطلوب محاسبه و به‌صورتِ اتمی اعمال می‌شود (nft -f کلِ جدول را تراکنشی جایگزین می‌کند؛ مجموعهٔ peerهای WireGuard دیف و همگام می‌شود)، هر گام خودتوان می‌شود، و نسلِ (generation) اعمال‌شده نسخه‌دهی می‌شود تا حلقه رانش را تشخیص و ترمیم کند. ترتیب مهم است: اول فایروال را تنگ کن، بعد تونل را باز کن — هرگز برعکس.

ترتیبِ فایروال و تونل: هرگز برعکس

اگر اول تونل باز شود و بعد بخواهد فایروال تنگ شود، در آن فاصلهٔ کوچک، دسترسیِ غیرمجاز برقرار است — یعنی یک حفرهٔ امنیتی. قاعدهٔ ثابت: همیشه پیش از باز کردن، تنگ کن. حالتِ شکستِ ترتیبِ معکوس، نشتِ دسترسی است، نه فقط یک خطای موقت.

مصالحه‌ها

  • تأخیرِ حلقهٔ آشتی — تغییرات فوری نیستند؛ یک پنجرهٔ همگرایی وجود دارد (که با حلقهٔ رویداد-محرک، نه فقط polling، کاهش می‌یابد).
  • control plane به‌عنوانِ تنها منبعِ حقیقت — اگر DB اشتباه باشد، host وفادارانه چیزِ اشتباه را اعمال می‌کند؛ پس درستیِ حالتِ مطلوب حیاتی است و همهٔ تغییرات ممیزی می‌شوند.
چرا حلقهٔ آشتی به‌جای اعمالِ تغییر در لحظهٔ فراخوانیِ API؟

به‌خاطرِ خودتوانی و خودترمیمی: همگرایی، رانش و شکستِ نیمه‌کاره را ترمیم می‌کند؛ اعمالِ امری نمی‌کند و بعد از هر خطا حالتِ مخلوط باقی می‌گذارد. همان تفاوتِ ترموستات و کلیدِ چراغ است.

چه چیزی این را «اعتماد صفر» می‌کند؟

هیچ اعتمادِ ضمنیِ شبکه‌ای نیست؛ هویتِ رمزنگارانهٔ به‌ازای هر دستگاه، ACLهای کمترین‌دسترسی، و سیاستی که در هر جهش (hop) اعمال می‌شود (تونل، فایروال، DNS). برخلافِ یک VPNِ تخت که همین‌که وارد شدی، به همه‌چیز می‌رسی.

چطور تغییراتِ چندابزارهٔ مسیرِ داده را اتمی می‌کنی؟

جایگزینیِ اتمیِ کلِ مجموعه‌قواعد (nft -f)، دیف‌وهمگامِ peerهای WireGuard، گام‌های خودتوان، و نسخه‌دهیِ نسل برای تشخیصِ رانش. یعنی هرگز نیمه‌کاره اعمال نکن — یا کلِ حالتِ جدید یا هیچ.


مطالعهٔ موردیِ ۸ — یک سیستمِ کنترلِ بلادرنگِ IoT + بینایی ماشین

پشته: حرکتِ سر (IMU/BNO085)، چشم/نگاه، MQTT، OpenCV، MediaPipe، Arduino/C++.

مسئله و دامنه

یک واسطِ کمکی که به کاربرانِ با تحرکِ محدود اجازه می‌دهد یک UI را بدونِ دست کنترل کنند، با ترکیبِ حرکتِ سر (IMU) و چشم/نگاه (بینایی ماشینِ دوربین) به رویدادهای نشانگر/انتخاب — حسگر روی یک Arduino، بینایی روی یک host.

معماری و جریان داده

 BNO085 IMU ──I2C──> Arduino/C++ ──MQTT(orientation)──┐
                                                        ▼
 camera ──> host: OpenCV + MediaPipe (face mesh/gaze) ──> sensor fusion
                                                        │ pointer + dwell-select
                                                        ▼
                                            UI control events (MQTT)
IMU و همجوشیِ حسگر: قطب‌نمای هوشمند

یک IMU (واحدِ اندازه‌گیریِ اینرسی) حسگری است که شتاب و چرخش را می‌سنجد. اما دادهٔ خامش نویزی و رانشی است — مثل قطب‌نمایی که کم‌کم کج می‌شود. BNO085 یک IMUِ هوشمند است که همجوشیِ حسگر (sensor fusion) را روی خودِ تراشه انجام می‌دهد: با ریاضیِ سبکِ کالمن‌گونه یک جهت‌گیریِ مطلقِ پایدار و رانش‌تصحیح‌شده (به‌صورتِ کواترنیون) می‌دهد، پس Arduino دیگر لازم نیست ریاضیِ سنگین کند.

BNO085 همجوشیِ حسگر را روی تراشه انجام می‌دهد (یک کواترنیونِ جهت‌گیریِ مطلقِ پایدار می‌دهد، نه شتاب/ژیروی خام)، پس Arduino جهت‌گیریِ تمیز را روی MQTT می‌فرستد؛ host آن را با نگاهِ برآمده از لندمارک‌های face-meshِ MediaPipe ترکیب می‌کند.

انتخاب‌های فنی کلیدی و چرایی

  • BNO085 — یک IMUِ همجوشی‌کننده است: همجوشیِ کالمن‌گونهٔ روی‌برد، جهت‌گیریِ رانش‌تصحیح‌شده (کواترنیون) می‌دهد، MCU را از ریاضیِ سنگین معاف می‌کند و از قفلِ گیمبال (gimbal lock) جلوگیری می‌کند. همجوشیِ خامِ ۶-محوره روی Arduino شکننده می‌بود.
  • MediaPipe — ردیابیِ face-mesh/عنبیهٔ بلادرنگِ سطحِ محصولی روی CPU؛ خیلی سریع‌تر به یک خطِ کاریِ سالم می‌رسد تا آموزشِ مدل‌های سفارشی.
  • MQTT — pub/subِ سبک برای لینک‌های محدود/پرافت؛ سطوحِ QoS اجازه می‌دهند طراح تضمینِ تحویل را به‌ازای هر topic انتخاب کند (جهت‌گیری روی QoS 0 برای تازگی، دستورها روی QoS 1).
  • کواترنیون به‌جای زوایای اویلر — برای اجتناب از قفلِ گیمبال و آرتیفکت‌های درون‌یابی در جهت‌گیریِ سر.
قفلِ گیمبال: وقتی دو محور در هم فرومی‌روند

اگر جهت‌گیری با سه زاویهٔ جدا نمایش داده شود (اویلر: pitch, roll, yaw)، در برخی وضعیت‌ها دو محور روی هم می‌افتند و یک درجهٔ آزادی از دست می‌رود — مثل گیرکردنِ گیمبالِ ژیروسکوپ در قطب؛ این قفلِ گیمبال است. یک کواترنیون نمایشِ چهارعددیِ چرخش است که چنین نقاطِ تکینی ندارد، درون‌یابیِ نرم (slerp) می‌دهد، و از نظرِ عددی پایدار است.

سخت‌ترین چالش فنی

ترکیبِ دو مُدالیتهٔ نویزی و بانرخِ متفاوت به یک نشانگرِ پایدار و بدونِ لرزش. جهت‌گیریِ IMU پرنرخ اما رانشی است؛ نگاه کم‌نرخ‌تر و نویزی است. راه‌حل: فیلترِ مکمل/پایین‌گذر به‌ازای هر جریان، بعد ترکیبِ وزنی (سر برای اشارهٔ درشت، نگاه برای هدف‌گیریِ ریز)، به‌علاوهٔ انتخابِ زمانِ‌مکث (dwell-time) — نگه‌داشتنِ نگاه/اشاره برای N میلی‌ثانیه — با پس‌ماند (hysteresis) برای جلوگیری از کلیکِ تصادفی و لرزشِ نشانگر. بودجهٔ تأخیر بی‌رحم است: هر فیلتر لختی اضافه می‌کند، پس ثابت‌های فیلتر در برابرِ یک هدفِ سرتاسریِ حدودِ ۱۰۰ میلی‌ثانیه تنظیم می‌شوند تا حس‌وحالِ پاسخگو بماند.

پس‌ماند: ترموستاتی که مدام روشن-خاموش نمی‌شود

اگر یک ترموستات دقیقاً روی ۲۲ درجه سوییچ می‌کرد، نزدیکِ آن دما مدام روشن و خاموش می‌شد. پس‌ماند یعنی دو آستانهٔ متفاوت می‌گذاری (مثلاً روشن روی ۲۱، خاموش روی ۲۳) تا بی‌خود نوسان نکند. در نشانگر، پس‌ماند دقیقاً روی مرزِ «انتخاب / عدم‌انتخاب» لرزش را متوقف می‌کند تا یک کلیکِ ناخواسته شلیک نشود.

مصالحه‌ها

  • هموارسازی در برابر تأخیر — فیلترِ بیشتر لرزش را می‌کُشد اما لختی اضافه می‌کند؛ UXِ کمکی روی همین تعادل زنده یا مرده است.
  • پیچیدگیِ ریاضیِ کواترنیون در برابر سادگیِ اویلر — ارزشش را دارد تا از قفلِ گیمبال بگریزی.
  • QoS 1 در MQTT تحویل را تضمین می‌کند اما رفت‌وبرگشت اضافه می‌کند؛ فقط برای دستورها به‌کارش ببر، نه جریانِ پرنرخِ جهت‌گیری.
چرا کواترنیون برای جهت‌گیری؟

چون قفلِ گیمبال ندارند، درون‌یابیِ نرم (slerp) می‌دهند، و فشرده و از نظرِ عددی پایدارند. زوایای اویلر در تکینگی‌های قطب شکست می‌خورند و همان‌جا یک درجهٔ آزادی از دست می‌رود.

چرا BNO085 به‌جای یک MPU خام + همجوشیِ خودنوشت؟

چون BNO085 همجوشیِ حسگر را روی خودِ تراشه اجرا می‌کند و جهت‌گیریِ کالیبره و رانش‌تصحیح‌شده می‌دهد؛ این MCU را سبک می‌کند و پایداری را بالا می‌برد. همجوشیِ دستیِ ۶-محوره روی Arduino شکننده و پرخطاست.

چطور لرزشِ نشانگر را بدونِ افزودنِ لختی می‌کُشی؟

با فیلترِ مکمل/پایین‌گذرِ تنظیم‌شده به یک بودجهٔ تأخیر، به‌علاوهٔ مکث + پس‌ماند برای انتخاب. یعنی به‌جای هموارسازیِ کور (که لختی اضافه می‌کند)، فیلتر را تا لبهٔ بودجهٔ حدودِ ۱۰۰ میلی‌ثانیه تنظیم کن.

انتخابِ QoS در MQTT به‌ازای هر جریان؟

QoS 0 برای جهت‌گیریِ پرنرخ (تازگی بر قابلیتِ اطمینان می‌چربد؛ یک نمونهٔ افتاده فوراً با بعدی جایگزین می‌شود)، و QoS 1 برای دستورهای گسسته (که حتماً باید برسند). یعنی تضمینِ تحویل را فقط جایی خرج کن که واقعاً لازم است.


بستنِ بحث: تم‌های تکرارشونده

اگر هشت مطالعهٔ موردی را از بالا نگاه کنی، چند «سیگنالِ ارشد» را می‌بینی که بارها و بارها سر برمی‌آورند — و همه همان واژه‌هایی‌اند که در بخش صفر ساخته شدند:

  • خودتوانی — در Outbox، حلقه‌های آشتی، دیدوپِ MQTT، کلیدهای تسویه. هرجا پیامی دوباره برسد، خودتوانی نجاتت می‌دهد.
  • فشار برگشتی و ایزوله‌سازی — خط‌های آبِ Netty، نرخ‌گیریِ به‌ازای مستأجر، واگذاری به استخرِ کارگر. هیچ جزئی نباید بقیه را گرسنه بگذارد.
  • سازگاریِ درخورِ کار — قوی برای انبار و پرداخت، نهایی برای جست‌وجو و تحلیل. انتخابِ آگاهانه، نه پیش‌فرض.
  • نام‌بردنِ مصالحه‌ای که پرداخته شده — هر انتخاب هزینه‌ای دارد؛ شناختنِ هزینه ثابت می‌کند انتخاب فهمیده شده.
جمع‌بندی

فهمیدنِ یک سیستم، خواندنِ فهرستِ ابزار نیست؛ دیدنِ تصمیم‌هاست. برای هر مطالعهٔ موردی این پنج بخش را بخوان: مسئله و دامنه (قیدها معماری را تحمیل می‌کنند)، معماری و جریانِ داده (جهتِ داده و یال‌های همگام/ناهمگام را نام ببر)، انتخاب‌ها با چراییِ آن‌ها (همیشه «چون…»)، سخت‌ترین چالش (جایی که سیگنالِ ارشد است)، و مصالحه‌ها (نام‌بردنِ هزینه = فهمِ انتخاب). دو قاعدهٔ کلیدی در هر هشت سیستم مشترک‌اند: «همگام دسترس‌پذیری را جفت می‌کند، ناهمگام شِما را»، و «برای هر جزء بدان زیرِ ۱۰ برابر بار اول چه می‌شکند و چه چیزی جمعش می‌کند». طراحی‌ای که برای هر جزءش دربارهٔ خودتوانی، فشار برگشتی، سازگاریِ درخور و مصالحه‌ها حرفی برای گفتن دارد، دیگر مثل کدِ معمولی خوانده نمی‌شود — مثل کارِ یک مهندسِ اصلی خوانده می‌شود.

Let's start with a habit worth breaking. When most people describe a system, they read out a list of tools — "Spring Boot, Kafka, Redis, Kubernetes…" — as if reciting a shopping receipt. But a tool list explains nothing: it doesn't say why each component exists, what breaks first under load, or what could have been done differently. This chapter takes the opposite approach. It walks through eight reference case studies of real-world-style systems and, for each, unpacks the problem, the architecture, the decisions, the hardest challenge, and the trade-offs — so that the lesson is how such systems are designed, not merely which technologies they use.

Roadmap for this chapter

First comes a five-part lens for reading any system design (problem → architecture → choices → hardest challenge → trade-offs). Then a "Part 0" builds from scratch the handful of words that recur across all eight case studies (idempotence, sync/async, backpressure, consistency, multi-tenancy, Saga, Outbox). Then eight systems are walked through one by one; each keeps its code, diagrams, and technical mechanisms — unpacked — with every design-review question in its own box and a full answer. It closes with the recurring themes that tie all eight together.

Part 0 — words you must know

Before the case studies, let's build, with analogies, a few terms that run like a red thread through all eight. Nail these now and the rest of the chapter flows.

Synchronous vs asynchronous: phone call vs text message

A synchronous call is like a phone call: until the other person answers, the caller is stuck on the line doing nothing. If they're asleep, the caller is stuck too. An asynchronous call is like a text message: it's sent and the sender gets on with the day; the other side replies whenever it can. In software, a synchronous call means service A waits for service B's response (if B falls over, A effectively falls over too); asynchronous means A drops a message on a queue and moves on.

From that analogy comes a golden rule used all chapter:

The golden rule of coupling

Synchronous calls couple availability; asynchronous calls couple schema. A synchronous call ties one service's "being up" to the other side being up. An asynchronous call (via a message) frees a service from the availability constraint — but now both sides must agree on the shape of the message (fields, event version) and stay in sync as it changes. Nearly all eight of these case studies are exercises in choosing which coupling to pay.

Idempotence: the elevator button

An elevator button is idempotent: press it once or ten times, the result is the same — the elevator comes once. An idempotent operation is one whose repetition adds no extra effect. Why does this matter? Because in distributed systems, messages arrive again (retries, network hiccups). If "decrement inventory" isn't idempotent, a duplicate message subtracts twice. The fix: give each message a unique id and have the consumer recognize and ignore duplicates.

Backpressure: the faucet and the glass

Open a high-pressure faucet over a small glass; the glass overflows. Backpressure is the mechanism that tells the sender to slow down because the receiver is filling up. In software, when the data producer runs faster than the consumer, without backpressure memory (heap) overflows and the program bursts. The fixes: a bounded queue, dropping stale data, or a "don't send yet" signal.

Strong vs eventual consistency: the live scoreboard vs the official stats

Strong consistency is like a bank vault: the moment a withdrawal happens, the balance is correct everywhere, instantly; nobody can spend the same coin twice. Eventual consistency is like a video view counter: it lags a few seconds but eventually becomes correct, and that lag doesn't hurt. The art of engineering is choosing the fitting consistency for each thing: strong for money and inventory, eventual for search and analytics.

Multi-tenancy: the apartment building

An apartment building has many tenants who share infrastructure (pipes, power, stairwells) but each needs security and privacy in their own unit. Multi-tenancy means one software instance serves multiple customers (tenants). The perennial challenge: how to isolate them — without building separate infrastructure per tenant — so one tenant's data never leaks to another, and a noisy tenant can't starve the rest.

Outbox and Saga: the post office and the multi-flight trip

Understand Outbox via the post office: instead of simultaneously "writing" a letter and "dropping it in the mailbox" (the power could cut out in between), the writer notes the letter in a ledger, and then a courier regularly checks the ledger and actually mails the letters — so no letter is ever lost. Saga is like a trip with several separate flights: each flight has its own ticket; if the third flight is cancelled, the earlier tickets are refunded (compensation) instead of trying to book the whole trip in one atomic transaction. The details come in the case studies.

The anatomy of a case study

Now the core lens. Studying a system is not about cataloguing what it contains — it's about understanding what it decides: why each component exists, what breaks first under load, and what could be done differently. Every case study below follows this five-part order, worth applying to any system encountered in the wild:

  1. Problem & scope — one sentence on the business problem, one on the non-functional constraints (throughput, latency, tenancy, consistency). Constraints are what force architecture; without constraints, any architecture is "right."
  2. Architecture & data flow — the boxes and the direction of data. Name the synchronous vs asynchronous edges.
  3. Key tech choices & why — never just "it uses Kafka"; always "it uses Kafka because it needs durable, replayable, ordered-per-key event distribution across teams."
  4. Hardest challenge — one deep, real problem and the mechanism that solves it. This is where the genuinely senior design signal lives.
  5. Trade-offs — every choice has a cost. Naming the cost is what proves the choice was actually understood.
A magic sentence for every component

Keep this ready for each piece of architecture: "What breaks first under 10x load, and what handles it?" A design that can answer that for every box is no longer an ordinary design — it reads like the work of a principal engineer.


Case Study 1 — An Internal Platform Framework & Service Scaffolding

Stack: modular Spring Boot, CLI code generator, multi-module Maven (BOM + 20+ modules), OAuth2/OIDC, Redis, RabbitMQ, Resilience4j, Camunda.

Problem & scope

Imagine a pizza chain where every branch reinvents the dough, sauce, and box from scratch — one salty, one bland, none alike. In a large software organization the same thing happens: every team re-implements the same cross-cutting concerns — the jobs unrelated to business logic but needed everywhere: auth, pagination, auditing, error envelopes, the Outbox pattern, resilience — separately and inconsistently in each service. The goal of a platform framework like this is a batteries-included library plus a CLI generator, so a new service starts production-shaped in minutes, with governance baked in.

Architecture & data flow

 CLI generator ──scaffolds──> new service (imports BOM)
                                   │
        ┌──────────── framework modules (starters) ────────────┐
        │ security(OIDC) · web(error-envelope) · data(auditing) │
        │ messaging(RabbitMQ+outbox) · resilience(R4j) · process(Camunda) │
        └───────────────────────────────────────────────────────┘
                                   │
   Redis (cache/rate-limit) ── RabbitMQ (events) ── Camunda (workflows)

Two key terms in this diagram are worth unpacking.

What a BOM is: the chef's coordinated shopping list

BOM stands for Bill of Materials. It's like an official recipe that says "exactly this flour brand version 2, this sauce version 5" — a coordinated set of versions known to work together. In Maven, the BOM pins the version of every library; downstream services just import the BOM and never write versions themselves.

Starter and auto-configuration: the ready-wired outlet

A starter is a module that wires itself up automatically the moment it's imported — like an outlet that turns on the instant something is plugged in, no need to open the wall. In Spring Boot this is called auto-configuration. Each module here is a starter that activates via conditions like @ConditionalOnProperty (if this setting is on) or @ConditionalOnClass (if this class is on the classpath). So importing a module is opt-in behavior, not forced.

Key tech choices & why

  • Multi-module Maven + BOM — the BOM is the single lever for version governance. Downstream services import it in <dependencyManagement> and never specify versions, which kills diamond-dependency drift (two dependency paths reaching the same library at two different versions, with no clear winner — the perennial pain of large orgs).
  • Auto-configuration over inheritance — a framework built on a shared base class forces coupling (everyone must inherit from one parent); starters compose like Lego bricks. This mirrors how Spring Boot itself is built.
  • Resilience4j over Hystrix — Hystrix is in maintenance mode, i.e. it gets no new features. R4j is lightweight, functional, and integrates with Micrometer, so it does metrics-driven circuit breaking. (A circuit breaker is like an electrical fuse: if the downstream service keeps erroring, it "opens" the circuit so callers temporarily stop calling it and give it room to recover.)
  • Camunda — long-running, human-in-the-loop workflows need durable state and compensation; encoding that imperatively is unmaintainable.

Hardest technical challenge

Auto-configuration ordering and conditional correctness. A starter must back off gracefully when the application already defines a bean, and must not eagerly instantiate heavy beans. The fix has several layers:

  • @ConditionalOnMissingBean on every exported bean (i.e. "only create it if the user hasn't").
  • @AutoConfigureAfter/@AutoConfigureBefore to order: security before web, web before data.
  • Registration of starters via the file META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports — which in Boot 3 replaced the old spring.factories mechanism.
  • A test harness using ApplicationContextRunner to assert each starter activates/backs-off under the right property matrix — the only way to keep 20 modules honest.
Magic is magic until it isn't

Heavy auto-config means "it just works"… until the day it doesn't. On that day, a junior engineer cannot trace why a bean even exists. The mitigation: the auto-config report via the --debug flag (which says what activated and why) plus thorough docs. If a system builds magic, it must build its debug flashlight too.

Trade-offs

  • Power vs. magic. As noted, "it just works" costs traceability.
  • Generator drift. Generated code diverges from the template after human edits; so regeneration cannot be a hard overwrite. The sound approach: generate once, and rely on library upgrades (bumping the BOM version) for ongoing changes, not re-scaffolding.
Why a BOM instead of a parent POM?

A parent POM can only be inherited once — because each Maven project has a single <parent>. A BOM is imported in dependencyManagement and can sit alongside the org's own parent. So a BOM governs versions without dictating build config. In short: parent = single inheritance, BOM = composable.

How does a Boot 3 starter get discovered?

Via the file META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports. The key version note: the auto-config keys that used to live in spring.factories were removed in Spring Boot 3 and must migrate to this new file.

How would you prevent a starter from clobbering user beans?

With @ConditionalOnMissingBean. User beans win because auto-config runs last; so if the user defined a bean, the "if it doesn't exist" condition fails and the starter backs off.

When is Camunda the wrong tool?

For short, stateless, high-throughput request/response flows. Camunda persists state to the DB per task and does DB round-trips; that overhead makes it far heavier than a plain in-memory state machine. Reserve Camunda for long-running, durable workflows, not a high-traffic API.


Case Study 2 — A Real-Time Telemetry Ingestion & Tracking System

Stack: Netty (TCP/UDP telemetry), live streaming, PostGIS geofencing, rules alerting, WebSocket, MinIO, MapLibre, React/Next.

Problem & scope

Consider a fleet-tracking system where thousands of devices emit position and telemetry (sensor data like speed, fuel, temperature) over raw TCP/UDP using device-specific binary protocols. The system must decode at line rate — as fast as data arrives, without falling behind — evaluate geofence and rule alerts in near real-time, and stream live positions to dashboards.

Architecture & data flow

 devices ──TCP/UDP binary──> Netty ingestion (codec per protocol)
                                  │ normalize → Position event
                                  ▼
        rules/geofence engine (PostGIS ST_Contains, ST_DWithin)
                                  │ alerts
        ┌─────────────┬──────────┴───────────┐
        ▼             ▼                        ▼
   WebSocket hub   time-series store       MinIO (raw frames)
        │
   MapLibre / Next.js dashboards (live markers)
Netty's event-loop model: one deft waiter vs one waiter per table

The naive model gives each customer (connection) their own waiter (thread); with thousands of customers there are thousands of waiters, most idle, drawing a salary of memory. Netty is like one deft waiter using an event loop: a small number of threads (roughly the CPU core count) that keep circling all the tables and only attend to a table when something is ready. This is non-blocking I/O: no thread ever wastes itself waiting behind one slow connection.

Raw frames pass through a pipeline: frame decoder → protocol codec → handler. The output is a clean Position event, fanned out to both the rules engine and the WebSocket hub.

Key tech choices & why

  • Netty — device protocols are binary, framed, and stateful over long-lived sockets. A servlet container (built for short HTTP requests) is the wrong model. Netty gives non-blocking I/O with a small fixed thread pool and precise pipeline control.
  • PostGIS — spatial predicates like ST_Contains (is this point inside this polygon?) and ST_DWithin (is it within a given distance?) with a GiST index turn geofence membership into a single indexed query, not heavy app-side polygon math.
  • MapLibre over Google Maps — open-source, self-hostable tiles, no per-load licensing — critical for an offline/on-prem fleet product.
  • MinIO — S3-compatible object store for raw device frames (audit/replay) without cloud lock-in.

Hardest technical challenge

Backpressure and slow consumers. Devices never stop sending; but a slow WebSocket client or a stalled DB write must not blow up the heap (remember: faucet and glass). The fix has several parts:

  • Netty's Channel.isWritable() plus high/low watermarks — thresholds that signal when a connection's buffer is full so writes stop; per connection the system then either buffers or drops.
  • Decoupling ingestion from consumers via a bounded queue.
  • Dropping stale positions with last-write-wins per vehicle, instead of queueing every point for a lagging dashboard. For the live view, the newer position always supersedes the old.
  • Moving geofence evaluation off the event loop onto a bounded worker pool, so a slow PostGIS query never stalls the I/O loop.

Trade-offs

  • UDP loses packets; TCP adds latency & connection state. Both are supported because some trackers only speak UDP. The UDP path tolerates loss (positions are idempotent snapshots, the next one replaces the last); the TCP path gets ordered delivery.
  • Last-write-wins drops intermediate points for the live view, but the full track is persisted separately — live and historical have different consistency needs.
Why Netty and not Spring MVC/WebFlux for ingestion?

Because devices speak raw, framed binary protocols over persistent sockets, not HTTP. Netty gives pipeline-level codec control and non-blocking I/O tuned to high connection counts; a generic HTTP stack does not.

How would you scale geofencing to thousands of fences?

Build a GiST spatial index on the fence geometries, then query with ST_DWithin/ST_Contains passing the point as a parameter. The index turns a linear test of every polygon (which grows with fence count) into a log-time lookup.

How would you handle a slow dashboard client?

With watermarks and channel writability checks; shed stale updates and never let one slow client backpressure the ingestion loop. The health of the whole system beats the completeness of one lagging dashboard.

UDP vs TCP for telemetry — trade-off?

UDP: lower overhead and tolerates loss, good for idempotent snapshots (a dropped sample is instantly superseded by the next). TCP: guaranteed ordering and delivery, but at the cost of per-connection state and head-of-line blocking — one lost packet holds up later packets until it's retransmitted.

(Gotcha) Where should rules be evaluated — on the event loop?

No, never. Don't block the event loop with a DB or spatial query. Offload it to a bounded worker pool. Blocking on the event loop gets those few precious threads stuck and the whole ingestion throughput collapses. This is the classic event-driven-programming trap.


Case Study 3 — A Collaborative Design SaaS (event-driven microservices)

Stack: contract-first microservices, OpenAPI/gRPC, Kafka (Outbox/Saga), PostgreSQL db-per-service, ClickHouse, Elasticsearch, K8s/Helm.

Problem & scope

A Canva/Figma-style editor: users compose designs from layers, assets, and templates. The backend must serve a collaborative editor, index assets for search, track analytics at massive scale, and coordinate multi-step operations (export, publish) across services.

Architecture & data flow

 client ──gRPC/REST(OpenAPI)──> API gateway
        ┌──────────┬──────────┬──────────┐
        ▼          ▼          ▼          ▼
   design-svc  asset-svc  export-svc  analytics-svc
   (PG own DB)(PG own DB)(PG own DB) (ClickHouse)
        │ outbox tables
        ▼
   Kafka (domain events) ──> Elasticsearch indexer (asset search)
                          └─> ClickHouse ingest (events/metrics)
   Saga orchestrator coordinates export/publish across services
Db-per-service: each shop its own till

Picture a large bazaar where every shop has its own till and account book, and no shop may reach into the neighbor's drawer. That's db-per-service: each service owns its own schema and there is no cross-service SQL. To share information, services only do it through the front door (an API) or by sending a receipt (a Kafka event).

Contract-first: the blueprint before the bricks

Contract-first means the contract is written first — a formal document stating each API's inputs/outputs (with OpenAPI for REST, or gRPC) — and teams integrate against it before a single line of code is written. Like a plumber and an electrician both working from one shared blueprint, not their own taste.

Key tech choices & why

  • Contract-first (OpenAPI/gRPC) — teams integrate against a versioned contract before the code exists. gRPC for low-latency internal service-to-service; OpenAPI/REST at the edge for browser clients.
  • Outbox pattern — this solves the classic dual-write problem: "commit to DB" and "publish to Kafka" cannot be atomic (a crash between them leaves one done and the other not). The fix: write the event to an outbox table in the same transaction, then a relay (Debezium or a poller) ships it to Kafka. Guarantee: at-least-once, no event lost.
  • Saga over 2PC — export/publish spans several services. 2PC (two-phase commit) across microservices is a scalability and availability trap (everyone must stay locked until all are ready). Sagas use local transactions + compensating actions.
  • ClickHouse for analytics, Elasticsearch for search, PostgreSQL for OLTP — three engines because their access patterns are incompatible: transactional row writes (OLTP), columnar analytical scans, and inverted-index full-text.

Hardest technical challenge

Exactly-once effect across the Outbox+Kafka+consumer chain. The transport is at-least-once, so consumers will see duplicates (relay retries, partition rebalances). The fix: idempotent consumers keyed on the event's unique id (a dedup table or upsert), and idempotent downstream operations (ClickHouse dedup via ReplacingMergeTree, Elasticsearch upserts by document id). The design stops chasing exactly-once delivery (impossible) and engineers exactly-once processing through idempotency.

Exactly-once: delivery vs processing

Burn this distinction in: exactly-once delivery is impractical in the real world — the network can always redeliver. But exactly-once processing is achievable: let the message arrive as many duplicate times as it likes, but build the consumer so duplicates leave no extra effect (i.e. it's idempotent). This is exactly the difference between an ordinary engineer and a senior one in distributed-system design.

Trade-offs

  • Db-per-service kills joins. Cross-service reads become API calls or read-model projections — more moving parts and eventual consistency. In return: independent deploy and scaling per service.
  • Saga complexity. Compensations are business logic ("un-publish," "refund credits") and must be designed, tested, and monitored. There is no free rollback.
  • Three data stores multiply the operational burden and need CDC/streaming to stay consistent.
Why Outbox instead of publishing to Kafka directly after commit?

Because a crash between commit and publish loses the event. Outbox makes the event part of the same ACID transaction: either both the record and the event are written, or neither. The relay then ships it to Kafka safely afterward.

Exactly-once with Kafka — real or myth?

Exactly-once delivery end-to-end is practically impossible. But exactly-once processing is achieved with idempotent consumers and dedup keys. So the correct answer is: "Not delivery, but processing effect, yes — through idempotency."

Why ClickHouse and not Postgres for analytics?

Because columnar storage plus vectorized execution scan billions of rows for aggregations far faster. The MergeTree engine is built for append-heavy, immutable event data — exactly the analytics data pattern. Postgres is great for transactional row writes, but not for huge analytical scans.

How does a Saga handle a failed step 3 of 4?

It runs the compensating transactions for steps 1 and 2 in reverse order; each service exposes an "undo" operation. The Saga's overall state is tracked either by an orchestrator, or by choreography — services coordinating themselves by exchanging events.

(Gotcha) Kafka guarantees ordering — globally?

No. Only per partition. Order-sensitive events must share a partition key (e.g., designId) so they land in one partition; otherwise their order is lost across partitions. This is the most common misconception about Kafka.


Case Study 4 — A Multi-Tenant SaaS with Write-Heavy Analytics

Stack: multi-tenant, Spring Cloud Gateway, PostgreSQL/PostGIS, ScyllaDB, Redis, RabbitMQ, Camunda BPM, Python/FastAPI.

Problem & scope

Schedule and publish posts across many social channels for many tenants, then ingest and analyze high-volume engagement data (likes, reach, comments) per tenant. Two things dominate the whole design: multi-tenancy and write-heavy analytics.

Architecture & data flow

 tenant ──> Spring Cloud Gateway (auth, tenant routing, rate-limit)
                 │
   scheduling-svc ──Camunda(publish workflow, retries/backoff)──> channel adapters (FastAPI)
                 │                                                     │ external APIs
   RabbitMQ (jobs/events)                                    ScyllaDB (engagement time-series)
                 │                                                     │
   PostgreSQL/PostGIS (tenant/config/geo)          Redis (rate-limit, token cache, hot counters)

Key tech choices & why

  • ScyllaDB — engagement metrics are massive, write-heavy, time-series, queried by a (tenant, channel, time) key. A wide-column store with tunable consistency and no single master out-scales Postgres for this pattern. Scylla is a Cassandra-compatible engine written in C++ with lower latency and per-shard threading.
  • Spring Cloud Gateway — a reactive, non-blocking edge; a central place for tenant resolution, JWT validation, and per-tenant rate limiting (Redis-backed).
  • Camunda BPM — publishing is a long, failure-prone workflow (rate limits, token refresh, retries with backoff, human approval). Modeling it as BPMN gives visibility and durable retry state.
  • Python/FastAPI adapters — social platform SDKs and tooling are Python-first. FastAPI isolates these volatile third-party integrations from the JVM core.

Hardest technical challenge

Tenant isolation without a per-tenant infrastructure explosion. (Remember: the apartment building.) A discriminator model works well: shared schema with a tenant_id column, plus row-level enforcement in Postgres, ScyllaDB partition keys prefixed by tenant, and Redis key namespacing — plus per-tenant rate limits at the gateway so a noisy tenant can't starve others (the noisy neighbor problem). The subtle part: token-bucket rate limiting must be distributed (a Redis Lua script for atomic check-and-decrement), or each gateway replica grants a full separate bucket and the limit becomes meaningless.

The local-rate-limit trap

If each gateway replica keeps its own token bucket, then with five replicas a client effectively gets five times the limit. Rate limiting in a multi-replica system must be central and atomic — hence the bucket lives in Redis with an atomic check-and-decrement via a Lua script.

Trade-offs

  • Shared-schema tenancy is cheap and scalable, but a single bug can leak one tenant's data to another; so every query must be tenant-scoped, in code and ideally at the DB.
  • ScyllaDB demands query-first data modeling — a table per query, heavy denormalization, and no ad-hoc joins.
  • Eventual consistency on analytics counters — hot counters in Redis are fast but are reconciled asynchronously against Scylla.
Multi-tenancy models — which and why?

Three models: Silo (db-per-tenant, strongest isolation but costly), Bridge (schema-per-tenant, the middle ground), Pool (shared schema + tenant_id, the cheapest and most scalable). Pool is the usual choice for scale, with its weaker isolation compensated by strict tenant scoping.

Why ScyllaDB over Cassandra?

It's API-compatible, but built in C++ with a shard-per-core architecture on the seastar framework instead of running on the JVM. Result: lower and more predictable p99 latency, and better hardware utilization — because it has no GC (garbage collector) pauses, which are the latency plague of JVM-based Cassandra.

Why Camunda for publishing rather than a queue + cron?

Because Camunda gives durable per-instance state, visual retry/compensation, SLA timers, and human tasks. A plain queue + cron gives none of that workflow visibility or long-lived state; it just moves the message.


Case Study 5 — An Event-Driven Content Delivery Platform

Stack: event-driven, Spring Cloud (Eureka/Config/Gateway), PostgreSQL, RabbitMQ, MinIO, JWT/RBAC, Docker.

Problem & scope

An online learning platform: courses, enrollments, content delivery, progress tracking. Classic microservices with service discovery and centralized config; content (video/PDF) stored as objects; access controlled by role.

Architecture & data flow

 client ──> Gateway ──(discovery via Eureka)──> {course, enroll, content, user}-svc
                            │                          │
                    Config Server (central)     PostgreSQL per concern
                            │
                    RabbitMQ (enrolled → grant-access, progress events)
                            │
                    MinIO (course assets, pre-signed URLs)
Service discovery and Eureka: the live phone book

When services keep spinning up and down and their addresses change, how does anyone know where they are right now? Eureka is like a live phone book: each service registers itself when it starts and is removed when it stops. Config Server is like a central notice board that holds everyone's settings in one place, so changing a setting doesn't require redeploying the service (via a scope called refresh).

Key tech choices & why

  • Eureka + Config Server — client-side service discovery and externalized config are the Spring Cloud Netflix baseline; services register/deregister dynamically and config changes without redeploy.
  • RabbitMQ over Kafka — for this scale, per-message routing (topic/direct exchanges) and simple work-queue semantics with acks and DLQ make RabbitMQ a better fit than Kafka's log-and-offset model; neither replay nor massive throughput is needed here. (DLQ = Dead-Letter Queue — where a message that failed several times goes to be examined later.)
  • MinIO + pre-signed URLs — the app never proxies large video bytes; it issues a time-limited pre-signed URL and the client fetches directly from object storage, so the JVM stays light.
  • JWT/RBACstateless auth at the gateway; the roles inside the token drive method-level @PreAuthorize. (RBAC = Role-Based Access Control.)

Hardest technical challenge

Consistent access grants across an event-driven flow. Enrollment (enroll-svc) must grant content access (content-svc) without a synchronous cross-service write. The design emits an EnrollmentCompleted event; content-svc consumes it and materializes an access grant. The dangerous race: a user hits content before the event is processed. Fix: the enroll response returns 200 only after the local transaction commits, the UI shows "processing," content-svc consumers are idempotent, and a fallback synchronous entitlement check covers the first access. Eventual consistency, made acceptable with UX and idempotency.

Trade-offs

  • Eureka/Config add infrastructure — and Spring Cloud Netflix is largely maintenance-mode; the modern alternative is Kubernetes-native discovery. So this choice is justified when running outside K8s.
  • Event-driven grants trade immediate consistency for decoupling; they need idempotent consumers and DLQ handling.
RabbitMQ vs Kafka — when RabbitMQ?

When rich routing, per-message ack/redelivery, priority/DLQ, and moderate throughput without replay are wanted. Kafka wins for high-throughput, replayable, ordered log streaming. In other words: RabbitMQ = smart work queue, Kafka = durable event log.

Why pre-signed URLs?

To keep large-object bytes out of the application tier; the object store handles the transfer and the app only authorizes. This drastically cuts server CPU and bandwidth.

JWT — how would you revoke it?

Pure JWT is hard to revoke (it's self-contained and stateless). The remedy: short TTLs + refresh tokens, or a revocation blocklist in Redis checked at the gateway. Either make it short-lived, or keep a small bit of revocation state.

Eureka in 2026 — still?

On Kubernetes, prefer native Services and DNS. Eureka fits VM/non-K8s deployments. And note that Spring Cloud Netflix components are maintenance-only (bug fixes, no new features).


Case Study 6 — An E-Commerce Order & Checkout Platform

Stack: Spring Boot microservices behind an API gateway; storefront + panel + admin surfaces.

Problem & scope

Catalog, cart, checkout, orders, and payments for a specialty retailer, with three frontends (customer storefront, seller/vendor panel, internal admin) fronted by one gateway.

Architecture & data flow

 storefront ┐
 panel      ┼──> API Gateway (auth, routing, rate-limit, aggregation)
 admin      ┘         │
        ┌────────┬────┴────┬─────────┬──────────┐
     catalog   cart      order    payment    inventory
        │        │          │         │          │
      each owns its data; order orchestrates checkout

Key tech choices & why

  • API Gateway as the single edge for three clients — centralizes auth, CORS, rate limiting, and backend-for-frontend aggregation so each frontend hits one origin.
  • Service-per-bounded-context — catalog, cart, order, payment, inventory have different consistency and scaling needs (catalog is read-heavy and cacheable; inventory needs strong consistency to avoid overselling).
  • Order as orchestrator — checkout spans cart → inventory reserve → payment → order create; a saga-style orchestration with compensation (release reservation, refund) keeps it consistent without 2PC.

Hardest technical challenge

Preventing overselling under concurrency. Two buyers race for the last unit. A naïve read-then-write oversells. The fix: reserve inventory with an atomic conditional update:

UPDATE inventory SET qty = qty - 1 WHERE sku = ? AND qty >= 1

This is atomic and returns the number of rows affected (if zero, there was no stock). The alternative: optimistic locking with a @Version column and retry. The reservation is time-boxed; if payment fails or times out, a compensating action releases it. This turns a distributed correctness problem into a single-row atomic guard plus saga compensation.

Optimistic vs pessimistic locking: editing a shared document

Pessimistic locking means locking the room before editing so no one else can enter (they wait). Optimistic locking means everyone edits freely, but on save the system checks a version number (@Version); if someone saved earlier, it says "your version is stale, retry." For low-contention data, optimistic is faster because nobody waits around for nothing.

Trade-offs

  • Strong inventory consistency vs. throughput. The atomic guard serializes on hot SKUs (everyone queues behind one row); for flash-sale scale the stock would be sharded into buckets or moved to a reservation queue.
  • Gateway as aggregator risks becoming a fat, coupled layer; keep aggregation thin or use a dedicated BFF per client.
How would you prevent double-charging on a checkout retry?

With an idempotency key per checkout attempt; the payment service dedups on it, so a retried request never charges twice. This is exactly the idempotence built in Part 0.

Where would you store the cart?

Usually in Redis (fast, with a TTL for abandonment), with the source of truth being order creation. The cart is ephemeral, the order is durable. That's fitting consistency: the cart isn't kept strongly consistent because losing it is no disaster.

How would you avoid overselling?

An atomic conditional decrement (the UPDATE ... WHERE qty >= 1) or optimistic @Version with retry, plus a time-boxed reservation and compensating release. It reduces a distributed problem to a single-row guard.

Where does eventual consistency bite in e-commerce?

Catalog/search indexes, recommendations, and analytics tolerate eventual consistency — that's fine. But never in payment capture or inventory decrement, which must be strongly consistent. Choosing the fitting consistency is the heart of this system.


Case Study 7 — A Zero-Trust Network Access Control Plane

Stack: Python/FastAPI, WireGuard, nftables, CoreDNS, reconcile loop from DB state, React/Electron.

Problem & scope

Replace flat VPN access with zero-trust: every user/device gets least-privilege network access, enforced by dynamically programming WireGuard peers, nftables firewall rules, and CoreDNS — all derived from desired state in a database. An Electron client manages the tunnel.

Architecture & data flow

 admin/UI (React/Electron) ──> FastAPI control plane ──> Postgres (desired state)
                                        │
                            reconcile loop (observe DB → converge host)
                                        │
        ┌──────────────┬───────────────┼───────────────┐
        ▼              ▼                ▼               ▼
   WireGuard peers  nftables rules   CoreDNS zones   audit log
   (per-device key) (least-priv ACL) (name policy)
The reconcile loop: a thermostat, not a light switch

A light switch is imperative: "turn on now." But if the power flickers mid-task, the switch doesn't know and the light stays off. A thermostat is different: a desired temperature is set (say 22°) and it continuously measures the actual temperature and converges toward the target — if a window opens and it gets cold, it heats again on its own. The reconcile loop is that thermostat: the control plane never touches the datapath directly on request; it only writes desired state, and the reconcile loop continuously drives the host toward it. This is exactly the Kubernetes-controller pattern.

Key tech choices & why

  • Reconcile loop over imperative apply — declarative desired state + convergence is idempotent and self-healing; if the host drifts (reboot, manual change), the next loop repairs it. Imperative "apply this rule now" leaves the host inconsistent after any partial failure.
  • WireGuard — a modern, fast, small-attack-surface tunnel; per-device keypairs give cryptographic identity — the atom of zero-trust.
  • nftables over iptables — atomic replacement of the whole ruleset, better performance, and native sets for efficient ACLs.
  • CoreDNS — programmable DNS to enforce name-resolution policy (a device only resolves what it's allowed to reach).
  • FastAPI — an async control plane, native to the Python systems/networking ecosystem.

Hardest technical challenge

Atomic, drift-free convergence of the datapath. Programming WireGuard + nftables + CoreDNS is multi-step and can fail partway, leaving the host in a mixed state that is a security hole (access granted but the firewall not yet tightened). The fix: compute the full desired ruleset and apply it atomically (nft -f replaces the whole table transactionally; the WireGuard peer set is diffed and synced), make every step idempotent, and version the applied generation so the loop detects and repairs drift. Ordering matters: tighten the firewall before opening the tunnel, never the reverse.

Firewall-then-tunnel ordering: never reverse it

If the tunnel opens first and only then the firewall tightens, in that tiny window unauthorized access is live — a security hole. The fixed rule: always tighten before opening. The failure mode of the reverse order is access leakage, not just a transient error.

Trade-offs

  • Reconcile-loop latency — changes aren't instant; there's a convergence window (mitigated with event-triggered loops, not just polling).
  • Control plane as the single source of truth — if the DB is wrong, the host faithfully enforces the wrong thing; so correctness of desired state is paramount, and all mutations are audited.
Why a reconcile loop instead of applying changes on the API call?

For idempotency and self-healing: convergence repairs drift and partial failures; imperative apply doesn't and leaves a mixed state after any error. It's the same difference as thermostat vs light switch.

What makes this "zero-trust"?

No implicit network trust; per-device cryptographic identity, least-privilege ACLs, and policy enforced at every hop (tunnel, firewall, DNS). Unlike a flat VPN where once you're in, you can reach everything.

How would you make multi-tool datapath changes atomic?

Full-ruleset atomic replace (nft -f), diff-and-sync for WireGuard peers, idempotent steps, and generation versioning for drift detection. That is: never apply partially — either the whole new state or nothing.


Case Study 8 — A Real-Time IoT + Computer-Vision Control System

Stack: head-motion (IMU/BNO085), eye/gaze, MQTT, OpenCV, MediaPipe, Arduino/C++.

Problem & scope

An assistive interface that lets users with limited mobility control a UI hands-free, by fusing head motion (IMU) and eye/gaze (camera CV) into pointer/selection events — the sensor on an Arduino, the vision on a host.

Architecture & data flow

 BNO085 IMU ──I2C──> Arduino/C++ ──MQTT(orientation)──┐
                                                        ▼
 camera ──> host: OpenCV + MediaPipe (face mesh/gaze) ──> sensor fusion
                                                        │ pointer + dwell-select
                                                        ▼
                                            UI control events (MQTT)
The IMU and sensor fusion: the smart compass

An IMU (Inertial Measurement Unit) is a sensor that measures acceleration and rotation. But its raw data is noisy and drifting — like a compass that slowly goes crooked. The BNO085 is a smart IMU that does sensor fusion on the chip itself: with lightweight Kalman-style math it outputs a stable, drift-corrected absolute orientation (as a quaternion), so the Arduino no longer needs to do heavy math.

Key tech choices & why

  • BNO085 — it's a sensor-fusion IMU: onboard Kalman-style fusion outputs drift-corrected orientation (a quaternion), sparing the MCU heavy math and avoiding gimbal lock. Raw 6-axis fusion on an Arduino would be fragile.
  • MediaPipe — production-grade, real-time face-mesh/iris tracking on CPU; far faster to a working pipeline than training bespoke models.
  • MQTT — lightweight pub/sub for constrained/lossy links; QoS levels let a designer choose the delivery guarantee per topic (orientation at QoS 0 for freshness, commands at QoS 1).
  • Quaternions over Euler angles — to avoid gimbal lock and interpolation artifacts in head orientation.
Gimbal lock: when two axes collapse into one

If orientation is represented with three separate angles (Euler: pitch, roll, yaw), in certain postures two axes line up and a degree of freedom is lost — like a gyroscope gimbal jamming at the pole; this is gimbal lock. A quaternion is a four-number representation of rotation that has no such singular points, gives smooth interpolation (slerp), and is numerically stable.

Hardest technical challenge

Fusing two noisy, different-rate modalities into a stable, jitter-free pointer. IMU orientation is high-rate but drifts; gaze is lower-rate and noisy. The fix: complementary/low-pass filtering per stream, then weighted fusion (head for coarse pointing, gaze for fine targeting), plus dwell-time selection — holding gaze/point for N ms — with hysteresis to prevent accidental clicks and cursor jitter. The latency budget is brutal: every filter adds lag, so the filter constants are tuned against a ~100 ms end-to-end target to keep it feeling responsive.

Hysteresis: a thermostat that doesn't flicker on and off

If a thermostat switched exactly at 22°, it would click on and off constantly near that temperature. Hysteresis means setting two different thresholds (say, on at 21°, off at 23°) so it doesn't oscillate needlessly. In the pointer, hysteresis stops jitter right at the "select / don't select" boundary so an unwanted click doesn't fire.

Trade-offs

  • Smoothing vs. latency — more filtering kills jitter but adds lag; assistive UX lives and dies on this balance.
  • Quaternion math complexity vs. Euler simplicity — worth it to escape gimbal lock.
  • MQTT QoS 1 guarantees delivery but adds round-trips; use it only for commands, not the high-rate orientation stream.
Why quaternions for orientation?

Because they have no gimbal lock, give smooth interpolation (slerp), and are compact and numerically stable. Euler angles fail at the pole singularities, losing a degree of freedom right there.

Why the BNO085 rather than a raw MPU + custom fusion?

Because the BNO085 runs sensor fusion on the chip itself and outputs calibrated, drift-corrected orientation; this offloads the MCU and improves stability. Hand-rolled 6-axis fusion on an Arduino is fragile and error-prone.

How would you kill pointer jitter without adding lag?

With complementary/low-pass filtering tuned to a latency budget, plus dwell + hysteresis for selection. That is: instead of blind smoothing (which adds lag), tune the filter right up to the edge of the ~100 ms budget.

MQTT QoS choice per stream?

QoS 0 for high-rate orientation (freshness beats reliability; a dropped sample is instantly superseded by the next), and QoS 1 for discrete commands (which must arrive). Spend the delivery guarantee only where it's truly needed.


Closing: the recurring themes

Looking across the eight case studies, a few "senior signals" surface again and again — all the same words built in Part 0:

  • Idempotency — in Outbox, reconcile loops, MQTT dedup, checkout keys. Wherever a message arrives again, idempotency saves the day.
  • Backpressure and isolation — Netty watermarks, per-tenant rate limits, worker offload. No one component may starve the rest.
  • The right consistency for the job — strong for inventory and payments, eventual for search and analytics. A deliberate choice, not a default.
  • Naming the trade-off paid — every choice has a cost; knowing the cost proves the choice was understood.
In a nutshell

Understanding a system is not reciting a tool list; it's seeing the decisions. For each case study, read these five parts: problem & scope (constraints force the architecture), architecture & data flow (name the sync/async edges), choices with their why (always "because…"), the hardest challenge (where the senior signal is), and the trade-offs (naming the cost = understanding the choice). Two rules carry across all eight: "sync couples availability, async couples schema," and "for each component, know what breaks first under 10x load and what handles it." A design that speaks to idempotency, backpressure, fitting consistency, and trade-offs everywhere reads not like ordinary code — but like the work of a principal engineer.