Microservices (Java/Spring) · میکروسرویس سنیورSenior ~49 دقیقه مطالعه~43 min read
میکروسرویس: مبانی، مرزبندی و کِی/چراMicroservices: Foundations, Boundaries & When/Why
میکروسرویس یک تصمیم سازمانی است نه فنی؛ این فصل به تو یاد میدهد کِی، چرا و چطور یک سیستم را با مرزبندی درست (bounded context، مالکیت داده، قانون Conway) بشکنی و کِی اصلاً نشکنی.Microservices is an organizational decision before a technical one; this chapter teaches you when, why, and how to split a system with correct boundaries (bounded contexts, data ownership, Conway's law) — and when not to split at all.
بذار با یک اعتراف شروع کنم که خیلی از سنیورها دیر میفهمند: میکروسرویس یک تصمیم معماری نیست، یک تصمیم سازمانی است. تو معماری را نمیشکنی چون «تمیزتر» است؛ میشکنی چون تیمهایت، چرخهٔ استقرار (deploy) ات، و مقیاسِ متفاوتِ بخشهای مختلف سیستم، تو را مجبور میکنند. اگر این جمله را از این فصل با خودت ببری، از نصف مهندسهای بازار جلوتری.
در این فصل قرار نیست فقط بگویم «میکروسرویس چیست». قرار است یاد بگیری مثل یک سنیور دربارهٔ آن قضاوت کنی: هزینهٔ واقعیاش را ببینی، مرز درست را با DDD پیدا کنی، بفهمی کِی مونولیت (monolith) انتخابِ بالغانهتر است، و در مصاحبه بتوانی تصمیمت را با اعداد و trade-off توجیه کنی، نه با کلمات مد روز.
۱) میکروسرویس در یک تصویر ساده چیست و چه مشکلی را حل میکند. ۲) سه گزینه: مونولیت، مونولیت ماژولار (modular monolith)، میکروسرویس — و کِی هرکدام. ۳) هزینهٔ پنهانِ توزیعشدن: عملیات، دیباگِ توزیعشده، داده، و «هشت دروغِ محاسبات توزیعشده». ۴) مرزبندی درست با DDD (bounded context) و قانون Conway. ۵) مالکیت داده: چرا هر سرویس دیتابیس خودش را دارد. ۶) دوازدهعامل (12-factor) و توپولوژی معمول Java/Spring با دیاگرام. ۷) مهاجرت با الگوی Strangler Fig از یک مونولیت. ۸) مالکیت تیمی و چارچوبِ تصمیمگیریِ یک سنیور.
۱) میکروسرویس در یک تصویر
یک رستورانِ کوچک را تصور کن که یک آشپز همهکاره دارد: سالاد، استیک، دسر، همه با یک نفر. سریع، ساده، هماهنگی صفر — چون فقط یک مغز درگیر است. این مونولیت است.
حالا یک رستورانِ بزرگ را تصور کن با ایستگاههای جدا: ایستگاه گریل، ایستگاه دسر، ایستگاه سالاد. هر ایستگاه متخصص خودش، ابزار خودش، و ریتم خودش را دارد. خروجی بالاتر است، اما حالا به یک «سرآشپز هماهنگکننده» (expediter)، به سیستم صدازدنِ سفارش، و به هماهنگی نیاز داری. این میکروسرویس است: سرعت و استقلالِ بیشتر، به قیمتِ هماهنگیِ بیشتر.
میکروسرویس یعنی: بهجای یک برنامهٔ واحد که همهٔ منطق کسبوکار را در یک فرآیند (process) و معمولاً یک دیتابیس نگه میدارد، سیستم را به مجموعهای از سرویسهای کوچک، مستقل و قابلِاستقرارِ جداگانه میشکنی که هرکدام:
- یک قابلیت کسبوکار (business capability) مشخص را در اختیار دارند (مثلاً «سفارشها»، «پرداخت»، «موجودی انبار»).
- دیتابیس خودشان را دارند و کسِ دیگری مستقیم به آن دست نمیزند.
- از طریق API روی شبکه (HTTP/gRPC) یا پیام روی broker با هم حرف میزنند.
- توسط یک تیم توسعه، استقرار و پایش میشوند و مستقل از بقیه release میشوند.
آن کلمهٔ آخر — «مستقل release میشوند» — قلبِ ماجراست. اگر برای دیپلویِ سرویس A مجبوری سرویس B را هم همزمان دیپلوی کنی، تو میکروسرویس نداری؛ یک مونولیتِ توزیعشده داری، که بدترینِ هر دو دنیاست.
اگر سرویسهایت را نمیتوانی جدا از هم دیپلوی کنی، یا هر تغییری زنجیرهای از تغییرِ همزمان در چند سرویس میطلبد، تو تمام هزینهٔ توزیعشدن (شبکه، عملیات، دیباگ) را پرداختهای ولی هیچکدام از مزایایش (استقلالِ استقرار، استقلالِ تیمی) را نگرفتهای. این شایعترین شکستِ معماری میکروسرویس در دنیای واقعی است.
۲) سه گزینه، نه دو گزینه
بازار عادت دارد دنیا را دوقطبی ببیند: «مونولیتِ بد» در برابر «میکروسرویسِ مدرن». این یک دوگانهٔ کاذب است. گزینهٔ سومی هست که اغلب برندهٔ واقعی است: مونولیت ماژولار.
مونولیت: یک واحدِ استقرار
مونولیت یعنی همهٔ کد در یک codebase، یک واحدِ build، یک فرآیندِ در حالِ اجرا. این «بد» نیست — سادگیِ عملیاتیِ بینظیری دارد: یک لاگ، یک تراکنش دیتابیس، یک جای دیپلوی، و فراخوانی بین ماژولها یک method call در همان JVM است (نانوثانیه، بدون شبکه، بدون serialization، بدون خطای شبکه).
وقتی هنوز نمیدانی محصولت چه شکلی است، مرزهای دامنهات هر هفته جابهجا میشوند. جابهجا کردن مرز درون یک مونولیت یعنی refactor کردن چند کلاس. جابهجا کردن همان مرز بین دو میکروسرویس یعنی نوشتن API نسخهبندیشده، مهاجرت داده، و هماهنگی دو تیم. سنیورِ باتجربه در مرحلهٔ ابهام، مونولیت را انتخاب میکند تا مرزها ارزان بمانند.
مونولیت ماژولار: یک واحدِ استقرار، مرزهای سفتوسخت
مونولیت ماژولار همان مونولیت است، اما با مرزهای ماژولاری که کامپایلر آنها را تحمیل میکند. ماژولها فقط از طریق API عمومیِ همدیگر حرف میزنند، به جدولهای دیتابیسِ هم دست نمیزنند، و وابستگیهایشان کنترلشده است — دقیقاً همان انضباطِ مرزیِ میکروسرویس، اما بدون هزینهٔ شبکه.
در اکوسیستم Spring، پروژهٔ Spring Modulith دقیقاً برای همین ساخته شده: مرز ماژولها را در زمان تست verify میکند، رویدادهای بین ماژولی را مدیریت میکند، و اگر ماژولی به داخلیهای ماژول دیگر دست بزند، تستهایت قرمز میشوند.
// یک تست Spring Modulith که مرزهای ماژول را تضمین میکند
import org.springframework.modulith.core.ApplicationModules;
import org.junit.jupiter.api.Test;
class ModularityTests {
ApplicationModules modules = ApplicationModules.of(EcommerceApplication.class);
@Test
void verifiesModularStructure() {
// اگر ماژول order به کلاسهای internal ماژول payment دست بزند،
// این تست fail میشود — مرز توسط build تحمیل میشود، نه توسط code review
modules.verify();
}
}
مونولیت ماژولار به تو مرزهای تمیز میدهد بدون هزینهٔ توزیعشدن. اگر روزی واقعاً به میکروسرویس نیاز پیدا کردی، هر ماژولِ خوشمرز آماده است تا بیدرد از بدنه جدا شود. این مسیرِ توصیهشدهٔ اکثر معمارهای امروزی است: «اول ماژولار بساز، بعد در صورتِ نیاز جدا کن» — نه اینکه از روز اول سیستمِ توزیعشده بسازی.
جدول مقایسه
| بُعد | مونولیت | مونولیت ماژولار | میکروسرویس |
|---|---|---|---|
| واحد استقرار | یک | یک | چند (مستقل) |
| مرز بین ماژولها | ضعیف/قراردادی | سفت (کامپایلر/تست) | سفت (شبکه) |
| فراخوانی داخلی | method call | method call | شبکه (HTTP/gRPC/پیام) |
| تراکنش | ACID محلی | ACID محلی | سازگاریِ نهایی (eventual) |
| استقلال استقرار تیمها | ندارد | ندارد | دارد |
| مقیاسپذیری مستقل | ندارد | ندارد | دارد |
| هزینهٔ عملیاتی | کم | کم | زیاد |
| سختی دیباگ | ساده (یک استکتریس) | ساده | سخت (توزیعشده) |
| بهترین برای | MVP، تیم کوچک | اکثر سیستمهای متوسط | سازمان بزرگ، مقیاس نامتقارن |
۳) هزینهٔ واقعیِ میکروسرویس (که در بلاگپستها نمینویسند)
اینجا جایی است که سنیور از جونیور جدا میشود. جونیور مزایا را میشمارد؛ سنیور هزینهها را میشمارد و میپرسد «آیا ارزشش را دارد؟»
الف) هزینهٔ عملیاتی چند برابر میشود
یک مونولیت یعنی یک pipeline، یک داشبورد، یک سیاست لاگ. با ۲۰ میکروسرویس، حالا ۲۰ pipeline، ۲۰ سری متریک، مدیریت نسخهٔ ۲۰ سرویس، و هماهنگیِ ۲۰ زمانبندیِ release داری. بدون پلتفرم بالغ (Kubernetes، CI/CD خودکار، observability متمرکز) میکروسرویس تو را زیر بارِ عملیات له میکند.
اگر تیمت هنوز دستی دیپلوی میکند، لاگها متمرکز نیستند، و trace توزیعشده نداری، شکستنِ سیستم به میکروسرویس مشکلاتت را چند برابر میکند نه کمتر. قانون تجربی: قبل از میکروسرویس، پلتفرم. اول CI/CD، containerization، لاگِ متمرکز (مثلاً Loki/ELK)، متریک (Prometheus)، و tracing (OpenTelemetry) را داشته باش.
ب) دیباگِ توزیعشده جهنم است
در مونولیت، وقتی چیزی میشکند، یک stack trace داری که از بالا تا پایین همهچیز را نشان میدهد. در میکروسرویس، یک درخواستِ کاربر از gateway به سرویس A، به سرویس B، به broker، به سرویس C میرود. وقتی کند میشود یا میشکند، تو ۵ لاگِ جدا در ۵ ماشین داری که هیچ ارتباطی به هم ندارند — مگر اینکه distributed tracing را از روز اول جدی گرفته باشی.
هر درخواست باید یک traceId یکتا داشته باشد که از gateway شروع میشود و در هر hop — چه HTTP، چه پیام Kafka — منتشر (propagate) میشود. در Spring این کار با Micrometer Tracing (جانشینِ Spring Cloud Sleuth) و یک backend مثل Tempo/Jaeger/Zipkin انجام میشود. بدونِ traceId، دیباگِ یک باگِ متناوب در تولید میتواند روزها طول بکشد. با آن، چند دقیقه.
ج) دادهٔ توزیعشده مرزِ سختِ ماجراست
در مونولیت یک @Transactional کل کارت را با ACID تضمین میکند: یا همهچیز commit میشود یا هیچچیز. وقتی «سفارش» و «پرداخت» در دو دیتابیسِ جدا هستند، دیگر تراکنش سراسری نداری. باید با سازگاریِ نهایی (eventual consistency)، الگوهایی مثل Saga و transactional outbox، و منطقِ جبران (compensation) کنار بیایی. این یکی از بزرگترین جهشهای ذهنیِ گذار به میکروسرویس است.
د) شبکه دروغ میگوید: هشت مغالطهٔ محاسبات توزیعشده
مهندسانِ تازهواردِ دنیای توزیعشده ناخودآگاه این هشت فرض غلط را میکنند: (۱) شبکه قابلاعتماد است، (۲) تأخیر صفر است، (۳) پهنای باند نامحدود است، (۴) شبکه امن است، (۵) توپولوژی ثابت است، (۶) یک مدیر واحد وجود دارد، (۷) هزینهٔ انتقال صفر است، (۸) شبکه همگن است. هر فراخوانیِ بین سرویسی میتواند کند شود، timeout بخورد، یا نصفهکاره بماند. طراحیِ میکروسرویس یعنی طراحی برای این واقعیتها — با timeout، retry، circuit breaker، و idempotency.
هر method call که در مونولیت نانوثانیه بود و هرگز شکست نمیخورد، در میکروسرویس یک فراخوانیِ شبکهای است که میتواند میلیثانیهها طول بکشد و شکست بخورد. این تغییر همهچیز را عوض میکند: باید هر تعامل را با فرضِ شکست طراحی کنی.
۴) کِی میکروسرویس نرو (مهمتر از کِی برو)
- تیمت کوچک است (کمتر از ~۱۵–۲۰ نفر). با ۵ نفر و ۱۵ سرویس، هر نفر باید ۳ سرویس را نگه دارد؛ این ضدِ استقلال است.
- دامنهات هنوز پایدار نیست؛ مرزها هر ماه جابهجا میشوند.
- هنوز observability و CI/CD بالغ نداری.
- بار سیستم نامتقارن نیست؛ همهٔ بخشها با هم مقیاس میگیرند.
- مشکلت «کد کثیف» است، نه «مقیاس». میکروسرویس کد بد را تمیز نمیکند؛ فقط آن را روی شبکه پخش میکند.
دلیلِ خوبِ رفتن به میکروسرویس تقریباً همیشه یکی از اینهاست: (۱) چند تیمِ مستقل که مدام روی پای هم میروند و میخواهند مستقل release کنند، (۲) بخشهایی با پروفایلِ مقیاسِ کاملاً متفاوت (مثلاً موتور جستجو باید ۱۰۰ برابرِ ماژول صورتحساب scale شود)، (۳) نیازِ به fault isolation (یک بخش نباید کلِ سیستم را پایین بیاورد). اگر هیچکدام از اینها را نداری، احتمالاً به میکروسرویس نیاز نداری — به یک مونولیتِ ماژولارِ خوب نیاز داری.
جملهٔ معروفِ Martin Fowler را به خاطر بسپار: «تقریباً همهٔ داستانهای موفقِ میکروسرویس با مونولیتی شروع شدند که خیلی بزرگ شد و شکسته شد. تقریباً همهٔ داستانهایی که با میکروسرویس از صفر شروع کردند، به دردسر بزرگ افتادند.» این را «Monolith First» مینامند و یکی از پختهترین توصیههای این حوزه است.
۵) قانون Conway: چرا معماری تو شکلِ سازمانت را میگیرد
Melvin Conway در ۱۹۶۷ مشاهده کرد: «سازمانها سیستمهایی طراحی میکنند که کپیِ ساختارِ ارتباطیِ خودِ سازماناند.» اگر سه تیم یک کامپایلر بسازند، یک کامپایلرِ سهمرحلهای بیرون میآید. مرزِ نرمافزار، مرزِ ارتباطِ انسانها را دنبال میکند.
نتیجهٔ عملی برای میکروسرویس عظیم است: اگر مرزهای سرویسهایت با مرزهای تیمهایت همراستا نباشند، شکست میخوری. اگر یک feature همیشه سه تیم و سه سرویس را درگیر میکند، هزینهٔ هماهنگی تو را میکشد.
تیمهای پیشرو این را برعکس میکنند: اول سازمان و تیمها را طوری طراحی کن که معماریِ دلخواهت را تولید کنند. میخواهی سرویس «پرداخت» مستقل باشد؟ یک تیمِ مستقلِ end-to-end برای پرداخت بساز که مالکِ کامل آن باشد. معماری از ساختار تیم بیرون میزند، نه برعکس. این قلبِ کتاب «Team Topologies» است — مفهومِ stream-aligned team که مالکِ کاملِ یک جریانِ ارزش است.
۶) مرزبندیِ درست با DDD: bounded context
بزرگترین اشتباه در میکروسرویس، مرزبندیِ فنی بهجای مرزبندیِ کسبوکاری است. اگر سرویسهایت اسمهایی مثل UserService، DatabaseService، ValidationService دارند، تو داری بر اساس لایههای فنی میشکنی — و این تقریباً همیشه غلط است، چون هر feature از چند لایه عبور میکند و باز چند سرویس را همزمان درگیر میکند.
روش درست از Domain-Driven Design (DDD) میآید.
در دپارتمان فروش، «مشتری» یعنی یک lead با احتمال خرید. در دپارتمان حسابداری، «مشتری» یعنی یک حسابِ قابلوصول با اعتبار مالی. در دپارتمان پشتیبانی، «مشتری» یعنی یک نفر با تاریخچهٔ تیکت. یک کلمه، سه معنیِ کاملاً متفاوت. اگر بخواهی یک مدلِ «مشتریِ» واحد برای همه بسازی، یک هیولای پرِ فیلدِ اختیاری میسازی که هیچکس راضی نیست. bounded context یعنی: بپذیر که هر دپارتمان مدلِ خودش را دارد و مرزِ سرویس را دقیقاً همانجا بکش.
Bounded context یعنی مرزی که در آن یک مدلِ دامنه و یک زبانِ مشترک (ubiquitous language) معنایِ دقیق و یکتا دارد. مرزِ میکروسرویسِ خوب معمولاً دقیقاً روی مرزِ یک bounded context میافتد. داخلِ آن مرز، Customer یک معنی دارد؛ بیرونِ آن، سرویسِ دیگر مدلِ خودش را دارد و از طریقِ API/رویداد ترجمه میشود (که DDD به آن anti-corruption layer میگوید).
چطور مرزها را پیدا کنیم
- Event Storming: با ذینفعانِ کسبوکار بنشین و رویدادهای دامنه را روی دیوار بچسبان (
OrderPlaced،PaymentAuthorized،ItemShipped). خوشههای رویدادها معمولاً bounded contextها را نشان میدهند. - تجزیه بر اساس قابلیت کسبوکار (business capability): «سفارشگیری»، «پرداخت»، «انبار»، «حملونقل» — نه بر اساس اسمها یا لایهها.
- subdomainها: هستهٔ اصلی (core) که مزیت رقابتیات است را از subdomainهای پشتیبان (supporting) و عمومی (generic، مثل ارسال ایمیل که میتوانی بخری) جدا کن.
دو خطای شایع در مرزبندی: (۱) entity-service — یک سرویس برای هر جدول دیتابیس (AddressService، OrderLineService). اینها چسبناکاند و همیشه با هم صدا زده میشوند؛ یعنی مرز غلط. (۲) nano-service — سرویسهای آنقدر ریز که سربارِ شبکه و عملیاتشان از کارِ مفیدشان بیشتر است. مرزِ درست دورِ یک قابلیتِ کاملِ کسبوکار است، نه دورِ یک موجودیتِ داده. یک تستِ خوب: آیا این سرویس میتواند بیشترِ درخواستهایش را بدون صدا زدنِ سرویسِ دیگر جواب دهد؟ اگر نه، مرزت اشتباه است.
۷) مالکیتِ داده: هر سرویس، دیتابیسِ خودش
این احتمالاً سختترین و مهمترین قانونِ میکروسرویس است: هیچ دو سرویسی یک دیتابیس مشترک ندارند، و هیچ سرویسی مستقیم به جدولِ سرویسِ دیگر دست نمیزند.
اگر سرویس A و B هر دو به یک جدول orders بنویسند، دیگر مستقل نیستند: تغییرِ schema توسط A، B را میشکند؛ نمیتوانی مستقل deploy کنی؛ نمیتوانی مستقل scale کنی؛ و یک قفلِ دیتابیس در A روی B اثر میگذارد. دیتابیسِ مشترک، تمام استقلالی که برایش هزینهٔ توزیعشدن دادی را نابود میکند. این پرتکرارترین خطای معماریِ داده در میکروسرویس است.
پس هر سرویس دیتابیسِ خودش را دارد (که میتواند فناوریِ متفاوتی باشد — «persistence polyglot»: سرویس سفارش روی PostgreSQL، سرویس جستجو روی Elasticsearch، سرویس سبد خرید روی Redis). اما این یک مشکل بزرگ میسازد: چطور دادهٔ بینسرویسی را همگام نگه داریم بدون تراکنشِ سراسری؟
الگوی Transactional Outbox
مشکل: میخواهی وقتی سفارشی ثبت شد، هم در دیتابیسِ خودت ذخیره کنی و هم رویداد OrderPlaced را به Kafka بفرستی. اگر اول در DB بنویسی بعد به Kafka، و بینِ این دو crash کنی، رویداد گم میشود (dual-write problem). راهحل: رویداد را در همان تراکنشِ دیتابیس در یک جدول outbox بنویس؛ بعد یک فرآیندِ جدا آن را میخواند و به broker میفرستد.
بیایید جدولِ outbox را در هر دو دیالکتی که با آنها کار میکنی ببینیم، چون تفاوتهای واقعی دارند.
-- PostgreSQL
CREATE TABLE outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aggregate VARCHAR(64) NOT NULL,
event_type VARCHAR(128) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published BOOLEAN NOT NULL DEFAULT false
);
-- خواندن دستهای رویدادهای منتشرنشده با قفلِ ردیف تا worker موازی دوبارهکاری نکند:
SELECT id, event_type, payload
FROM outbox
WHERE published = false
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
-- Oracle (19c/23ai)
CREATE TABLE outbox (
id RAW(16) DEFAULT SYS_GUID() PRIMARY KEY,
aggregate VARCHAR2(64) NOT NULL,
event_type VARCHAR2(128) NOT NULL,
payload CLOB NOT NULL, -- یا JSON در 21c به بعد
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
published NUMBER(1) DEFAULT 0 NOT NULL -- Oracle نوعِ boolean بومی نداشت
);
-- معادلِ pagination و skip-lock در Oracle:
SELECT id, event_type, payload
FROM outbox
WHERE published = 0
ORDER BY created_at
FETCH FIRST 100 ROWS ONLY
FOR UPDATE SKIP LOCKED;
۱) boolean: PostgreSQL نوعِ BOOLEAN بومی دارد؛ Oracle تا پیش از 23ai نداشت و مرسوم است NUMBER(1) با ۰/۱ بگذاری. ۲) pagination: LIMIT n در PostgreSQL در برابرِ FETCH FIRST n ROWS ONLY در Oracle (که از 12c به بعد استاندارد است؛ قدیمیترها ROWNUM مینوشتند). ۳) UUID/GUID: gen_random_uuid() در برابرِ SYS_GUID(). ۴) JSON: JSONB در PostgreSQL در برابرِ CLOB/نوع JSON (از 21c) در Oracle. ۵) خوشبختانه FOR UPDATE SKIP LOCKED در هر دو موجود است و برای worker-queueهای رقابتی حیاتی است. ۶) رشتهٔ خالی: در Oracle یک رشتهٔ '' معادلِ NULL است — تلهٔ کلاسیکی که در PostgreSQL وجود ندارد.
بهجای اینکه یک worker مدام جدول outbox را poll کند، تیمهای بالغ از Change Data Capture (مثلاً Debezium) استفاده میکنند که مستقیماً write-ahead log دیتابیس را میخواند و تغییرات را به Kafka میفرستد — بدون فشارِ query روی دیتابیس و با تأخیرِ کمتر. این الگوی استاندارد صنعتیِ امروز برای انتشارِ رویداد از یک سرویس است.
مبادلهٔ داده بین سرویسها معمولاً دو شکل دارد: API composition (یک سرویس یا gateway چند سرویس را صدا میزند و پاسخها را میچسباند) یا CQRS با replica خواندهای (هر سرویس یک کپیِ فقط-خواندنیِ محلی از دادهٔ مورد نیازش را از طریق رویدادها نگه میدارد تا مجبور نشود سرِ هر درخواست سرویسِ دیگر را صدا بزند).
۸) دوازدهعامل (The Twelve-Factor App)
دوازدهعامل مجموعهای از اصول برای ساختِ سرویسهایی است که در ابر (cloud) خوب رفتار کنند — مقیاس بگیرند، بیدرد deploy شوند، و قابلِ حمل باشند. برای میکروسرویسِ Spring، مهمترینهایشان:
- Config در محیط، نه در کد (Factor III): رمزها و URLها از environment variable یا config server بیایند، نه hard-code. در Spring:
application.yml+ متغیرهای محیطی + Spring Cloud Config. - Backing services بهعنوان منابعِ قابلتعویض (IV): دیتابیس، broker، کش همه از طریق URL در config وصل شوند، طوری که بشود بیتغییرِ کد عوضشان کرد.
- بیحالت بودن پردازهها (VI, stateless): سرویس هیچ حالتی در حافظهٔ محلی که بین درخواستها لازم باشد نگه ندارد؛ حالت در دیتابیس/کش میرود. این پیششرطِ scale افقی است.
- Port binding (VII): سرویس، سرورِ خودش را embed میکند (Spring Boot با Tomcat/Netty جاسازیشده) و روی یک port میشنود.
- Concurrency با process (VIII): برای بار بیشتر، instanceهای بیشتر بالا بیاور (scale افقی)، نه یک instanceِ غول.
- Disposability (IX): سریع بالا بیا، graceful shutdown داشته باش (Spring Boot:
server.shutdown=graceful). - Logs بهعنوان جریانِ رویداد (XI): به stdout لاگ بزن، نه به فایل؛ پلتفرم آن را جمع میکند.
اگر سرویست session کاربر را در حافظهٔ محلی نگه دارد، وقتی سه instance از آن بالا میآوری، load balancer کاربر را به instance دوم میفرستد که sessionاش را ندارد و کاربر logout میشود. راهحل: session را در Redis بگذار یا از JWT بیحالت استفاده کن. هر instance باید قابلِ کشتن و جایگزینی در هر لحظه باشد بدون اینکه کاربری متوجه شود. این پیششرطِ هر معماریِ ابری است.
۹) توپولوژیِ معمولِ یک سیستمِ Java/Spring
حالا بیایید یک نقشهٔ واقعی بکشیم. یک سیستم تجارت الکترونیکِ نمونه با اجزای استانداردِ اکوسیستم Spring Cloud.
دیاگرام معماری: مسیرِ درخواست از کلاینت تا سرویسها و زیرساخت (client → gateway → services → data/broker).
flowchart TD
Client[Web / Mobile Client] --> GW[API Gateway]
GW --> OrderSvc[Order Service]
GW --> PaymentSvc[Payment Service]
GW --> CatalogSvc[Catalog Service]
OrderSvc --> OrderDB[(Order DB)]
PaymentSvc --> PayDB[(Payment DB)]
CatalogSvc --> CatalogDB[(Catalog DB)]
OrderSvc -- OrderPlaced --> Broker[(Kafka Broker)]
Broker --> PaymentSvc
Broker --> ShipSvc[Shipping Service]
Registry{{Service Registry}} -.discovery.- GW
Registry -.discovery.- OrderSvc
Registry -.discovery.- PaymentSvc
Config[[Config Server]] -.config.- OrderSvc
Config -.config.- PaymentSvc
اجزای کلیدی:
- API Gateway: تنها درِ ورودی. مسیریابی (routing)، احراز هویت، rate limiting، و ترجمهٔ پروتکل را متمرکز میکند. در Spring: Spring Cloud Gateway.
- Service Registry / Discovery: سرویسها آدرس ثابت ندارند (در Kubernetes مدام جابهجا میشوند). registry مثل «دفترچهٔ تلفنِ زنده» است: هر سرویس خودش را ثبت میکند و بقیه با اسم پیدایش میکنند. مثل Eureka یا Consul.
- Config Server: پیکربندیِ متمرکز و نسخهبندیشده برای همهٔ سرویسها. مثل Spring Cloud Config یا Consul KV.
- Message Broker: برای ارتباطِ ناهمگام و event-driven. مثل Kafka یا RabbitMQ.
- دیتابیسِ هر سرویس: جداگانه، طبق قانون مالکیتِ داده.
Spring Boot روی خط ۳.۵.x است (آخرینها حدود 3.5.16) و Spring Cloud با آن نسخهٔ 2025.0.x «Northfields» و نسخهٔ جدیدترِ 2025.1.0 «Oakwood» (آبان ۱۴۰۴/نوامبر ۲۰۲۵) است. نکتهٔ مهم برای dependencyها: در Spring Cloud 2025.0 استارترِ gateway تغییر نام داد. استارترِ قدیمیِ spring-cloud-starter-gateway منسوخ (deprecated) شد و در 2025.1 حذف شد؛ نامِ جدید spring-cloud-starter-gateway-server-webflux است (نسخهٔ Web MVC هم با پسوند -mvc وجود دارد). این تغییر برای شفاف کردنِ دو سبکِ gateway (server در برابر proxy-exchange) و دو استکِ وب (WebFlux در برابر Web MVC) انجام شد.
بخش بزرگی از Spring Cloud Netflix (Zuul، Ribbon، Hystrix، Archaius) از سالها پیش منسوخ یا در maintenance mode است. Zuul جایش را به Spring Cloud Gateway داد؛ Ribbon جایش را به Spring Cloud LoadBalancer داد؛ Hystrix جایش را به Resilience4j داد. Eureka هنوز کار میکند و پرکاربرد است، اما برای پروژهٔ جدید بسیاری از تیمها یا Consul/Nacos یا service discovery بومیِ Kubernetes را انتخاب میکنند. اگر در مصاحبه از Hystrix حرف بزنی، سنیورِ مقابل میفهمد منبعت قدیمی است — بگو Resilience4j.
کد: gateway که با discovery مسیریابی میکند
pom.xml (بخشهای کلیدی):
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway-server-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!-- برای resolve کردنِ lb:// از روی registry ضروری است -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
application.yml روی gateway:
spring:
application:
name: api-gateway
cloud:
gateway:
discovery:
locator:
enabled: false # در تولید مسیرها را صریح تعریف کن، نه خودکار
routes:
- id: order-service
uri: lb://ORDER-SERVICE # lb:// یعنی از registry پیدا و load-balance کن
predicates:
- Path=/api/orders/**
filters:
- name: CircuitBreaker
args:
name: orderCB
fallbackUri: forward:/fallback/orders
- id: catalog-service
uri: lb://CATALOG-SERVICE
predicates:
- Path=/api/catalog/**
eureka:
client:
service-url:
defaultZone: http://discovery:8761/eureka/
حالتِ locator خودکار، هر سرویسی که در registry ثبت شده را بهطور خودکار روی gateway expose میکند. این یعنی یک سرویسِ داخلی که هرگز نباید از بیرون دیده شود، ناگهان از اینترنت قابلِدسترسی میشود. در تولید همیشه مسیرها را صریح تعریف کن و locator خودکار را خاموش نگه دار.
کد: یک سرویس که خودش را ثبت میکند و config میگیرد
روی سرویس سفارش، application.yml:
spring:
application:
name: order-service
config:
import: "optional:configserver:http://config:8888" # از Spring Cloud Config بخوان
server:
port: 0 # پورت تصادفی؛ registry آدرس واقعی را میداند
shutdown: graceful # درخواستهای در جریان را قبل از خاموشی تمام کن
management:
endpoints:
web:
exposure:
include: health,info,prometheus
tracing:
sampling:
probability: 1.0 # در تولید معمولاً کمتر، مثلاً 0.1
eureka:
client:
service-url:
defaultZone: http://discovery:8761/eureka/
کلاسِ اصلی و یک client مقاوم:
@SpringBootApplication
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(OrderServiceApplication.class, args);
}
}
// فراخوانیِ بینسرویسی با نامِ منطقی (نه IP) و مقاومسازی با Resilience4j
@Service
class PaymentClient {
private final RestClient restClient;
PaymentClient(RestClient.Builder builder) {
// "http://payment-service" با load balancer به instanceِ واقعی resolve میشود
this.restClient = builder.baseUrl("http://payment-service").build();
}
@CircuitBreaker(name = "payment", fallbackMethod = "fallback")
@Retry(name = "payment")
public PaymentResult authorize(PaymentRequest req) {
return restClient.post()
.uri("/authorizations")
.body(req)
.retrieve()
.body(PaymentResult.class);
}
// وقتی circuit باز است یا همهٔ retryها شکست خوردند، این صدا زده میشود
private PaymentResult fallback(PaymentRequest req, Throwable ex) {
return PaymentResult.pending(req.orderId()); // تنزلِ زیبنده (graceful degradation)
}
}
یک fallbackِ خوب پاسخِ درجهدو میدهد، نه خطا. اگر سرویسِ توصیهگر پایین باشد، محبوبترین محصولات را نشان بده، نه یک صفحهٔ خالی. اگر سرویسِ پرداخت کند است، سفارش را PENDING ثبت کن و بعداً پردازش کن. سنیور همیشه میپرسد: «اگر این وابستگی پایین باشد، تجربهٔ حداقلیِ قابلقبول چیست؟» — این تفاوتِ سیستمی است که در قطعیِ جزئی سرِ پا میماند با سیستمی که کاملاً میافتد.
سکانسِ یک درخواست از ابتدا تا انتها
دیاگرام سکانس: ثبت سفارش با ترکیبِ فراخوانیِ همگام و رویدادِ ناهمگام (synchronous call + async event).
sequenceDiagram
participant C as Client
participant G as Gateway
participant O as Order Service
participant P as Payment Service
participant K as Kafka
participant S as Shipping Service
C->>G: POST /api/orders
G->>O: route (lb + traceId)
O->>O: persist Order (status=PENDING) + outbox row
O-->>K: OrderPlaced (via outbox/CDC)
O-->>G: 202 Accepted
G-->>C: 202 Accepted
K->>P: OrderPlaced
P->>P: authorize payment
P-->>K: PaymentAuthorized
K->>O: PaymentAuthorized -> status=CONFIRMED
K->>S: PaymentAuthorized -> schedule shipment
توجه کن که پاسخ به کاربر (202 Accepted) قبل از تکمیلِ پرداخت برمیگردد. این آگاهانه است: سیستم ناهمگام و eventual consistent است. کاربر بلافاصله جواب میگیرد، و بقیهٔ کار در پسزمینه از طریق رویدادها جلو میرود. این الگویی است که مقیاس و تابآوری میدهد، اما ذهنِ مونولیتمحور را اذیت میکند چون دیگر همهچیز «همینالان و اتمیک» نیست.
۱۰) الگوهای ارتباط: همگام در برابر ناهمگام
همگام (synchronous): سرویس A منتظرِ پاسخِ B میماند (REST/gRPC). ساده برای فهم، اما کوپلینگِ زمانی (temporal coupling) ایجاد میکند: اگر B پایین باشد، A هم گیر میکند، و این میتواند زنجیرهوار کلِ سیستم را پایین بیاورد (cascading failure). ناهمگام (asynchronous): A رویداد را به broker میفرستد و ادامه میدهد؛ B هر وقت توانست پردازش میکند. تابآورتر و decoupledتر، اما پیچیدگیِ eventual consistency و پیامرسانی را اضافه میکند. قانون طلایی: برای query که کاربر منتظرش است، همگام؛ برای command و انتشارِ تغییرِ حالت بین سرویسها، ناهمگام. یک زنجیرهٔ طولانیِ فراخوانیِ همگام (A→B→C→D) بویِ بدِ طراحی میدهد.
تصور کن سرویس D کند میشود. C که منتظرِ D است، threadهایش پر میشوند. B که منتظرِ C است، threadهایش پر میشوند. سریع کلِ سیستم قفل میشود — فقط بهخاطر یک وابستگیِ کند. circuit breaker این زنجیره را میشکند: بعد از چند شکستِ متوالی، «مدار را باز میکند» و فوراً fallback برمیگرداند بهجای اینکه منتظر بماند. همراهش timeout و bulkhead (جدا کردنِ thread poolها) لازم است تا یک وابستگیِ بیمار کلِ سرویس را نبلعد.
دیاگرام حالت: چرخهٔ عمرِ یک circuit breaker (closed → open → half-open).
stateDiagram-v2
[*] --> Closed
Closed --> Open: failure rate > threshold
Open --> HalfOpen: after wait duration
HalfOpen --> Closed: trial calls succeed
HalfOpen --> Open: trial calls fail
در حالت Closed همهچیز عادی است و فراخوانیها رد میشوند. وقتی نرخِ خطا از آستانه رد شد، به Open میرود و همهٔ فراخوانیها فوراً fallback میخورند (به D فشار نمیآوریم تا فرصتِ ریکاوری بگیرد). بعد از یک بازهٔ انتظار، به Half-Open میرود و چند فراخوانیِ آزمایشی میفرستد؛ اگر موفق بودند، برمیگردد به Closed؛ اگر نه، دوباره Open. در Spring این را با Resilience4j پیاده میکنی.
۱۱) مهاجرت با الگوی Strangler Fig
تقریباً هیچکس میکروسرویس را از صفر با موفقیت نساخته. مسیرِ درست، شکستنِ تدریجیِ یک مونولیتِ موجود است. الگوی استانداردش Strangler Fig است.
درختِ انجیرِ خفهکننده دورِ یک درختِ میزبان رشد میکند، شاخههایش را کمکم میپوشاند، و در نهایت درختِ اصلی میپوسد و میرود و انجیر جایش را کاملاً میگیرد — بدون اینکه یک لحظه فضایِ خالی بماند. Martin Fowler این را استعارهٔ مهاجرت گرفت: یک لایهٔ مسیریابی جلوی مونولیت بگذار، و feature به feature را به سرویسهای جدید منتقل کن. مونولیت کمکم «خفه» میشود تا چیزی از آن نماند — بدون یک بازنویسیِ بزرگِ پرریسک.
مراحلِ عملی:
۱) یک gateway/proxy جلوی مونولیت بگذار که همهٔ ترافیک از آن رد شود (بدون تغییرِ رفتار). ۲) یک bounded context با ریسکِ پایین و ارزشِ بالا را انتخاب کن (مثلاً «اطلاعرسانی» یا «کاتالوگ»). ۳) آن قابلیت را بهعنوانِ یک سرویسِ جدید بساز، و در gateway مسیرِ آن را به سرویسِ جدید بفرست (نه به مونولیت). ۴) دادهٔ آن حوزه را با CDC/رویداد همگام کن تا مونولیت و سرویسِ جدید در دورهٔ گذار هماهنگ بمانند. ۵) تکرار کن؛ هر بار یک تکه، تا مونولیت به یک هستهٔ کوچک برسد یا کاملاً برود.
دیاگرام: لایهٔ مسیریابی که ترافیک را بین مونولیت و سرویسِ استخراجشده تقسیم میکند (strangler routing).
flowchart LR
Client --> Router[Strangler Facade / Gateway]
Router -->|/catalog/**| NewCatalog[New Catalog Service]
Router -->|everything else| Monolith[Legacy Monolith]
NewCatalog --> CatalogDB[(Catalog DB)]
Monolith --> LegacyDB[(Legacy DB)]
Monolith -. sync via CDC .-> CatalogDB
تکهٔ اول را طوری انتخاب کن که کمترین وابستگیِ ورودی از بقیهٔ مونولیت را داشته باشد — یک قابلیتِ لبهای مثل ارسال ایمیل، گزارشگیری، یا جستجو. اگر اول قلبِ گرهخوردهٔ سیستم (مثلاً «حساب کاربری» که همهچیز به آن وصل است) را بکنی، در دریایی از وابستگی غرق میشوی و پروژه شکست میخورد. اولین برشِ موفق، اعتماد و مومنتوم میسازد.
وسوسهانگیزترین و مرگبارترین تصمیم: «بیایید مونولیت را کنار بگذاریم و از صفر همهچیز را میکروسرویس بنویسیم.» این تقریباً همیشه شکست میخورد چون: (۱) مونولیت هزاران قانونِ کسبوکارِ نانوشته دارد که فقط در کد زندگی میکنند، (۲) در طولِ بازنویسی، دو سیستم باید همزمان نگهداری شوند، (۳) کسبوکار در این مدت متوقف نمیشود و feature میخواهد. Strangler Fig ریسک را تکهتکه و برگشتپذیر میکند؛ big-bang همهچیز را روی یک پرتاب شرط میبندد.
۱۲) مالکیتِ تیمی: «تو میسازی، تو اجرا میکنی»
فلسفهٔ سازمانیِ میکروسرویس در یک جمله از Werner Vogels (مدیرِ ارشدِ فنیِ آمازون) خلاصه میشود: "You build it, you run it." تیمی که سرویس را میسازد، همان تیم آن را در تولید هم اجرا و پشتیبانی میکند — pager را هم خودش دست میگیرد.
وقتی همان تیمی که کد را نوشته، ساعت ۳ صبح از قطعیِ همان سرویس بیدار میشود، انگیزهٔ کاملاً متفاوتی برای کیفیت، observability، و طراحیِ تابآور دارد. جدا کردنِ «توسعه» از «عملیات» (مدلِ قدیمیِ throw-over-the-wall) دقیقاً چیزی است که DevOps و میکروسرویس علیهاش شکل گرفتند. مالکیت، کیفیت میسازد.
هر سرویس یک مالکِ مشخص دارد و یک قرارداد (contract) روشن با مصرفکنندههایش. برای اینکه تغییرِ یک تیم، تیمِ دیگر را نشکند، از contract testing (مثلاً Spring Cloud Contract یا Pact) استفاده میشود: مصرفکننده انتظاراتش از API را بهصورتِ تست مینویسد، و pipeline تولیدکننده آن قرارداد را verify میکند. اینطور یک تغییرِ ناسازگار قبل از تولید گیر میافتد، نه بعدش.
وقتی ۵ تیمِ مستقل دارند مصرفکنندهٔ سرویس تو میشوند، دیگر نمیتوانی API را دلبخواه عوض کنی. یک تغییرِ ناسازگار (breaking change) بدونِ نسخهبندی، همهٔ مصرفکنندهها را همزمان میشکند و مجبورت میکند همه را با هم deploy کنی — یعنی برگشت به مونولیتِ توزیعشده. قاعده: تغییراتِ additive بده (فیلدِ جدیدِ اختیاری اشکالی ندارد)، فیلدها را حذف/تغییرِ نوع نده، و برای تغییرِ ناسازگار نسخهٔ جدید (/v2) بساز و نسخهٔ قبلی را در یک دورهٔ گذار زنده نگه دار.
۱۳) چطور یک سنیور انتخاب را توجیه میکند
در مصاحبه یا در جلسهٔ طراحی، فرقِ جونیور و سنیور در نحوهٔ استدلال است. جونیور میگوید «میکروسرویس مقیاسپذیرتر است». سنیور یک چارچوبِ trade-off میآورد:
۱) چند تیم؟ اگر یک تیمِ کوچک است، احتمالاً مونولیتِ ماژولار. میکروسرویس وقتی میدرخشد که چند تیمِ مستقل بخواهند مستقل release کنند. ۲) بلوغِ پلتفرم؟ CI/CD، containerization، و observability داری؟ اگر نه، اول اینها. ۳) بارِ نامتقارن؟ آیا بخشی هست که باید مستقل و بهشدت متفاوت از بقیه scale شود؟ این یک دلیلِ فنیِ واقعی است. ۴) پایداریِ دامنه؟ مرزها را میشناسی؟ اگر دامنه هنوز در حالِ کشف است، مرزها را ارزان و درونِ مونولیت نگه دار. ۵) fault isolation؟ آیا قطعیِ یک بخش نباید کلِ سیستم را بخواباند؟ ۶) هزینه در برابر فایده: هزینهٔ عملیاتی و پیچیدگیِ توزیعشده را در برابرِ سرعتِ تیمی و مقیاس بگذار. اگر فایده روشن نیست، نرو.
جملهٔ کلیدی که در مصاحبه باید بتوانی بگویی: «میکروسرویس یک بهینهسازیِ سازمانی است که با پیچیدگیِ فنی پرداخت میشود. من وقتی این پیچیدگی را میخرم که مزیتِ سازمانیاش (استقلالِ تیم، مقیاسِ مستقل، جداسازیِ خطا) از هزینهاش بیشتر باشد — و تا آن موقع، یک مونولیتِ ماژولارِ خوب میسازم که مسیرِ مهاجرتم را باز نگه دارد.» این جمله نشان میدهد که تو trade-off را میفهمی، نه فقط buzzword را.
سؤالات مصاحبه
هر دو سیستم را به سرویسها میشکنند، اما فلسفه فرق دارد. SOA (معماریِ سرویسگرا) معمولاً حولِ یک Enterprise Service Bus (ESB) هوشمندِ مرکزی میچرخد که منطق مسیریابی، تبدیل و orchestration را در خودش دارد؛ سرویسها اغلب دیتابیس مشترک دارند و meshشان سنگین است. میکروسرویس فلسفهٔ «لولههای احمق، نقاطِ پایانیِ باهوش» (smart endpoints, dump pipes) را دارد: هوش در خودِ سرویسهاست و broker/شبکه صرفاً پیام را جابهجا میکند. علاوه بر این، میکروسرویس روی دیتابیسِ مستقل بهازای هر سرویس و استقلالِ استقرار بسیار سختگیرتر است. خلاصه: میکروسرویس را میشود «SOA که درست انجام شده، با تأکید بر decoupling و مالکیتِ مستقل» دید.
وقتی تیم کوچک است، دامنه هنوز پایدار نیست، پلتفرم (CI/CD و observability) بالغ نیست، بار نامتقارن نیست، یا مشکل اصلی «کدِ کثیف» است نه «مقیاس». میکروسرویس هزینهٔ عملیاتی و پیچیدگیِ توزیعشده را چند برابر میکند؛ اگر مزیتِ سازمانیِ روشنی (چند تیمِ مستقل، مقیاسِ مستقل، جداسازیِ خطا) در کار نباشد، این هزینه توجیه ندارد. توصیهٔ من در ابهام، مونولیتِ ماژولار است — مرزهای تمیز بدون هزینهٔ شبکه، با مسیرِ باز برای جدا شدنِ بعدی. جملهٔ «Monolith First» را نقل میکنم.
bounded context مفهومی از DDD است: مرزی که درونِ آن یک مدلِ دامنه و یک زبانِ مشترک معنایِ دقیق و یکتا دارند. مثلاً «مشتری» در فروش با «مشتری» در حسابداری دو مدلِ متفاوت است. مرزِ خوبِ میکروسرویس تقریباً همیشه روی مرزِ یک bounded context میافتد، چون آنجا کوپلینگِ داخلی زیاد و کوپلینگِ خارجی کم است — یعنی سرویس میتواند مستقل کار کند. اگر مرز را جای دیگری (مثلاً روی لایهٔ فنی یا یک جدولِ داده) بکشی، سرویسهایت چسبناک میشوند و همیشه با هم صدا زده میشوند.
قانون Conway میگوید طراحیِ سیستم، کپیِ ساختارِ ارتباطیِ سازمان است. یعنی اگر مرزِ سرویسها با مرزِ تیمها همراستا نباشد، هزینهٔ هماهنگی تو را میکشد. استفادهٔ عملی، مانورِ Conway معکوس است: بهجای اینکه معماری را طراحی کنی و امیدوار باشی تیمها با آن جور دربیایند، اول تیمها را طوری بچین که معماریِ دلخواهت را تولید کنند — یعنی برای هر سرویس/جریانِ ارزش یک تیمِ مستقلِ end-to-end (stream-aligned team). این ایدهٔ محوریِ «Team Topologies» است.
چون دیتابیسِ مشترک استقلال را نابود میکند — همان چیزی که برایش هزینهٔ توزیعشدن دادی. با دیتابیسِ مشترک، تغییرِ schema توسط یک سرویس، بقیه را میشکند؛ نمیتوانی مستقل deploy یا scale کنی؛ و قفلهای دیتابیس بین سرویسها نشت میکنند. با دیتابیسِ مستقل، هر سرویس مالکِ کاملِ داده و schema خودش است و فقط از طریقِ API/رویداد آن را در دسترسِ بقیه میگذارد. هزینهاش این است که دیگر تراکنشِ ACIDِ سراسری نداری و باید با eventual consistency و الگوهایی مثل Saga و outbox کنار بیایی.
مشکلِ dual-write: میخواهی هم در دیتابیس بنویسی و هم رویداد به Kafka بفرستی، اما این دو منبعِ متفاوتاند و تراکنشِ اتمیکِ مشترک ندارند. اگر بین این دو crash کنی، یا داده ذخیره شده ولی رویداد گم شده، یا برعکس. راهحل: رویداد را در همان تراکنشِ دیتابیسیِ تغییرِ حالت، در یک جدول outbox بنویس (پس یا هر دو commit میشوند یا هیچکدام). بعد یک فرآیندِ جدا — با poll یا بهتر، با CDC مثل Debezium — رویدادها را از outbox به broker میفرستد. این تضمینِ at-least-once میدهد؛ برای همین مصرفکنندهها باید idempotent باشند.
وقتی یک سرویسِ عمیق در زنجیره کند میشود، سرویسهای بالادست که منتظرِ آناند threadهایشان پر میشود و آنها هم کند/قفل میشوند؛ این خرابی مثل دومینو زنجیرهوار بالا میآید تا کلِ سیستم بخوابد. ابزارهای دفاعی: timeout (هرگز بینهایت منتظر نمان)، circuit breaker (بعد از چند شکست، مدار را باز کن و فوراً fallback بده تا وابستگیِ بیمار فرصتِ ریکاوری بگیرد)، bulkhead (thread poolها را جدا کن تا یک وابستگیِ کند همهٔ ظرفیت را نبلعد)، و retry با backoff و jitter (با احتیاط، وگرنه خودش بار را بیشتر میکند). در Spring اینها را با Resilience4j پیاده میکنی.
همگام (REST/gRPC): A منتظرِ پاسخِ B میماند. ساده اما کوپلینگِ زمانی دارد — اگر B پایین باشد A هم گیر میکند. ناهمگام (پیام روی broker): A رویداد را میفرستد و ادامه میدهد؛ decoupled و تابآور اما eventual consistency و پیچیدگیِ پیامرسانی میآورد. قاعده: برای query که کاربر همان لحظه منتظرِ پاسخ است، همگام؛ برای انتشارِ تغییرِ حالت و command بین سرویسها، ناهمگام. یک زنجیرهٔ طولانیِ همگام (A→B→C→D) نشانهٔ مرزبندیِ بد است و باید یا سرویسها را ادغام کنی یا به رویداد سوییچ کنی.
روشِ مهاجرتِ تدریجی از مونولیت به میکروسرویس بدونِ بازنویسیِ بزرگ. یک لایهٔ مسیریابی (facade/gateway) جلوی مونولیت میگذاری؛ بعد یک قابلیت را به سرویسِ جدید منتقل میکنی و مسیرِ آن را در gateway به سرویسِ جدید میفرستی، بقیه هنوز به مونولیت میرود. داده را با CDC/رویداد در دورهٔ گذار همگام نگه میداری. تکهتکه پیش میروی تا مونولیت «خفه» شود. مزیتش این است که هر برش کوچک، برگشتپذیر و کمریسک است — برخلافِ big-bang rewrite که تقریباً همیشه شکست میخورد. نکته: اول تکههای «برگ» با کمترین وابستگی را بکن، نه قلبِ گرهخوردهٔ سیستم.
Gateway تنها درِ ورودی است و مسئولیتهای عرضی (cross-cutting) را متمرکز میکند: مسیریابی، احراز هویت/authorization لبهای، rate limiting، ترجمهٔ پروتکل، SSL termination، و گاهی API composition. اما منطقِ کسبوکار نباید در gateway باشد — این ضدالگویی است که gateway را به یک ESBِ چاق و یک نقطهٔ single-point-of-coupling تبدیل میکند. gateway باید نازک بماند. در Spring از Spring Cloud Gateway استفاده میشود (که استارترش در 2025.0 به spring-cloud-starter-gateway-server-webflux تغییر نام داد). هشدارِ عملی: discovery.locator.enabled=true را در تولید خاموش کن تا سرویسهای داخلی ناخواسته expose نشوند.
در محیطِ پویا (مثلاً Kubernetes)، instanceهای سرویس مدام بالا/پایین میروند و IP عوض میکنند؛ پس نمیتوانی آدرس را hard-code کنی. service discovery مثل «دفترچهٔ تلفنِ زنده» است: هر instance خودش را در یک registry ثبت میکند و مصرفکنندهها با نامِ منطقی سرویس را پیدا میکنند و بینِ instanceها load-balance میکنند. دو مدل دارد: client-side (مثل Eureka + Spring Cloud LoadBalancer، که کلاینت لیست را میگیرد و خودش انتخاب میکند) و server-side (مثل Kubernetes Service که یک IP ثابت و load-balancerِ داخلی میدهد). نکتهٔ بهروز: Netflix Eureka هنوز کار میکند اما stack نتفلیکس در maintenance است؛ برای پروژهٔ جدید Consul/Nacos یا discovery بومیِ Kubernetes رایجترند.
با distributed tracing. یک traceId یکتا در ورودی (gateway) ساخته میشود و در هر hop — چه HTTP header، چه header پیامِ Kafka — منتشر (propagate) میشود، و هر سرویس span خودش را زیرِ همان trace ثبت میکند. بعد در یک backend (Tempo/Jaeger/Zipkin) کلِ سفرِ درخواست را بهصورتِ یک آبشار میبینی و میفهمی کجا کند شده یا شکسته. در Spring این با Micrometer Tracing (جانشینِ Spring Cloud Sleuth) و معمولاً OpenTelemetry انجام میشود. بدونِ tracing، دیباگِ یک باگِ توزیعشده تقریباً غیرممکن است — این جزوِ سهگانهٔ observability (لاگ، متریک، trace) است که پیششرطِ میکروسرویس است.
سیستمی که بهظاهر میکروسرویس است (چند سرویسِ جدا روی شبکه) اما در عمل بهشدت کوپل است: سرویسها را نمیشود مستقل deploy کرد، دیتابیس مشترک دارند، یا هر تغییری زنجیرهای چند سرویس را همزمان میطلبد. این بدترین حالت است چون تمام هزینهٔ توزیعشدن (تأخیرِ شبکه، خطای شبکه، دیباگِ سخت، عملیاتِ چندبرابر) را میپردازی ولی هیچکدام از مزایایش (استقلالِ استقرار و تیم، مقیاسِ مستقل) را نمیگیری. علتِ اصلی معمولاً مرزبندیِ غلط (فنی بهجای bounded context) و دیتابیسِ مشترک است. راهحل: مرزها را با DDD اصلاح کن و مالکیتِ داده را جدا کن.
با نسخهبندی و سازگاریِ رو به عقب. قاعدهٔ اصلی: تغییراتِ additive آزاد است (افزودنِ فیلدِ اختیاریِ جدید مصرفکنندههای قدیمی را نمیشکند)، اما حذفِ فیلد، تغییرِ نوع، یا تغییرِ معنی، breaking است. برای breaking change یک نسخهٔ جدید (/v2 یا از طریقِ header/media-type) میسازی و نسخهٔ قبلی را در یک دورهٔ گذارِ اعلامشده زنده نگه میداری تا مصرفکنندهها مهاجرت کنند. برای اینکه ناسازگاری قبل از تولید گیر بیفتد از contract testing (Spring Cloud Contract / Pact) استفاده میکنی: انتظاراتِ مصرفکننده بهصورتِ تست ثبت میشود و pipeline تولیدکننده آن را verify میکند. بدونِ این انضباط، یک تغییر همهٔ مصرفکنندهها را همزمان میشکند و تو را به مونولیتِ توزیعشده برمیگرداند.
سهتا از همه مهمترند. Config در محیط (نه hard-code) تا یک artifact واحد در هر محیط بدونِ rebuild اجرا شود. پردازهٔ stateless تا بشود افقی scale کرد و هر instance را در هر لحظه بیدردسر کشت و جایگزین کرد (session در Redis یا JWT، نه در حافظهٔ محلی). Disposability / graceful shutdown تا rolling deployها و autoscaling بدونِ از دست رفتنِ درخواستِ در جریان کار کنند (در Spring Boot: server.shutdown=graceful). کنارِ اینها، backing serviceها بهعنوانِ منابعِ قابلتعویض و logs بهصورتِ جریانِ رویداد به stdout هم برای پرتابل بودن و observability کلیدیاند.
- میکروسرویس یک تصمیم سازمانی است: آن را برای استقلالِ تیم، مقیاسِ مستقل و جداسازیِ خطا انتخاب کن — نه برای «تمیزی» یا مد.
- سه گزینه داری، نه دو: مونولیت، مونولیتِ ماژولار (اغلب برندهٔ واقعی)، و میکروسرویس. در ابهام، ماژولار بساز و مسیرِ جدا شدن را باز نگه دار.
- هزینهٔ واقعی را ببین: عملیاتِ چندبرابر، دیباگِ توزیعشده، دادهٔ توزیعشده (eventual consistency)، و هشت مغالطهٔ شبکه. قبل از میکروسرویس، پلتفرم.
- مرز را با bounded context (DDD) بکش، نه با لایهٔ فنی. مرزهای سرویس را با مرزهای تیم همراستا کن (Conway و مانورِ معکوسش).
- هر سرویس، دیتابیسِ خودش. دادهٔ بینسرویسی را با رویداد، outbox/CDC، و Saga همگام کن — نه با دیتابیسِ مشترک.
- توپولوژیِ Spring: gateway + discovery + config + broker + دیتابیسِ مستقل. با نسخههای امروزی (Spring Boot 3.5.x، Spring Cloud 2025.x) و ابزارهای امروزی (Resilience4j، Micrometer Tracing، Spring Cloud Gateway) — نه Hystrix/Zuul.
- مهاجرت را با Strangler Fig تدریجی و برگشتپذیر کن؛ هرگز big-bang rewrite نکن.
- "You build it, you run it": مالکیتِ end-to-end، نسخهبندیِ API، و contract testing، چسبی است که چند تیمِ مستقل را از تبدیل شدن به مونولیتِ توزیعشده نجات میدهد.
Let me open with a confession many seniors learn too late: microservices is not an architectural decision, it is an organizational one. You don't split a system because it's "cleaner"; you split it because your teams, your deploy cadence, and the asymmetric scale of different parts of the system force you to. If you take only one sentence from this chapter, you're already ahead of half the market.
This chapter isn't just "what is a microservice." You're going to learn to judge it like a senior: see its real cost, find the right boundary with DDD, know when a monolith is the more mature choice, and defend your decision in an interview with numbers and trade-offs instead of buzzwords.
- What a microservice is in one simple picture, and what problem it solves.
- Three options: monolith, modular monolith, microservices — and when each wins.
- The hidden cost of going distributed: ops, distributed debugging, data, and the "eight fallacies."
- Correct boundaries with DDD (bounded context) and Conway's law.
- Data ownership: why each service owns its own database.
- The twelve-factor app and a typical Java/Spring topology, with diagrams.
- Migration with the Strangler Fig pattern from a monolith.
- Team ownership and a senior's decision framework.
1) A microservice in one picture
Picture a small restaurant with one all-rounder chef: salads, steaks, desserts, all one person. Fast, simple, zero coordination — because there's only one brain. That's the monolith.
Now picture a large restaurant with separate stations: grill, pastry, garde manger. Each station has its own specialist, its own tools, its own rhythm. Throughput is higher — but now you need an expediter to coordinate, an order-calling system, and coordination itself. That's microservices: more speed and autonomy, paid for with more coordination.
Microservices means: instead of one application holding all the business logic in one process and usually one database, you split the system into a set of small, independent, separately deployable services, each of which:
- Owns one specific business capability (e.g. "orders", "payment", "inventory").
- Owns its own database, which no one else touches directly.
- Talks to others over a network API (HTTP/gRPC) or messages on a broker.
- Is developed, deployed, and operated by one team and released independently of the rest.
That last phrase — "released independently" — is the heart of it. If deploying service A forces you to deploy service B at the same time, you don't have microservices; you have a distributed monolith, which is the worst of both worlds.
If you can't deploy your services independently, or any change requires a coordinated same-time change across several services, you've paid the entire cost of going distributed (network, ops, debugging) while capturing none of the benefits (independent deployment, team autonomy). This is the single most common real-world failure of microservice architecture.
2) Three options, not two
The industry loves to see the world as a binary: "bad monolith" vs. "modern microservices." That's a false dichotomy. There's a third option that often wins in practice: the modular monolith.
Monolith: one deployment unit
A monolith means all code in one codebase, one build unit, one running process. That's not "bad" — it has unmatched operational simplicity: one log, one database transaction, one thing to deploy, and a call between modules is a method call inside the same JVM (nanoseconds, no network, no serialization, no network errors).
When you still don't know what your product is, your domain boundaries move every week. Moving a boundary inside a monolith means refactoring a few classes. Moving that same boundary between two microservices means writing a versioned API, migrating data, and coordinating two teams. In a phase of ambiguity, the experienced senior chooses the monolith to keep boundaries cheap to change.
Modular monolith: one deployment unit, hard boundaries
A modular monolith is still a monolith, but with module boundaries the compiler enforces. Modules talk only through each other's public API, never touch each other's database tables, and have controlled dependencies — exactly the boundary discipline of microservices, without the network cost.
In the Spring ecosystem, Spring Modulith exists precisely for this: it verifies module boundaries at test time, manages inter-module events, and turns your tests red if a module reaches into another module's internals.
// A Spring Modulith test that guarantees module boundaries
import org.springframework.modulith.core.ApplicationModules;
import org.junit.jupiter.api.Test;
class ModularityTests {
ApplicationModules modules = ApplicationModules.of(EcommerceApplication.class);
@Test
void verifiesModularStructure() {
// If the order module reaches into the payment module's internal classes,
// this test fails — the boundary is enforced by the build, not by code review
modules.verify();
}
}
A modular monolith gives you clean boundaries without the cost of going distributed. If you one day genuinely need microservices, each well-bounded module is ready to be peeled off painlessly. This is the path most modern architects recommend: "build modular first, split later if needed" — not "build a distributed system from day one."
Comparison table
| Dimension | Monolith | Modular monolith | Microservices |
|---|---|---|---|
| Deployment unit | one | one | many (independent) |
| Boundary between modules | weak/by convention | hard (compiler/test) | hard (network) |
| Internal call | method call | method call | network (HTTP/gRPC/msg) |
| Transaction | local ACID | local ACID | eventual consistency |
| Independent team deploy | no | no | yes |
| Independent scaling | no | no | yes |
| Operational cost | low | low | high |
| Debugging difficulty | easy (one stack trace) | easy | hard (distributed) |
| Best for | MVP, small team | most mid-size systems | large org, asymmetric scale |
3) The real cost of microservices (that the blog posts skip)
This is where the senior separates from the junior. The junior counts the benefits; the senior counts the costs and asks "is it worth it?"
a) Operational cost multiplies
A monolith means one pipeline, one dashboard, one logging policy. With 20 microservices you now have 20 pipelines, 20 sets of metrics, version management for 20 services, and coordination of 20 release schedules. Without a mature platform (Kubernetes, automated CI/CD, centralized observability), microservices will crush you under operational load.
If your team still deploys by hand, logs aren't centralized, and you have no distributed tracing, splitting into microservices multiplies your problems, not reduces them. Rule of thumb: platform before microservices. First get CI/CD, containerization, centralized logging (e.g. Loki/ELK), metrics (Prometheus), and tracing (OpenTelemetry).
b) Distributed debugging is hell
In a monolith, when something breaks you get one stack trace showing everything top to bottom. In microservices, a user request goes from the gateway to service A, to service B, to the broker, to service C. When it slows down or breaks, you have 5 separate logs on 5 machines with no connection to each other — unless you took distributed tracing seriously from day one.
Every request must carry a unique traceId that starts at the gateway and is propagated across every hop — HTTP or Kafka message alike. In Spring this is done with Micrometer Tracing (the successor to Spring Cloud Sleuth) and a backend like Tempo/Jaeger/Zipkin. Without a traceId, debugging an intermittent production bug can take days. With it, minutes.
c) Distributed data is the hard wall
In a monolith one @Transactional guarantees your whole unit of work with ACID: everything commits or nothing does. When "order" and "payment" live in two separate databases, you no longer have a global transaction. You must live with eventual consistency, patterns like Saga and transactional outbox, and compensation logic. This is one of the biggest mental leaps in the move to microservices.
d) The network lies: the eight fallacies of distributed computing
Engineers new to distributed systems unconsciously make these eight false assumptions: (1) the network is reliable, (2) latency is zero, (3) bandwidth is infinite, (4) the network is secure, (5) topology doesn't change, (6) there is one administrator, (7) transport cost is zero, (8) the network is homogeneous. Every inter-service call can slow down, time out, or half-complete. Designing microservices means designing for these realities — with timeouts, retries, circuit breakers, and idempotency.
Every method call that was a nanosecond in a monolith and never failed becomes, in microservices, a network call that can take milliseconds and can fail. That change changes everything: you must design every interaction assuming failure.
4) When NOT to go micro (more important than when to go)
- Your team is small (fewer than ~15–20 people). With 5 people and 15 services, each person owns 3 services — that's the opposite of autonomy.
- Your domain isn't stable yet; boundaries move every month.
- You don't yet have mature observability and CI/CD.
- Your load isn't asymmetric; every part scales together.
- Your problem is "messy code," not "scale." Microservices don't clean up bad code; they just spread it across the network.
The good reason to go microservices is almost always one of these: (1) multiple independent teams that keep stepping on each other and want to release independently, (2) parts with wildly different scale profiles (e.g. the search engine must scale 100x more than the billing module), (3) the need for fault isolation (one part must not take down the whole system). If you have none of these, you probably don't need microservices — you need a good modular monolith.
Remember Martin Fowler's famous line: "Almost all the successful microservice stories have started with a monolith that got too big and was broken up. Almost all the cases where a system was built as a microservice system from scratch have ended up in serious trouble." This is called "Monolith First" and is one of the most mature pieces of advice in the field.
5) Conway's law: why your architecture mirrors your org
In 1967, Melvin Conway observed: "organizations design systems that mirror their own communication structure." If three teams build a compiler, you get a three-pass compiler. The boundary of the software follows the boundary of human communication.
The practical consequence for microservices is huge: if your service boundaries aren't aligned with your team boundaries, you will fail. If one feature always drags in three teams and three services, coordination cost will kill you.
Leading teams flip this around: first design your organization and teams so they produce the architecture you want. Want the "payment" service to be independent? Build an independent end-to-end team for payment that fully owns it. Architecture emerges from team structure, not the other way around. This is the heart of the "Team Topologies" book — the concept of a stream-aligned team that fully owns a value stream.
6) Correct boundaries with DDD: bounded context
The biggest mistake in microservices is drawing technical boundaries instead of business boundaries. If your services have names like UserService, DatabaseService, ValidationService, you're splitting by technical layer — and that's almost always wrong, because every feature crosses multiple layers and thus drags in multiple services at once.
The right method comes from Domain-Driven Design (DDD).
In sales, a "customer" is a lead with a probability to buy. In accounting, a "customer" is an account receivable with a credit standing. In support, a "customer" is a person with a ticket history. One word, three completely different meanings. If you try to build one unified "customer" model for everyone, you build a monster of optional fields that satisfies no one. A bounded context means: accept that each department has its own model, and draw the service boundary exactly there.
A bounded context is a boundary within which one domain model and one shared language (ubiquitous language) have a precise, unique meaning. A good microservice boundary usually falls exactly on a bounded context. Inside that boundary, Customer means one thing; outside it, another service has its own model and translates via API/events (DDD calls this an anti-corruption layer).
How to find the boundaries
- Event Storming: sit with business stakeholders and stick domain events on the wall (
OrderPlaced,PaymentAuthorized,ItemShipped). Clusters of events usually reveal bounded contexts. - Decompose by business capability: "order taking", "payment", "inventory", "shipping" — not by nouns or layers.
- Subdomains: separate the core (your competitive advantage) from supporting and generic subdomains (e.g. sending email, which you can just buy).
Two common boundary mistakes: (1) entity-service — one service per database table (AddressService, OrderLineService). These are sticky and always called together; that means the boundary is wrong. (2) nano-service — services so tiny that their network and operational overhead exceeds their useful work. The right boundary is around a complete business capability, not a data entity. A good test: can this service answer most of its requests without calling another service? If not, your boundary is wrong.
7) Data ownership: each service, its own database
This is probably the hardest and most important rule of microservices: no two services share a database, and no service touches another service's tables directly.
If services A and B both write to an orders table, they're no longer independent: a schema change by A breaks B; you can't deploy independently; you can't scale independently; and a database lock in A affects B. A shared database destroys all the independence you paid for by going distributed. This is the most frequent data-architecture mistake in microservices.
So each service owns its own database (which can be a different technology — "polyglot persistence": the order service on PostgreSQL, the search service on Elasticsearch, the cart service on Redis). But this creates a big problem: how do you keep cross-service data in sync without a global transaction?
The Transactional Outbox pattern
Problem: when an order is placed you want to both save it in your own database and publish an OrderPlaced event to Kafka. If you write to the DB first, then to Kafka, and crash in between, the event is lost (the dual-write problem). Solution: write the event in the same database transaction into an outbox table; then a separate process reads it and sends it to the broker.
Let's look at the outbox table in both dialects you work with, because they have real differences.
-- PostgreSQL
CREATE TABLE outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aggregate VARCHAR(64) NOT NULL,
event_type VARCHAR(128) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published BOOLEAN NOT NULL DEFAULT false
);
-- Batch-read unpublished events with a row lock so parallel workers don't double-process:
SELECT id, event_type, payload
FROM outbox
WHERE published = false
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
-- Oracle (19c/23ai)
CREATE TABLE outbox (
id RAW(16) DEFAULT SYS_GUID() PRIMARY KEY,
aggregate VARCHAR2(64) NOT NULL,
event_type VARCHAR2(128) NOT NULL,
payload CLOB NOT NULL, -- or JSON type in 21c and later
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
published NUMBER(1) DEFAULT 0 NOT NULL -- Oracle had no native boolean
);
-- Oracle equivalent of pagination + skip-lock:
SELECT id, event_type, payload
FROM outbox
WHERE published = 0
ORDER BY created_at
FETCH FIRST 100 ROWS ONLY
FOR UPDATE SKIP LOCKED;
- boolean: PostgreSQL has a native
BOOLEAN; Oracle didn't before 23ai, soNUMBER(1)with 0/1 is the convention. 2) pagination:LIMIT nin PostgreSQL vs.FETCH FIRST n ROWS ONLYin Oracle (standard since 12c; older code usedROWNUM). 3) UUID/GUID:gen_random_uuid()vs.SYS_GUID(). 4) JSON:JSONBin PostgreSQL vs.CLOB/theJSONtype (21c+) in Oracle. 5) Happily,FOR UPDATE SKIP LOCKEDexists in both and is essential for competing worker-queues. 6) Empty string: in Oracle''equalsNULL— a classic trap that doesn't exist in PostgreSQL.
Instead of a worker constantly polling the outbox table, mature teams use Change Data Capture (e.g. Debezium) that reads the database's write-ahead log directly and streams changes to Kafka — with no query pressure on the database and lower latency. This is today's industry-standard pattern for publishing events from a service.
Cross-service data exchange usually takes two shapes: API composition (a service or the gateway calls several services and stitches the responses) or CQRS with a read replica (each service keeps a local read-only copy of the data it needs, fed by events, so it doesn't have to call another service on every request).
8) The Twelve-Factor App
The twelve factors are a set of principles for building services that behave well in the cloud — scale, deploy painlessly, and stay portable. For Spring microservices, the most important ones:
- Config in the environment, not in code (Factor III): secrets and URLs come from environment variables or a config server, not hard-code. In Spring:
application.yml+ env vars + Spring Cloud Config. - Backing services as swappable resources (IV): database, broker, cache all connected via a URL in config, so they can be swapped with no code change.
- Stateless processes (VI): the service keeps no in-memory state that's needed between requests; state goes to the database/cache. This is the prerequisite for horizontal scaling.
- Port binding (VII): the service embeds its own server (Spring Boot with embedded Tomcat/Netty) and listens on a port.
- Concurrency via processes (VIII): for more load, spin up more instances (scale horizontally), not one giant instance.
- Disposability (IX): start fast, shut down gracefully (Spring Boot:
server.shutdown=graceful). - Logs as event streams (XI): log to stdout, not to a file; the platform collects it.
If your service keeps a user session in local memory, then when you spin up three instances, the load balancer sends the user to the second instance, which doesn't have their session, and the user gets logged out. The fix: put the session in Redis or use a stateless JWT. Every instance must be killable and replaceable at any moment without any user noticing. This is the prerequisite for any cloud architecture.
9) A typical Java/Spring topology
Now let's draw a real map: a sample e-commerce system with the standard components of the Spring Cloud ecosystem.
Architecture diagram: the request path from client to services and infrastructure (client → gateway → services → data/broker).
flowchart TD
Client[Web / Mobile Client] --> GW[API Gateway]
GW --> OrderSvc[Order Service]
GW --> PaymentSvc[Payment Service]
GW --> CatalogSvc[Catalog Service]
OrderSvc --> OrderDB[(Order DB)]
PaymentSvc --> PayDB[(Payment DB)]
CatalogSvc --> CatalogDB[(Catalog DB)]
OrderSvc -- OrderPlaced --> Broker[(Kafka Broker)]
Broker --> PaymentSvc
Broker --> ShipSvc[Shipping Service]
Registry{{Service Registry}} -.discovery.- GW
Registry -.discovery.- OrderSvc
Registry -.discovery.- PaymentSvc
Config[[Config Server]] -.config.- OrderSvc
Config -.config.- PaymentSvc
Key components:
- API Gateway: the single entry door. It centralizes routing, authentication, rate limiting, and protocol translation. In Spring: Spring Cloud Gateway.
- Service Registry / Discovery: services don't have fixed addresses (in Kubernetes they move constantly). The registry is like a "live phone book": each service registers itself and others find it by name. Like Eureka or Consul.
- Config Server: centralized, versioned configuration for all services. Like Spring Cloud Config or Consul KV.
- Message Broker: for asynchronous, event-driven communication. Like Kafka or RabbitMQ.
- A database per service: separate, per the data-ownership rule.
Spring Boot is on the 3.5.x line (latest around 3.5.16), and the matching Spring Cloud is release train 2025.0.x "Northfields" and the newer 2025.1.0 "Oakwood" (November 2025). Important for dependencies: in Spring Cloud 2025.0 the gateway starter was renamed. The old spring-cloud-starter-gateway was deprecated and removed in 2025.1; the new name is spring-cloud-starter-gateway-server-webflux (a Web MVC variant exists with the -mvc suffix). The rename clarifies the two gateway styles (server vs. proxy-exchange) and the two web stacks (WebFlux vs. Web MVC).
Much of Spring Cloud Netflix (Zuul, Ribbon, Hystrix, Archaius) has been deprecated or in maintenance mode for years. Zuul gave way to Spring Cloud Gateway; Ribbon gave way to Spring Cloud LoadBalancer; Hystrix gave way to Resilience4j. Eureka still works and is widely used, but for a new project many teams pick either Consul/Nacos or Kubernetes-native service discovery. If you talk about Hystrix in an interview, the senior across the table knows your source is dated — say Resilience4j.
Code: a gateway that routes via discovery
pom.xml (key parts):
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway-server-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!-- Required to resolve lb:// from the registry -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
application.yml on the gateway:
spring:
application:
name: api-gateway
cloud:
gateway:
discovery:
locator:
enabled: false # In production define routes explicitly, not automatically
routes:
- id: order-service
uri: lb://ORDER-SERVICE # lb:// means find via registry and load-balance
predicates:
- Path=/api/orders/**
filters:
- name: CircuitBreaker
args:
name: orderCB
fallbackUri: forward:/fallback/orders
- id: catalog-service
uri: lb://CATALOG-SERVICE
predicates:
- Path=/api/catalog/**
eureka:
client:
service-url:
defaultZone: http://discovery:8761/eureka/
The automatic locator mode automatically exposes every service registered in the registry through the gateway. That means an internal service that should never be seen from outside suddenly becomes reachable from the internet. In production always define routes explicitly and keep the automatic locator off.
Code: a service that registers itself and pulls config
On the order service, application.yml:
spring:
application:
name: order-service
config:
import: "optional:configserver:http://config:8888" # read from Spring Cloud Config
server:
port: 0 # random port; the registry knows the real address
shutdown: graceful # finish in-flight requests before shutting down
management:
endpoints:
web:
exposure:
include: health,info,prometheus
tracing:
sampling:
probability: 1.0 # usually lower in production, e.g. 0.1
eureka:
client:
service-url:
defaultZone: http://discovery:8761/eureka/
The main class and a resilient client:
@SpringBootApplication
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(OrderServiceApplication.class, args);
}
}
// Inter-service call by logical name (not IP), hardened with Resilience4j
@Service
class PaymentClient {
private final RestClient restClient;
PaymentClient(RestClient.Builder builder) {
// "http://payment-service" resolves to a real instance via the load balancer
this.restClient = builder.baseUrl("http://payment-service").build();
}
@CircuitBreaker(name = "payment", fallbackMethod = "fallback")
@Retry(name = "payment")
public PaymentResult authorize(PaymentRequest req) {
return restClient.post()
.uri("/authorizations")
.body(req)
.retrieve()
.body(PaymentResult.class);
}
// Called when the circuit is open or all retries failed
private PaymentResult fallback(PaymentRequest req, Throwable ex) {
return PaymentResult.pending(req.orderId()); // graceful degradation
}
}
A good fallback returns a second-rate answer, not an error. If the recommender service is down, show the most popular products, not a blank page. If payment is slow, record the order as PENDING and process it later. The senior always asks: "if this dependency is down, what's the minimum acceptable experience?" — that's the difference between a system that survives a partial outage and one that fully collapses.
The end-to-end sequence of a request
Sequence diagram: placing an order with a mix of a synchronous call and an asynchronous event.
sequenceDiagram
participant C as Client
participant G as Gateway
participant O as Order Service
participant P as Payment Service
participant K as Kafka
participant S as Shipping Service
C->>G: POST /api/orders
G->>O: route (lb + traceId)
O->>O: persist Order (status=PENDING) + outbox row
O-->>K: OrderPlaced (via outbox/CDC)
O-->>G: 202 Accepted
G-->>C: 202 Accepted
K->>P: OrderPlaced
P->>P: authorize payment
P-->>K: PaymentAuthorized
K->>O: PaymentAuthorized -> status=CONFIRMED
K->>S: PaymentAuthorized -> schedule shipment
Notice the response to the user (202 Accepted) returns before payment completes. That's deliberate: the system is asynchronous and eventually consistent. The user gets an immediate answer, and the rest of the work proceeds in the background via events. This pattern gives scale and resilience, but it disturbs the monolith-shaped mind because things are no longer "right now and atomic."
10) Communication patterns: synchronous vs. asynchronous
Synchronous: service A waits for B's response (REST/gRPC). Simple to reason about, but creates temporal coupling: if B is down, A hangs too, and this can cascade the whole system down (cascading failure). Asynchronous: A sends an event to the broker and moves on; B processes whenever it can. More resilient and decoupled, but adds the complexity of eventual consistency and messaging. Golden rule: for a query the user is waiting on, synchronous; for commands and propagating state changes between services, asynchronous. A long synchronous call chain (A→B→C→D) is a design smell.
Imagine service D slows down. C, waiting on D, fills up its threads. B, waiting on C, fills up its threads. Quickly the whole system locks up — all because of one slow dependency. A circuit breaker breaks this chain: after several consecutive failures, it "opens the circuit" and returns a fallback immediately instead of waiting. Alongside it you need timeouts and bulkheads (isolating thread pools) so one sick dependency can't swallow the whole service.
State diagram: the lifecycle of a circuit breaker (closed → open → half-open).
stateDiagram-v2
[*] --> Closed
Closed --> Open: failure rate > threshold
Open --> HalfOpen: after wait duration
HalfOpen --> Closed: trial calls succeed
HalfOpen --> Open: trial calls fail
In the Closed state everything is normal and calls pass through. When the failure rate crosses the threshold, it goes to Open and all calls immediately hit the fallback (we don't hammer D, giving it a chance to recover). After a wait period, it goes to Half-Open and sends a few trial calls; if they succeed, back to Closed; if not, Open again. In Spring you implement this with Resilience4j.
11) Migration with the Strangler Fig pattern
Almost no one has successfully built microservices from scratch. The right path is to gradually break up an existing monolith. The standard pattern is the Strangler Fig.
The strangler fig grows around a host tree, gradually covering its branches, until the original tree rots away and the fig completely takes its place — without a moment of empty space. Martin Fowler took this as the migration metaphor: put a routing layer in front of the monolith, and move feature by feature to new services. The monolith is gradually "strangled" until nothing remains — without one big risky rewrite.
The practical steps:
- Put a gateway/proxy in front of the monolith so all traffic goes through it (with no behavior change).
- Pick a low-risk, high-value bounded context (e.g. "notifications" or "catalog").
- Build that capability as a new service, and in the gateway route it to the new service (not the monolith).
- Sync that domain's data via CDC/events so the monolith and the new service stay consistent during the transition.
- Repeat; one slice at a time, until the monolith shrinks to a small core or disappears entirely.
Diagram: a routing layer splitting traffic between the monolith and the extracted service (strangler routing).
flowchart LR
Client --> Router[Strangler Facade / Gateway]
Router -->|/catalog/**| NewCatalog[New Catalog Service]
Router -->|everything else| Monolith[Legacy Monolith]
NewCatalog --> CatalogDB[(Catalog DB)]
Monolith --> LegacyDB[(Legacy DB)]
Monolith -. sync via CDC .-> CatalogDB
Pick the first slice to have the fewest inbound dependencies from the rest of the monolith — an edge capability like sending email, reporting, or search. If you first extract the tangled heart of the system (e.g. "user account," which everything depends on), you'll drown in a sea of dependencies and the project fails. A successful first cut builds trust and momentum.
The most tempting and most fatal decision: "let's throw away the monolith and rewrite everything from scratch as microservices." This almost always fails because: (1) the monolith holds thousands of unwritten business rules that live only in the code, (2) during the rewrite two systems must be maintained at once, (3) the business doesn't stop and keeps demanding features. The Strangler Fig makes the risk incremental and reversible; big-bang bets everything on one launch.
12) Team ownership: "You build it, you run it"
The organizational philosophy of microservices is summed up in one line from Werner Vogels (Amazon's CTO): "You build it, you run it." The team that builds the service also runs and supports it in production — they carry the pager too.
When the same team that wrote the code gets woken at 3 a.m. by that service's outage, they have a completely different incentive for quality, observability, and resilient design. Separating "development" from "operations" (the old throw-over-the-wall model) is exactly what DevOps and microservices formed against. Ownership creates quality.
Each service has a clear owner and a clear contract with its consumers. To keep one team's change from breaking another, teams use contract testing (e.g. Spring Cloud Contract or Pact): the consumer writes its API expectations as tests, and the producer's pipeline verifies that contract. This way an incompatible change is caught before production, not after.
When 5 independent teams become consumers of your service, you can no longer change the API at will. An unversioned breaking change breaks all consumers at once and forces you to deploy everyone together — i.e. back to the distributed monolith. The rule: make additive changes (a new optional field is fine), don't remove fields or change their types, and for a breaking change create a new version (/v2) and keep the previous one alive through a transition period.
13) How a senior justifies the choice
In an interview or a design meeting, the difference between junior and senior is in how they reason. The junior says "microservices scales better." The senior brings a trade-off framework:
- How many teams? If it's one small team, probably a modular monolith. Microservices shine when multiple independent teams want to release independently.
- Platform maturity? Do you have CI/CD, containerization, and observability? If not, those first.
- Asymmetric load? Is there a part that must scale independently and very differently from the rest? That's a real technical reason.
- Domain stability? Do you know the boundaries? If the domain is still being discovered, keep boundaries cheap and inside a monolith.
- Fault isolation? Must an outage in one part not take down the whole system?
- Cost vs. benefit: weigh the operational cost and distributed complexity against team velocity and scale. If the benefit isn't clear, don't go.
The key sentence you should be able to say in an interview: "Microservices is an organizational optimization paid for with technical complexity. I buy that complexity when its organizational benefit (team autonomy, independent scale, fault isolation) outweighs its cost — and until then I build a good modular monolith that keeps my migration path open." This sentence shows you understand the trade-off, not just the buzzword.
Interview questions
Both split a system into services, but the philosophy differs. SOA (service-oriented architecture) typically revolves around a central smart Enterprise Service Bus (ESB) that holds routing, transformation, and orchestration logic; services often share a database and the mesh is heavy. Microservices follow the "smart endpoints, dumb pipes" philosophy: the intelligence lives in the services themselves and the broker/network just moves messages. Microservices are also far stricter about a database per service and independent deployment. In short: microservices can be seen as "SOA done right, with an emphasis on decoupling and independent ownership."
When the team is small, the domain isn't stable yet, the platform (CI/CD and observability) isn't mature, load isn't asymmetric, or the core problem is "messy code" rather than "scale." Microservices multiply operational cost and distributed complexity; without a clear organizational benefit (multiple independent teams, independent scale, fault isolation), that cost isn't justified. My advice under ambiguity is a modular monolith — clean boundaries without network cost, with an open path to split later. I'd quote "Monolith First."
A bounded context is a DDD concept: a boundary within which one domain model and one shared language have a precise, unique meaning. For example, "customer" in sales is a different model than "customer" in accounting. A good microservice boundary almost always falls on a bounded context, because there internal coupling is high and external coupling is low — meaning the service can operate independently. If you draw the boundary elsewhere (e.g. on a technical layer or a single data table), your services become sticky and are always called together.
Conway's law says a system's design mirrors the organization's communication structure. So if service boundaries aren't aligned with team boundaries, coordination cost kills you. The practical use is the Inverse Conway Maneuver: instead of designing the architecture and hoping the teams fit it, first arrange the teams so they produce the architecture you want — i.e. an independent end-to-end (stream-aligned) team per service/value stream. This is the central idea of "Team Topologies."
Because a shared database destroys independence — the very thing you paid for by going distributed. With a shared database, a schema change by one service breaks the others; you can't deploy or scale independently; and database locks leak between services. With a separate database, each service fully owns its own data and schema and exposes it to others only via API/events. The cost is that you no longer have a global ACID transaction and must live with eventual consistency and patterns like Saga and outbox.
The dual-write problem: you want to both write to the database and publish an event to Kafka, but these are two different resources with no shared atomic transaction. If you crash between them, either the data is saved but the event is lost, or vice versa. Solution: write the event in the same database transaction as the state change, into an outbox table (so either both commit or neither does). Then a separate process — via polling or, better, CDC like Debezium — ships events from the outbox to the broker. This gives at-least-once delivery; that's why consumers must be idempotent.
When a deep service in the chain slows down, upstream services waiting on it fill up their threads and also slow/lock; the failure propagates domino-style up the chain until the whole system goes down. Defensive tools: timeouts (never wait infinitely), circuit breaker (after several failures, open the circuit and return a fallback immediately so the sick dependency can recover), bulkhead (isolate thread pools so one slow dependency doesn't swallow all capacity), and retry with backoff and jitter (carefully, or it adds load itself). In Spring you implement these with Resilience4j.
Synchronous (REST/gRPC): A waits for B's response. Simple but has temporal coupling — if B is down A hangs too. Asynchronous (messages on a broker): A sends the event and moves on; decoupled and resilient but brings eventual consistency and messaging complexity. Rule: for a query the user is waiting on right now, synchronous; for propagating state changes and commands between services, asynchronous. A long synchronous chain (A→B→C→D) is a sign of bad boundaries and you should either merge services or switch to events.
A method for gradual migration from monolith to microservices without a big rewrite. You put a routing layer (facade/gateway) in front of the monolith; then you move one capability to a new service and route it in the gateway to the new service, while everything else still goes to the monolith. You keep data in sync via CDC/events during the transition. You proceed slice by slice until the monolith is "strangled." The advantage is that each small slice is reversible and low-risk — unlike a big-bang rewrite, which almost always fails. Note: extract the "leaf" slices with the fewest dependencies first, not the tangled heart of the system.
The gateway is the single entry door and centralizes cross-cutting concerns: routing, edge authentication/authorization, rate limiting, protocol translation, SSL termination, and sometimes API composition. But business logic should not be in the gateway — that's an anti-pattern that turns the gateway into a fat ESB and a single point of coupling. The gateway must stay thin. In Spring you use Spring Cloud Gateway (whose starter was renamed to spring-cloud-starter-gateway-server-webflux in 2025.0). Practical warning: turn off discovery.locator.enabled=true in production so internal services aren't unintentionally exposed.
In a dynamic environment (e.g. Kubernetes), service instances constantly come up/down and change IPs, so you can't hard-code addresses. Service discovery is like a "live phone book": each instance registers itself in a registry and consumers find the service by its logical name and load-balance across instances. There are two models: client-side (like Eureka + Spring Cloud LoadBalancer, where the client gets the list and picks itself) and server-side (like a Kubernetes Service that gives a fixed IP and an internal load balancer). Up-to-date note: Netflix Eureka still works but the Netflix stack is in maintenance; for new projects Consul/Nacos or Kubernetes-native discovery are more common.
With distributed tracing. A unique traceId is created at the entry (gateway) and propagated across every hop — HTTP header or Kafka message header alike — and each service records its own span under that same trace. Then in a backend (Tempo/Jaeger/Zipkin) you see the whole request journey as a waterfall and understand where it slowed or broke. In Spring this is done with Micrometer Tracing (the successor to Spring Cloud Sleuth), usually over OpenTelemetry. Without tracing, debugging a distributed bug is nearly impossible — it's part of the observability triad (logs, metrics, traces) that's a prerequisite for microservices.
A system that looks like microservices (several separate services on the network) but is in practice tightly coupled: services can't be deployed independently, share a database, or any change requires a coordinated same-time change across several services. This is the worst case because you pay all the cost of going distributed (network latency, network errors, hard debugging, multiplied ops) while getting none of its benefits (independent deployment and teams, independent scale). The root cause is usually wrong boundaries (technical instead of bounded context) and a shared database. The fix: correct the boundaries with DDD and separate data ownership.
With versioning and backward compatibility. The main rule: additive changes are free (adding a new optional field doesn't break old consumers), but removing a field, changing its type, or changing its meaning is breaking. For a breaking change you create a new version (/v2, or via header/media-type) and keep the previous one alive through an announced transition period so consumers can migrate. To catch incompatibilities before production you use contract testing (Spring Cloud Contract / Pact): the consumer's expectations are recorded as tests and the producer's pipeline verifies them. Without this discipline, one change breaks all consumers at once and drags you back into a distributed monolith.
Three stand out. Config in the environment (not hard-coded) so a single artifact runs in every environment without a rebuild. Stateless processes so you can scale horizontally and kill/replace any instance at any moment painlessly (session in Redis or JWT, not local memory). Disposability / graceful shutdown so rolling deploys and autoscaling work without losing in-flight requests (in Spring Boot: server.shutdown=graceful). Alongside these, backing services as swappable resources and logs as event streams to stdout are also key for portability and observability.
- Microservices is an organizational decision: choose it for team autonomy, independent scale, and fault isolation — not for "cleanliness" or fashion.
- You have three options, not two: monolith, modular monolith (often the real winner), and microservices. Under ambiguity, build modular and keep the split path open.
- See the real cost: multiplied ops, distributed debugging, distributed data (eventual consistency), and the eight network fallacies. Platform before microservices.
- Draw boundaries with bounded contexts (DDD), not technical layers. Align service boundaries with team boundaries (Conway and its inverse maneuver).
- Each service, its own database. Sync cross-service data with events, outbox/CDC, and Saga — not a shared database.
- The Spring topology: gateway + discovery + config + broker + a database per service. With today's versions (Spring Boot 3.5.x, Spring Cloud 2025.x) and today's tools (Resilience4j, Micrometer Tracing, Spring Cloud Gateway) — not Hystrix/Zuul.
- Make migration gradual and reversible with the Strangler Fig; never do a big-bang rewrite.
- "You build it, you run it": end-to-end ownership, API versioning, and contract testing are the glue that keeps multiple independent teams from becoming a distributed monolith.