Microservices (Java/Spring) · میکروسرویس سنیورSenior ~52 دقیقه مطالعه~45 min read
ارتباطِ سرویسها و مسیرِ کاملِ یک درخواستService Communication & the Full Request Path
هر چیزی که یک سرویس را به سرویسِ دیگر وصل میکند — از REST و OpenFeign و gRPC تا Kafka و RabbitMQ — بههمراهِ ردیابیِ کاملِ یک درخواست، الگوهای choreography/orchestration و saga، و ابزارهایِ بقا در تولید: idempotency، timeout، retry با backoff و jitter، deadline propagation، contract testing و نسخهبندیِ API.Everything that wires one service to another — REST, OpenFeign, gRPC, Kafka and RabbitMQ — plus the full end-to-end path of a single request, choreography vs orchestration and the saga idea, and the survival kit for production: idempotency, timeouts, retries with backoff and jitter, deadline propagation, contract testing and API versioning.
پیشنیاز:Prerequisites: میکروسرویس: مبانی، مرزبندی و کِی/چراMicroservices: Foundations, Boundaries & When/Why
وقتی یک برنامه را از حالتِ monolith به microservices میشکنی، یک چیزِ عجیب اتفاق میافتد: کدی که دیروز فقط یک method call ساده بود — orderService.charge(user) — امروز تبدیل میشود به یک سفرِ کامل روی شبکه؛ با تأخیر، خطا، timeout، از دست رفتنِ بسته، و سرویسی که آنطرفِ خط ممکن است اصلاً جواب ندهد. این فصل دقیقاً دربارهٔ همان «فاصلهٔ بینِ سرویسها» است: چطور دو سرویس با هم حرف میزنند، یک درخواست از لحظهای که کاربر روی دکمه میزند تا لحظهای که پاسخ برمیگردد چه مسیری طی میکند، و چطور کاری کنیم که این مسیر در دنیایِ واقعی — که شبکه غیرقابلاعتماد است — نشکند.
در monolith، صدا زدنِ یک متد مثل این است که در یک اتاق نشستهای و از همکارِ کناریات میپرسی «قیمتِ این کالا چنده؟» — جواب فوری است، همیشه میآید، و اگر او سکته کند تو هم میمیری (همان process). در microservices همان سؤال تبدیل میشود به یک تماسِ تلفنی (synchronous؛ منتظر میمانی تا جواب بدهد) یا یک نامه در صندوقِ پستی (asynchronous؛ میاندازی و میروی، بعداً جواب میرسد). تلفن سریع است ولی اگر طرف نباشد پشتِ خط معطل میمانی؛ نامه کُند است ولی اگر گیرنده مسافرت باشد، نامه در صندوق منتظر میماند. کلِ این فصل، انتخابِ درست بینِ «تلفن» و «نامه» و مدیریتِ عواقبِ هرکدام است.
در این فصل اینها را از صفر تا سطحِ senior میسازیم:
- دو خانوادهٔ ارتباط: synchronous (REST, OpenFeign, RestClient/WebClient, gRPC) در برابر asynchronous (Kafka, RabbitMQ) و اینکه هرکدام کِی درست است.
- مسیرِ کاملِ یک درخواست: از client → gateway → serviceA → serviceB → DB → پاسخ، با یک sequence diagram.
- دو سبکِ هماهنگی: choreography در برابر orchestration، و معرفیِ ایدهٔ saga برای تراکنشهایِ توزیعشده.
- جعبهابزارِ بقا در تولید: idempotency key، timeout، retry با backoff و jitter، deadline و deadline propagation.
- قراردادها: contract testing با Spring Cloud Contract و Pact، و نسخهبندیِ API.
- تلهٔ کلاسیک: مشکلِ N+1 و «سرویسِ پرحرف» (chatty service).
نسخههایی که در این فصل مبنا قرار میدهیم واقعی و امروزیاند: Spring Boot 3.5 بههمراهِ Spring Cloud 2025.0 (نامِ کدِ Northfields) که در ۲۰۲۵ منتشر شد، و پروژهٔ رسمیِ Spring gRPC 1.0 GA که از حالتِ experimental به عضوِ کاملِ خانوادهٔ Spring ارتقا یافت.
۱) دو خانوادهٔ بزرگ: synchronous و asynchronous
اول از همه بیایید جهان را به دو نیم تقسیم کنیم، چون ۹۰٪ تصمیمهایِ معماری از همین یک انتخاب شروع میشوند.
Synchronous (همزمان / درخواست-پاسخ): سرویسِ A درخواست میفرستد و بلاک میشود تا جوابِ سرویسِ B برسد. مثلِ تماسِ تلفنی. رابطهٔ زمانی سفتوسخت است: هردو باید همزمان زنده و آماده باشند. پروتکلها: HTTP/REST، gRPC.
Asynchronous (ناهمزمان / پیاممحور): سرویسِ A یک پیام را در یک واسطه (broker مثلِ Kafka یا RabbitMQ) میگذارد و بلافاصله برمیگردد سرِ کارش. سرویسِ B هر وقت توانست پیام را برمیدارد و پردازش میکند. مثلِ نامه. رابطهٔ زمانی شل است: فرستنده و گیرنده لازم نیست همزمان زنده باشند.
یک senior وقتی طراحی میکند، پیشفرضِ ذهنیاش این است: «اگر پاسخِ فوری برایِ کاربر لازم است (مثلِ خواندنِ پروفایل یا اعتبارسنجیِ یک پرداخت) → synchronous. اگر کاری است که میتواند کمی دیرتر انجام شود و نباید مسیرِ کاربر را کند کند (ارسالِ ایمیل، بهروزرسانیِ آمار، ایندکسکردن) → asynchronous.» قانونِ طلایی: coupling را کم کن. هر تماسِ synchronous یک زنجیرِ سفت بینِ دو سرویس میسازد؛ اگر B کند شود، A هم کند میشود و این کندی مثلِ دومینو بالا میرود. asynchronous این زنجیر را میشکند اما پیچیدگیِ «سازگاریِ نهایی» (eventual consistency) را میآورد.
نمای کلیِ توپولوژی — نگاشتِ سرویسها، gateway و broker | Topology overview: services, gateway and broker.
flowchart LR
Client([Mobile / Web]) --> GW[API Gateway]
GW --> Order[Order Service]
GW --> User[User Service]
Order -->|sync REST/gRPC| Payment[Payment Service]
Order --> OrderDB[(Order DB)]
User --> UserDB[(User DB)]
Order -->|async event| Broker[(Kafka / RabbitMQ)]
Broker --> Notify[Notification Service]
Broker --> Analytics[Analytics Service]
دقت کن: پرداخت را synchronous صدا میزنیم چون کاربر باید همان لحظه بداند تراکنش موفق بوده یا نه؛ ولی «ارسالِ نوتیفیکیشن» و «آمار» را asynchronous رد میکنیم چون کاربر لازم نیست منتظرشان بماند. این تفکیک، قلبِ طراحیِ درست است.
| ویژگی | Synchronous (REST/gRPC) | Asynchronous (Kafka/RabbitMQ) |
|---|---|---|
| مدلِ زمانی | درخواست-پاسخ، بلاکشونده | fire-and-forget، غیربلاک |
| coupling زمانی | بالا (هردو باید زنده باشند) | پایین (broker میانجی است) |
| سازگاری | strong / immediate | eventual (نهایی) |
| مدیریتِ خطا | timeout/retry سمتِ caller | retry/DLQ سمتِ consumer |
| فشارِ بار (backpressure) | سخت؛ caller غرق میشود | طبیعی؛ صف بافر میکند |
| مناسبِ | خواندنِ فوری، پرداخت، auth | event، کارِ پسزمینه، fan-out |
| ابزارِ دیباگ | سادهتر (یک stack trace) | سختتر (مسیرِ پیام پخش است) |
معیارِ اصلی این است: «آیا کاربر باید همینالان نتیجه را ببیند و آیا آن نتیجه بخشی از پاسخِ اوست؟» اگر بله (اعتبارسنجیِ پرداخت، خواندنِ دادهای که در صفحه نمایش داده میشود، auth) → synchronous، چون به پاسخِ فوری و قطعی نیاز داری. اگر نه — کاری که میتواند کمی دیرتر انجام شود و نباید latency مسیرِ کاربر را زیاد کند (ایمیل، نوتیفیکیشن، آمار، ایندکس، fan-out به چند مصرفکننده) → asynchronous، چون coupling را کم میکند و مسیرِ کاربر را سریع و مقاوم نگه میدارد. عاملِ دومِ تصمیم، تحملِ سیستم در برابرِ خطاست: تماسِ synchronous یعنی اگر downstream بیفتد تو هم میافتی؛ async با broker این وابستگیِ زمانی را میشکند و در ازایش eventual consistency را میپذیرد. یک senior اغلب یک مسیرِ ترکیبی میسازد: هستهٔ تراکنش synchronous، و همهچیزِ جانبی async.
۲) ارتباطِ synchronous در Spring
۲-۱) REST: از RestTemplate تا RestClient
سالها ابزارِ پیشفرضِ Spring برایِ صدا زدنِ HTTP، کلاسِ RestTemplate بود. اما RestTemplate در حالتِ maintenance است و برایِ کدِ جدید توصیه نمیشود. از Spring Framework 6.1 به بعد، یک کلاینتِ synchronousِ مدرن و fluent بهنامِ RestClient آمده که همان قدرتِ RestTemplate را دارد ولی با APIِ زنجیرهای و خوانا — و از همان زیرساختِ message converter استفاده میکند.
// ساختِ یک RestClient با base URL و یک default header
@Configuration
public class HttpClients {
@Bean
RestClient paymentClient(RestClient.Builder builder) {
return builder
.baseUrl("http://payment-service") // با service discovery رزولوشن میشود
.requestInterceptor(new TraceIdPropagator()) // ادامهٔ trace context
.build();
}
}
@Service
public class OrderService {
private final RestClient paymentClient;
public OrderService(RestClient paymentClient) {
this.paymentClient = paymentClient;
}
public PaymentResult charge(ChargeRequest req) {
return paymentClient.post()
.uri("/payments")
.contentType(MediaType.APPLICATION_JSON)
.header("Idempotency-Key", req.idempotencyKey())
.body(req)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (request, response) -> {
throw new PaymentRejectedException(response.getStatusText());
})
.body(PaymentResult.class);
}
}
RestClient (synchronous, بلاکشونده) برایِ اکثرِ سرویسهایِ معمولیِ MVC انتخابِ درست است — کد ساده و خطی است. WebClient غیربلاک و reactive است (روی Project Reactor) و وقتی بهدردت میخورد که کلِ استکات reactive باشد (Spring WebFlux) یا بخواهی همزمان صدها درخواستِ موازی را بدونِ مصرفِ صدها thread مدیریت کنی. اشتباهِ رایج: آوردنِ WebClient در یک اپِ کاملاً blocking فقط چون «مدرنتر است» — بعد با .block() همهچیز را دوباره blocking میکنی و بدترین دو دنیا را میگیری. برایِ MVC از RestClient استفاده کن.
۲-۲) OpenFeign: کلاینتِ اعلانی (declarative)
نوشتنِ دستیِ URL و body برایِ هر تماس خستهکننده است. Spring Cloud OpenFeign میگذارد که تماسِ HTTP را مثلِ یک interface تعریف کنی و Spring پیادهسازیاش را میسازد. تو فقط قرارداد را مینویسی.
@FeignClient(name = "payment-service", path = "/payments")
public interface PaymentClient {
@PostMapping
PaymentResult charge(@RequestHeader("Idempotency-Key") String key,
@RequestBody ChargeRequest req);
@GetMapping("/{id}")
PaymentResult find(@PathVariable("id") String id);
}
# پیکربندیِ timeout — بدونِ این، پیشفرضها میتوانند خطرناک باشند
spring:
cloud:
openfeign:
client:
config:
payment-service:
connect-timeout: 1000 # میلیثانیه؛ برقراریِ اتصال
read-timeout: 2000 # میلیثانیه؛ انتظار برایِ پاسخ
default:
logger-level: basic
پرتکرارترین حادثهٔ تولید با Feign این است: تیم یک @FeignClient مینویسد، read-timeout را ست نمیکند، و یک روز سرویسِ downstream کند میشود. حالا هر thread ای که آن Feign را صدا زده تا دقیقهها بلاک میماند، thread poolِ سرویس پُر میشود، و سرویسی که هیچ باگی نداشت هم میافتد — این را «cascading failure» یا آبشارِ خرابی میگویند. همیشه روی هر کلاینتِ synchronous یک connect-timeout و read-timeoutِ صریح بگذار. تایماوتِ بینهایت یعنی «صبر کن تا ابد» و ابد در تولید یعنی فاجعه.
RestClient یک کلاینتِ synchronous و fluent است (Spring 6.1+) که جایگزینِ مدرنِ RestTemplate شده؛ برایِ تماسهایِ blockingِ ساده در MVC عالی است. WebClient غیربلاک و reactive است (WebFlux/Reactor)؛ وقتی میخواهی هزاران تماسِ همزمان با تعدادِ کمی thread داشته باشی یا کلِ استک reactive است انتخابش کن. OpenFeign یک لایهٔ declarative روی اینهاست: بهجایِ نوشتنِ imperative، یک interface تعریف میکنی و Spring کد را تولید میکند؛ خوانایی و یکپارچگی با service discovery و load balancing را میدهد اما یک وابستگیِ Spring Cloud اضافه میکند. یک senior میگوید: برایِ چند تماسِ ساده RestClient، برایِ interfaceهایِ تمیز و تکراری OpenFeign، و برایِ concurrencyِ بالا WebClient.
۲-۳) gRPC: وقتی سرعت و قرارداد مهم است
REST روی HTTP/1.1 و JSON کار میکند: خوانا، ساده، جهانشمول — ولی JSON حجیم است و parse کردنش هزینه دارد. gRPC پروتکلی است رویِ HTTP/2 که پیامها را با Protocol Buffers (protobuf) بهشکلِ باینریِ فشرده کد میکند و از یک فایلِ قرارداد .proto هم کدِ سرور و هم کلاینت را تولید میکند. مزیتها: سریعتر، سبکتر، قراردادِ سفتوسخت (strongly typed)، و پشتیبانی از streaming دوطرفه.
// payment.proto — منبعِ حقیقتِ قرارداد بینِ دو سرویس
syntax = "proto3";
package payment;
service PaymentService {
rpc Charge (ChargeRequest) returns (ChargeResponse);
}
message ChargeRequest {
string order_id = 1;
int64 amount_cents = 2;
string idempotency_key = 3;
}
message ChargeResponse {
string payment_id = 1;
bool approved = 2;
}
از می ۲۰۲۵ پروژهٔ رسمیِ Spring gRPC به نسخهٔ ۱.۰ GA رسید و به عضوِ کاملِ خانوادهٔ Spring تبدیل شد؛ حالا starterِ رسمی org.springframework.grpc:spring-grpc-spring-boot-starter داری که هم سمتِ سرور و هم کلاینت را با autoconfiguration پوشش میدهد.
// سمتِ سرور: پیادهسازیِ سرویسِ تولیدشده از proto
@Service
public class PaymentGrpcService extends PaymentServiceGrpc.PaymentServiceImplBase {
@Override
public void charge(ChargeRequest req, StreamObserver<ChargeResponse> obs) {
var response = ChargeResponse.newBuilder()
.setPaymentId(UUID.randomUUID().toString())
.setApproved(true)
.build();
obs.onNext(response); // پاسخ را میفرست
obs.onCompleted(); // جریان را ببند
}
}
gRPC درخششِ واقعیاش در ارتباطِ سرویس-به-سرویسِ داخلی است، جایی که کارایی و قراردادِ محکم ارزش دارند و هر دو طرف را خودت کنترل میکنی. برایِ APIهایِ عمومی که مرورگرها و تیمهایِ بیرونی مصرف میکنند، REST/JSON هنوز پادشاه است چون همهجا کار میکند، در مرورگر دیباگ میشود، و منحنیِ یادگیریاش صفر است. مرورگرها gRPC را مستقیم پشتیبانی نمیکنند (نیاز به gRPC-Web و proxy). قاعده: REST به بیرون، gRPC به داخل — مخصوصاً برایِ مسیرهایِ داغ و پرترافیک.
| معیار | REST (HTTP/1.1 + JSON) | gRPC (HTTP/2 + protobuf) |
|---|---|---|
| فرمت | متنی، خوانا | باینری، فشرده |
| قرارداد | شل (OpenAPI اختیاری) | سفت (فایلِ .proto) |
| کارایی | متوسط | بالا (سریعتر، سبکتر) |
| streaming | محدود (SSE) | native، دوطرفه |
| پشتیبانیِ مرورگر | کامل | نیازمندِ gRPC-Web |
| دیباگ با curl | بله | خیر (ابزارِ خاص) |
| بهترین کاربرد | API عمومی | mesh داخلی، low-latency |
۳) ارتباطِ asynchronous: Kafka و RabbitMQ
وقتی میگوییم asynchronous، یعنی یک broker وسط میگذاریم. اما Kafka و RabbitMQ دو فلسفهٔ متفاوت دارند و اشتباهگرفتنشان یکی از پرتکرارترین خطاهایِ طراحی است.
RabbitMQ مثلِ یک مرکزِ پستِ هوشمند است: نامهها را میگیرد، بر اساسِ آدرس (routing key) به صفهایِ مختلف مسیریابی میکند، و بهمحضِ اینکه نامهرسان (consumer) نامه را برداشت و تأیید کرد (ack)، نامه از صف پاک میشود. تمرکزش روی «تحویلِ کار به یک worker» است. Kafka مثلِ یک دفترِ رویدادهایِ روزنامه است که همهچیز را بهترتیب مینویسد و نگه میدارد (مثلاً ۷ روز). هر خواننده (consumer group) با یک نشانگر (offset) میگوید «من تا اینجا خواندهام». پیام بعد از خواندن پاک نمیشود؛ ده گروهِ مختلف میتوانند همان رویداد را مستقل بخوانند. تمرکزش روی «جریانِ رویدادها و بازپخش» است.
توپولوژیِ دو broker — الگویِ صف در برابر الگویِ لاگ | Two broker topologies: queue vs log.
flowchart TB
subgraph RabbitMQ [RabbitMQ: routing then delete]
P1[Producer] --> EX{Exchange}
EX -->|key=email| Q1[Queue email]
EX -->|key=sms| Q2[Queue sms]
Q1 --> C1[Worker A]
Q2 --> C2[Worker B]
end
subgraph Kafka [Kafka: append-only log, retained]
P2[Producer] --> T[(Topic: orders)]
T --> G1[Consumer Group: billing]
T --> G2[Consumer Group: analytics]
end
۳-۱) Kafka با Spring
// تولیدکننده: انتشارِ یک event
@Service
public class OrderEventPublisher {
private final KafkaTemplate<String, OrderPlaced> kafka;
public OrderEventPublisher(KafkaTemplate<String, OrderPlaced> kafka) {
this.kafka = kafka;
}
public void publish(OrderPlaced event) {
// کلید = orderId؛ همهٔ eventهایِ یک سفارش به یک partition میروند => ترتیب حفظ میشود
kafka.send("orders", event.orderId(), event);
}
}
// مصرفکننده
@Component
public class BillingListener {
@KafkaListener(topics = "orders", groupId = "billing")
public void onOrder(OrderPlaced event) {
// idempotent باشد! ممکن است این پیام بیش از یکبار تحویل شود
billing.charge(event.orderId(), event.amount());
}
}
Kafka و RabbitMQ در حالتِ عادی at-least-once تحویل میدهند: یعنی تضمین میکنند پیام حداقل یکبار میرسد، اما ممکن است چند بار برسد (مثلاً اگر consumer پردازش کرد ولی قبل از commitِ offset کرش کرد). پس هر consumer باید idempotent باشد. exactly-once در Kafka با transactionها ممکن است ولی هزینه و پیچیدگی دارد و در مرزِ سیستمهایِ خارجی (مثلِ صدا زدنِ یک payment gateway) عملاً وجود ندارد. قانون: at-least-once را فرض کن و idempotency را بساز. این را بعداً کامل میکنیم.
۳-۲) RabbitMQ با Spring
@Component
public class EmailListener {
@RabbitListener(queues = "email.welcome")
public void onSignup(UserSignedUp event) {
emailService.sendWelcome(event.email());
}
}
هردو broker اند اما مدلِ ذهنیشان فرق دارد. RabbitMQ یک message brokerِ سنتی (مدلِ صف) است: عالی برایِ توزیعِ کار (task queue) بینِ workerها، routingِ پیچیده با exchange و routing key، و الگویِ request/reply. پیام بعد از ack پاک میشود. Kafka یک لاگِ توزیعشدهٔ append-only است: پیامها نگهداری میشوند، throughputِ خیلی بالا دارد، امکانِ بازپخش (replay) و چند consumer group مستقل روی همان داده، و مناسبِ event streaming و event sourcing است. یک senior میگوید: «اگر میخواهی یک کار را به یک worker بسپاری و تمام → RabbitMQ. اگر میخواهی یک رویداد را منتشر کنی که چند سیستمِ مختلف الان و در آینده مصرفش کنند، و شاید بخواهی تاریخچه را بازپخش کنی → Kafka.» عاملِ مقیاس هم مهم است: Kafka برایِ میلیونها پیام در ثانیه ساخته شده.
۴) مسیرِ کاملِ یک درخواست: از client تا DB و برگشت
حالا بیایید یک درخواستِ واقعی را ذرهذره دنبال کنیم. کاربر روی «ثبتِ سفارش» میزند. این درخواست از چه لایههایی عبور میکند؟
۱. Client (مرورگر/موبایل) درخواستِ HTTPS را به سمتِ یک دامنه میفرستد.
۲. API Gateway (مثلِ Spring Cloud Gateway) نقطهٔ ورودیِ واحد است: TLS را terminate میکند، توکن را با auth چک میکند، rate limit میگذارد، یک trace id تولید یا تزریق میکند، و درخواست را به سرویسِ درست route میکند.
۳. Order Service درخواست را میگیرد، اعتبارسنجی میکند، و برایِ کسرِ پول بهشکلِ synchronous به Payment Service زنگ میزند.
۴. Payment Service با DB خودش کار میکند و نتیجه را برمیگرداند.
۵. Order Service سفارش را در DB خودش ذخیره میکند، یک event (OrderPlaced) روی Kafka منتشر میکند (برایِ کارهایِ async مثلِ ایمیل)، و پاسخ را به gateway و از آنجا به client برمیگرداند.
ردیابیِ end-to-end یک درخواست — مسیرِ synchronous بههمراهِ یک شاخهٔ async | End-to-end trace of one request, sync path plus one async branch.
sequenceDiagram
autonumber
participant C as Client
participant G as API Gateway
participant O as Order Service
participant P as Payment Service
participant DB as Order DB
participant K as Kafka
C->>G: POST /orders (JWT, Idempotency-Key)
G->>G: authN/authZ, rate limit, inject trace-id
G->>O: POST /orders (+ trace-id, deadline)
O->>P: charge(order) [sync, timeout 2s]
P->>P: check Idempotency-Key
P-->>O: 200 paymentId (approved)
O->>DB: INSERT order (status=PAID)
DB-->>O: ok
O-->>K: publish OrderPlaced (fire-and-forget)
O-->>G: 201 Created {orderId}
G-->>C: 201 Created {orderId}
Note over K: Notification & Analytics<br/>consume asynchronously later
دقت کن که چه چیزهایی رویِ مسیرِ پاسخِ کاربر اند و چه چیزهایی نیستند. پرداخت روی مسیر است چون کاربر باید نتیجهاش را ببیند. اما ارسالِ ایمیلِ تأیید، بهروزرسانیِ داشبوردِ فروش، و ایندکسِ جستجو نباید روی مسیر باشند — آنها را با یک event به Kafka میسپاریم و مسیرِ کاربر بلافاصله بسته میشود. هرچه کارِ کمتری روی hot path بگذاری، latency پایینتر و مقاومتِ سیستم بالاتر است. این سادهترین و بزرگترین برندهٔ کارایی در microservices است.
ریاضیِ ترسناکِ microservices: اگر هر سرویس ۹۹.۹٪ در دسترس باشد (uptime خوبی است)، و یک درخواست از ۵ سرویسِ synchronous پشتِسرِهم عبور کند، در دسترس بودنِ کلِ مسیر میشود ۰.۹۹۹ به توانِ ۵ ≈ ۹۹.۵٪ — یعنی خطا پنج برابر شد. زنجیرهٔ طولانیِ تماسهایِ synchronous شکنندگی را ضرب میکند. این تنها یک استدلالِ نظری نیست؛ دلیلِ اصلیِ این است که seniorها زنجیره را کوتاه نگه میدارند، تماسها را موازی میکنند وقتی مستقلاند، و هرجا شد به async مهاجرت میکنند.
با distributed tracing. در نقطهٔ ورود (gateway) یک شناسهٔ یکتا بهنامِ trace id به درخواست تزریق میشود، و این id در هر hop از طریقِ هدرها propagate میشود؛ هر سرویس علاوه بر آن یک span (یک بازهٔ زمانیِ کارِ خودش) با یک span id میسازد که به trace id گره میخورد. استانداردِ امروزی برایِ این کار W3C Trace Context (هدرِ traceparent) و ابزارِ جمعآوری OpenTelemetry است؛ در Spring Boot اینها با Micrometer Tracing یکپارچهاند و به backendهایی مثلِ Zipkin/Jaeger/Tempo فرستاده میشوند. نتیجه: میتوانی یک درخواستِ واحد را در یک نمودارِ آبشاری ببینی — کدام سرویس چقدر طول کشید، کجا خطا خورد، و کجا کند بود. نکتهٔ senior: trace id را در لاگها هم بگذار (correlation id) تا لاگِ همهٔ سرویسها برایِ یک درخواست به هم بچسبند؛ بدونِ آن، دیباگِ یک حادثهٔ توزیعشده کابوس است.
۵) هماهنگی: choreography در برابر orchestration
فرض کن ثبتِ سفارش چند مرحله دارد: کسرِ پول، رزروِ موجودی، ساختِ حملِ بار. این مراحل روی چند سرویسِ مختلف پخشاند. چطور هماهنگشان کنیم؟ دو سبک وجود دارد.
Choreography (رقصِ هماهنگ بدونِ رهبر): هیچ مغزِ مرکزیای نیست. هر سرویس به eventها گوش میدهد و وقتی رویدادِ مربوطه را دید، کارِ خودش را میکند و یک event جدید منتشر میکند. مثلِ یک گروهِ رقص که هرکس با دیدنِ حرکتِ بغلیاش حرکتِ بعدی را میکند.
Orchestration (ارکستر با رهبر): یک سرویسِ مرکزی (orchestrator) مثلِ رهبرِ ارکستر ایستاده و بهترتیب به هر سرویس دستور میدهد: «تو پول را کسر کن»، «حالا تو موجودی را رزرو کن». او جریان را میداند و مدیریت میکند.
دو سبکِ هماهنگی — چپ بدونِ رهبر، راست با رهبرِ مرکزی | Two coordination styles: leaderless (left) vs central conductor (right).
flowchart LR
subgraph Choreography
O1[Order] -->|OrderPlaced| Pay1[Payment]
Pay1 -->|Paid| Inv1[Inventory]
Inv1 -->|Reserved| Ship1[Shipping]
end
subgraph Orchestration
Orc[Order Orchestrator] --> Pay2[Payment]
Orc --> Inv2[Inventory]
Orc --> Ship2[Shipping]
end
| بُعد | Choreography | Orchestration |
|---|---|---|
| کنترل | توزیعشده (event-driven) | متمرکز (orchestrator) |
| coupling | شل بینِ سرویسها | سرویسها به orchestrator وصلاند |
| دیدِ کلیِ جریان | سخت؛ منطق پخش است | آسان؛ یکجا میبینی |
| افزودنِ مرحله | یک consumer جدید | تغییرِ orchestrator |
| دیباگ | سختتر (مسیرِ ضمنی) | سادهتر (مسیرِ صریح) |
| مناسبِ | جریانهایِ ساده و مستقل | جریانهایِ پیچیده و شرطی |
تازهکارها بینِ این دو جنگِ مذهبی راه میاندازند. senior میداند که هردو ابزارند. choreography برایِ جریانهایِ سادهٔ ۲-۳ مرحلهای که سرویسها واقعاً مستقلاند عالی است و coupling را کم نگه میدارد. اما وقتی جریان بزرگ میشود (۷-۸ مرحله با منطقِ شرطی و جبران)، choreography به یک «قتلِ مرموز» تبدیل میشود: هیچکس نمیداند کلِ فرآیند کجاست، چون منطق در ده جایِ مختلف پخش شده. آنجا orchestration برنده است چون جریان را در یک جا صریح و قابلِ ردیابی میکند. قاعدهٔ عملی: جریانِ ساده → choreography؛ جریانِ پیچیده و تراکنشمانند → orchestration.
در choreography کنترل توزیعشده است: هر سرویس به eventها گوش میدهد و مستقل واکنش نشان میدهد، هیچ هماهنگکنندهٔ مرکزیای نیست. مزیت: coupling کم، افزودنِ یک مصرفکنندهٔ جدید بدونِ لمسِ بقیه، مقیاسپذیریِ طبیعی. عیب: منطقِ کلِ جریان در هیچجا صریح نیست — پخش شده در چند سرویس — پس فهمیدنِ «الان فرآیند کجاست» و دیباگ سخت میشود، و ریسکِ حلقههایِ event پنهان وجود دارد. در orchestration یک سرویسِ مرکزی (orchestrator) جریان را میداند و به هر سرویس دستور میدهد. مزیت: جریان صریح، قابلِردیابی و آسان برایِ منطقِ شرطی و جبران (مناسبِ saga)؛ عیب: orchestrator یک نقطهٔ coupling و بالقوه bottleneck/single point of failure میشود و باید خودش مقاوم و بدونِ state سنگین طراحی شود. قاعده: جریانِ ساده و مستقل → choreography؛ فرآیندِ کسبوکاریِ پیچیده و چندمرحلهای با جبران → orchestration.
۶) ایدهٔ Saga: تراکنش وقتی که ACID نداری
در monolith یک @Transactional میگذاشتی و اگر هر مرحله خطا میداد، دیتابیس همهچیز را rollback میکرد — atomicity رایگان بود. اما وقتی «کسرِ پول» در دیتابیسِ Payment است و «رزروِ موجودی» در دیتابیسِ Inventory، یک تراکنشِ واحد روی هردو غیرممکن است. اینجا Saga وارد میشود.
یک سفر رزرو میکنی: بلیطِ هواپیما، هتل، ماشین. اگر بعد از رزروِ هواپیما و هتل، ماشین گیر نیامد، نمیتوانی همهچیز را با یک دکمهٔ «undo» جادویی پس بگیری. مجبوری هرکدام را جداگانه کنسل کنی: هتل را کنسل، هواپیما را کنسل. Saga دقیقاً همین است: بهجایِ یک rollbackِ اتمی، یک دنباله از تراکنشهایِ محلی داری، و برایِ هر مرحله یک تراکنشِ جبرانی (compensating transaction) تعریف میکنی که اگر مرحلهٔ بعدی شکست خورد، کارِ این مرحله را «خنثی» کند.
Saga: PlaceOrder
T1: charge payment → C1: refund payment
T2: reserve inventory → C2: release inventory
T3: create shipment → C3: cancel shipment
اگر T3 شکست خورد → C2 و سپس C1 را اجرا کن (به ترتیبِ معکوس)
ماشینِ حالتِ یک saga — پیشروی و جبران در صورتِ شکست | Saga state machine: forward progress and compensation on failure.
stateDiagram-v2
[*] --> PaymentCharged: T1 charge
PaymentCharged --> InventoryReserved: T2 reserve
InventoryReserved --> ShipmentCreated: T3 ship
ShipmentCreated --> Completed: success
InventoryReserved --> CompensatingT1: T3 failed
CompensatingT1 --> Failed: refund done
PaymentCharged --> Failed: T2 failed -> refund
Completed --> [*]
Failed --> [*]
یک اشتباهِ ذهنیِ خطرناک: فکر کنی compensating transaction همان rollback است. نیست. rollback یعنی «انگار هیچ اتفاقی نیفتاد». جبران یعنی «یک عملِ جدید که اثرِ عملِ قبلی را خنثی میکند» — و رد پایش میماند. مثلاً «واریزِ برگشتی» یک ردیفِ جدید در حساب است، نه پاکشدنِ ردیفِ قبلی. بدتر: بعضی کارها جبرانپذیر نیستند — ایمیلی که فرستادی برنمیگردد، پیامکی که رفت رفته. senior این کارهایِ برگشتناپذیر را تا آخرین مرحلهٔ ممکن در saga عقب میاندازد، تا احتمالِ اینکه لازم شود جبرانشان کنی کمینه شود. saga موضوعِ یک فصلِ کامل است؛ اینجا فقط ایده را کاشتیم.
چون هر سرویس دیتابیسِ خودش را دارد (الگویِ database-per-service) و یک تراکنشِ ACID فقط داخلِ مرزِ یک دیتابیس معنا دارد. راهِ حلِ کلاسیکِ «two-phase commit» (2PC/XA) روی چند دیتابیس از نظرِ تئوری ممکن است اما در عمل کنار گذاشته میشود: قفلهایِ طولانی، نقطهٔ شکستِ واحد در coordinator، و مقیاسناپذیری. بهجایش الگویِ Saga را میپذیریم: دنبالهای از تراکنشهایِ محلی که هرکدام داخلِ سرویسِ خودش اتمی است، و برایِ خطا از compensating transaction استفاده میکنیم. قیمتی که میپردازیم: سیستم دیگر strongly consistent نیست بلکه eventually consistent است، و باید حالتهایِ میانی (مثلِ «پرداخت شد ولی هنوز ارسال نشده») را در طراحی بپذیریم.
۷) Idempotency: سنگِ بنایِ بقا
گفتیم شبکه غیرقابلاعتماد است و پیامها تکرار میشوند. حالا سؤالِ حیاتی: اگر همان درخواستِ «کسرِ ۱۰۰ دلار» دوبار برسد چه میشود؟ اگر ساده نوشته باشی، دوبار کسر میشود و مشتری عصبانی. راهِحل: idempotency.
یک عملیات idempotent است اگر اجرایش یکبار یا صد بار، همان نتیجهٔ نهایی را بدهد. GET طبیعتاً idempotent است (خواندن چیزی را عوض نمیکند). DELETE هم معمولاً idempotent است. اما POSTی که پول کسر میکند ذاتاً idempotent نیست — پس باید دستی idempotentاش کنیم. ابزار: Idempotency-Key.
طرزِ کار: client (یا سرویسِ caller) برایِ هر عملیاتِ منطقی یک کلیدِ یکتا میسازد (مثلاً یک UUID) و آن را در هدرِ Idempotency-Key میفرستد. سرورِ گیرنده قبل از انجامِ کار، این کلید را در یک جدول/کش چک میکند: اگر قبلاً دیده، همان پاسخِ قبلی را برمیگرداند بدونِ اجرایِ دوباره؛ اگر جدید است، کار را انجام میدهد و نتیجه را با آن کلید ذخیره میکند.
@PostMapping("/payments")
public ResponseEntity<PaymentResult> charge(
@RequestHeader("Idempotency-Key") String key,
@RequestBody ChargeRequest req) {
// اگر این کلید قبلاً پردازش شده، همان پاسخِ ذخیرهشده را بده
var existing = idempotencyStore.find(key);
if (existing != null) {
return ResponseEntity.ok(existing.result());
}
var result = paymentEngine.charge(req); // کارِ واقعی
idempotencyStore.save(key, result); // ذخیره برایِ درخواستهایِ تکراری
return ResponseEntity.ok(result);
}
برایِ اینکه ذخیرهٔ کلید در برابرِ رقابتِ همزمان (race) امن باشد، از قیدِ یکتاییِ دیتابیس استفاده کن — و اینجا فرقِ دیالکتها را ببین.
-- PostgreSQL: درج با idempotency؛ اگر کلید بود، هیچکاری نکن
INSERT INTO idempotency_keys (key, response, created_at)
VALUES (:key, :response, now())
ON CONFLICT (key) DO NOTHING;
-- Oracle: MERGE برایِ همان رفتار (Oracle تا 23ai بهطورِ سنتی ON CONFLICT ندارد)
MERGE INTO idempotency_keys t
USING (SELECT :key AS key FROM dual) s
ON (t.key = s.key)
WHEN NOT MATCHED THEN
INSERT (key, response, created_at) VALUES (:key, :response, SYSTIMESTAMP);
PostgreSQL دستورِ تمیزِ INSERT ... ON CONFLICT (...) DO NOTHING | DO UPDATE را دارد. Oracle بهطورِ سنتی از MERGE استفاده میکند (اگرچه Oracle 23ai هم ON CONFLICT را اضافه کرده، اما در بیشترِ محیطهایِ تولید هنوز روی 19c هستی، پس MERGE امنترین انتخابِ قابلِحمل است). نکتهٔ portability: کدِ upsert را در یک لایهٔ repository جدا کن تا تعویضِ دیالکت فقط یک فایل را لمس کند.
یادت باشد گفتیم Kafka/RabbitMQ حالتِ at-least-once دارند. یعنی همان listener ممکن است یک event را دوبار بگیرد. اگر listenerِ تو «موجودی را کم کن» را دوبار اجرا کند، موجودی اشتباه میشود. پس idempotency فقط یک ماجرایِ HTTP نیست؛ هر consumerِ event باید idempotent باشد. الگویِ رایج: یک event_id یکتا در هر پیام بگذار و در consumer یک جدولِ processed_events نگه دار؛ اگر id را قبلاً دیدی، پیام را نادیده بگیر (skip). این یک خطِ دفاعیِ حیاتی است که خیلی از تیمها فراموشش میکنند تا اولین حادثهٔ «دیتایِ دوبل».
مهم است چون شبکه ذاتاً retry دارد و بدونِ idempotency، هر retry میتواند اثرِ جانبیِ تکراری بسازد (دوبار کسرِ پول، دوبار ثبتِ سفارش). راه: از client یک Idempotency-Key یکتا برایِ هر عملیاتِ منطقی بگیر؛ سمتِ سرور قبل از اجرا چک کن که این کلید قبلاً پردازش شده یا نه؛ اگر بله، پاسخِ ذخیرهشده را برگردان بدونِ اجرایِ دوباره؛ اگر نه، اجرا کن و نتیجه را با کلید ذخیره کن. برایِ امنیت در برابرِ راهٔ همزمان، از قیدِ یکتاییِ دیتابیس (ON CONFLICT/MERGE) بهجایِ چکِ ساده در حافظه استفاده کن. همین الگو را در consumerهایِ Kafka/RabbitMQ هم با یک event_id و جدولِ processed_events پیاده کن، چون تحویلِ at-least-once یعنی تکرار قطعی است.
۸) Timeout، retry با backoff و jitter
سه ابزارِ کوچک که بینِ یک سیستمِ مقاوم و یک فاجعهٔ زنجیرهای فرق میگذارند.
۸-۱) Timeout: هرگز تا ابد صبر نکن
هر تماسِ synchronous باید یک سقفِ زمانی داشته باشد. بدونِ آن، یک سرویسِ کندِ downstream میتواند threadهایِ تو را یکییکی قفل کند تا کلِ سرویس بخوابد. دو timeout مهماند: connect-timeout (چقدر منتظرِ برقراریِ اتصال بمانی) و read-timeout (چقدر منتظرِ پاسخ بمانی).
۸-۲) Retry: تلاشِ دوباره، اما با احتیاط
خیلی از خطاها گذرا (transient) اند: یک قطعیِ لحظهای شبکه، یک 503ِ موقت. برایِ اینها retry منطقی است. اما retryِ خام خطرناک است.
تصور کن سرویسِ downstream بهخاطرِ فشار کند شده. حالا هزار caller همزمان خطا میگیرند و بلافاصله retry میکنند. این یعنی ناگهان دوبرابر بار روی سرویسی که همین الان هم زیرِ فشار است — که کاملاً میخواباندش. به این «retry storm» یا طوفانِ تلاشِ دوباره میگویند. retry بدونِ backoff مثلِ این است که وقتی در گیر کرد، محکمتر و سریعتر بکوبیاش. سه قانون: (۱) فقط خطاهایِ گذرا را retry کن، نه 400ها را (منطقی که خراب باشد با تکرار درست نمیشود)، (۲) بینِ تلاشها صبر کن (backoff)، (۳) این صبر را تصادفی کن (jitter).
Exponential backoff: بینِ هر تلاش صبر کن، و صبر را نمایی زیاد کن: ۲۰۰ms، ۴۰۰ms، ۸۰۰ms... این به سرویسِ downstream فرصتِ نفسکشیدن میدهد.
Jitter (لرزش): اگر همهٔ callerها دقیقاً بعد از ۴۰۰ms با هم retry کنند، باز یک موجِ همزمان میسازند. با اضافهکردنِ یک تصادفِ کوچک به هر صبر، این موج را پخش میکنی. بدونِ jitter، همهٔ instanceها با هم میافتند و با هم دوباره تلاش میکنند؛ با jitter، تلاشها روی یک بازهٔ زمانیِ بازتر پخش میشوند.
با Resilience4j (کتابخانهٔ استانداردِ resilience در اکوسیستمِ Spring) اینها فقط چند خطِ config اند:
resilience4j:
retry:
instances:
paymentApi:
maxAttempts: 4 # جمعاً ۴ تلاش (۱ اصلی + ۳ retry)
waitDuration: 200ms # صبرِ پایه
enableExponentialBackoff: true # ۲۰۰ → ۴۰۰ → ۸۰۰ ...
exponentialBackoffMultiplier: 2
enableRandomizedWait: true # jitter را روشن کن
randomizedWaitFactor: 0.5 # ±۵۰٪ لرزشِ تصادفی
retryExceptions:
- java.io.IOException
- java.util.concurrent.TimeoutException
ignoreExceptions:
- com.acme.PaymentRejectedException # خطایِ منطقی: retry نکن
@Retry(name = "paymentApi")
public PaymentResult charge(ChargeRequest req) {
return paymentClient.charge(req.idempotencyKey(), req);
}
اینجا دو مبحثِ قبلی به هم گره میخورند: retry بدونِ idempotency یعنی کسرِ چندبارهٔ پول. اگر تلاشِ اول واقعاً به سرور رسید و اجرا شد، ولی پاسخش در راهِ برگشت گم شد، caller فکر میکند شکست خورده و retry میکند — و حالا دوبار کسر شده. به همین دلیل همان Idempotency-Key که در بخشِ قبل ساختیم اینجا حیاتی میشود: تلاشِ دومِ retry همان کلید را میفرستد، سرور تشخیص میدهد که تکراری است و دوباره کسر نمیکند. قاعدهٔ آهنین: retry را فقط روی عملیاتِ idempotent روشن کن.
۸-۳) Deadline و deadline propagation
timeout معمولاً «محلی» است: هر hop timeoutِ خودش را دارد. اما در یک زنجیرهٔ A → B → C مشکلی پیش میآید. فرض کن client یک timeoutِ ۳ ثانیهای دارد. A به B زنگ میزند با timeoutِ ۳ ثانیه، B به C با timeoutِ ۳ ثانیه. حالا اگر B خودش ۲.۹ ثانیه طول کشیده و بعد به C زنگ بزند، C ممکن است ۳ ثانیهٔ دیگر کار کند — یعنی جمعاً ۵.۹ ثانیه — درحالیکه client خیلی وقت است قطع کرده. C دارد کارِ بیهوده انجام میدهد و منابع میسوزاند برایِ پاسخی که هیچکس دیگر منتظرش نیست.
راهِحل، تفکر با deadline بهجایِ timeout است. بهجایِ «۳ ثانیه صبر کن»، بگو «تا ساعتِ ۱۰:۰۰:۰۳ فرصت داری». این deadline (یک لحظهٔ مطلق در زمان) را در هر hop همراهِ درخواست حمل کن (مثلاً در یک هدر). هر سرویس قبل از شروعِ کار چک میکند: «آیا هنوز تا deadline وقت مانده؟ اگر نه، اصلاً شروع نکن و فوراً خطا بده.» و وقتی به سرویسِ بعدی زنگ میزند، timeoutِ آن تماس را برابرِ زمانِ باقیمانده میگذارد، نه یک عددِ ثابت. gRPC این را بهشکلِ توکار با مفهومِ deadline پشتیبانی میکند؛ در REST باید دستی با یک هدرِ deadline پیادهاش کنی.
// یک الگویِ ساده برایِ deadline propagation در REST
long remainingMs = deadline.toEpochMilli() - System.currentTimeMillis();
if (remainingMs <= 0) {
throw new DeadlineExceededException("no time budget left");
}
// timeoutِ تماسِ بعدی = زمانِ باقیمانده (با کمی حاشیه)
var result = downstream.callWithTimeout(request, remainingMs - 50, deadline);
timeout یک بازهٔ نسبی است که در هر hop از نو صفر میشود: «۳ ثانیه از الان». مشکل این است که در یک زنجیرهٔ چندسرویسه، این timeoutها جمع میشوند و میتوانند از بودجهٔ اصلیِ client خیلی فراتر بروند، پس سرویسهایِ تهزنجیره ممکن است روی درخواستی کار کنند که caller اصلی مدتهاست رهایش کرده. deadline یک لحظهٔ مطلق در زمان است («تا ساعتِ فلان») که در طولِ کلِ زنجیره propagate میشود. هر سرویس زمانِ باقیمانده تا deadline را حساب میکند، اگر تمام شده فوراً fail-fast میکند، و timeoutِ تماسهایِ بعدیاش را از همان بودجهٔ باقیمانده برمیدارد. نتیجه: هدررفتِ منابع کمتر و رفتارِ قابلپیشبینیتر. gRPC این را توکار دارد؛ در REST دستی میسازیاش.
الگویِ کاملِ resilience — deadline، timeout، retry، circuit breaker | Full resilience pattern: deadline, timeout, retry, circuit breaker.
flowchart TD
A[Incoming request + deadline] --> B{time budget left?}
B -->|no| X[fail fast 504]
B -->|yes| C[call downstream with remaining budget]
C --> D{success?}
D -->|yes| E[return result]
D -->|transient error| F{retries left AND idempotent?}
F -->|yes| G[wait backoff + jitter] --> B
F -->|no| H[open circuit / fallback]
۹) Contract testing: قرارداد را قبل از شکستن بگیر
بزرگترین ترسِ microservices: تیمِ Payment یک فیلد را در پاسخش عوض میکند (amount را به amountCents تغییر میدهد)، deploy میکند، و ناگهان Order — که آن فیلد را میخواند — در تولید میشکند. هیچ تستی این را نگرفت چون هر سرویس تستهایِ خودش را پاس کرده بود. راهِحل: contract testing.
مثلِ قراردادِ کاری بینِ یک تأمینکننده (provider) و یک مشتری (consumer) است. مشتری میگوید «من انتظار دارم وقتی این درخواست را بفرستم، پاسخی با این شکل بگیرم.» این انتظار بهشکلِ یک قرارداد نوشته میشود. حالا هردو طرف میتوانند مستقل تست شوند: consumer با یک provider جعلی که طبقِ قرارداد جواب میدهد، و provider با تستی که ثابت میکند واقعاً قرارداد را برآورده میکند. اگر provider قرارداد را بشکند، تستِ خودش قرمز میشود — قبل از رسیدن به تولید.
دو ابزارِ اصلی: Spring Cloud Contract و Pact.
- Spring Cloud Contract: بومیِ JVM. قرارداد را با یک DSL (Groovy/YAML) مینویسی. هم producer-driven و هم consumer-driven را پشتیبانی میکند. از قرارداد، تستهایِ provider و stubهایِ آماده برایِ consumer تولید میکند. بهترین انتخاب وقتی همهٔ طرفها Spring/JVM اند و broker جدا نمیخواهی.
- Pact: consumer-driven و چندزبانه (polyglot). قرارداد از تستهایِ consumer تولید میشود و به فرمتِ JSON است، پس زبانشمول است — عالی وقتی consumerها ناهمگوناند (مثلاً فرانتاندِ React + بکاندِ Java + سرویسِ داده به Python). معمولاً با یک Pact Broker مرکزی کار میکند.
// Spring Cloud Contract — یک قرارداد به DSLِ Groovy (سمتِ provider)
Contract.make {
request {
method 'POST'
url '/payments'
headers { contentType('application/json') }
body([ orderId: 'o-1', amountCents: 5000 ])
}
response {
status 200
headers { contentType('application/json') }
body([ paymentId: anyUuid(), approved: true ])
}
}
| بُعد | Spring Cloud Contract | Pact |
|---|---|---|
| فلسفه | producer- و consumer-driven | فقط consumer-driven |
| زبان | JVM محور | polyglot (JSON) |
| نوشتنِ قرارداد | Groovy/YAML DSL | از تستِ consumer تولید میشود |
| broker | نمیخواهد (اختیاری) | Pact Broker (رایج) |
| بهترین کاربرد | خانوادهٔ خالصِ Spring | تیمهایِ چندزبانه |
دامِ ذهنی: فکر کنی contract test همهچیز را پوشش میدهد. نه. contract test فقط شکلِ تعامل (schema، فیلدها، status code) را تأیید میکند، نه منطقِ عمیقِ کسبوکار را. هنوز به تستِ واحد برایِ منطق و به چند تستِ end-to-end برایِ مسیرهایِ حیاتی نیاز داری. ارزشِ واقعیِ contract testing این است که سریع است (نیازی به بالا آوردنِ کلِ سیستم نیست) و در CI جلویِ breaking changeها را میگیرد — یعنی همان لایهای که integration testهایِ کندِ end-to-end همیشه در آن ضعیفاند.
مشکلِ «تغییرِ خاموشِ قرارداد بینِ سرویسها». unit test هر سرویس را در انزوا درست میسنجد، ولی نمیفهمد که provider یک فیلد را عوض کرده. integration testِ end-to-end این را میگیرد ولی کند، شکننده و پرهزینه است و باید کلِ سیستم را بالا بیاوری. contract testing حدِ وسط است: یک قرارداد بینِ consumer و provider میبندی و هر طرف را مستقل میسنجی که به آن قرارداد وفادار است — سریع و در CI. اگر provider قرارداد را بشکند، buildِ provider قرمز میشود قبل از deploy. دو ابزارِ رایج Spring Cloud Contract (بومیِ JVM، producer/consumer-driven) و Pact (consumer-driven، polyglot، با broker) اند.
۱۰) نسخهبندیِ API: تغییر بده بدونِ اینکه بشکنی
سرویسها مستقل deploy میشوند، پس نمیتوانی همهٔ consumerها را مجبور کنی همزمان با تو آپدیت شوند. باید بتوانی APIِات را جلو ببری بدونِ اینکه کلاینتهایِ قدیمی بشکنند. اینجا versioning وارد میشود.
سه سبکِ رایج:
- URI versioning:
/api/v1/ordersدر برابر/api/v2/orders. صریح، ساده، قابلِکش و دیدنی در لاگها — پرکاربردترین. - Header versioning: یک هدرِ سفارشی مثلِ
X-API-Version: 2یا از طریقِAccept: application/vnd.acme.v2+json(که به «content negotiation» معروف است). URL تمیز میماند ولی کمتر دیدنی است. - Query param:
/orders?version=2. ساده ولی کثیفکنندهٔ کش و کمتر توصیهشده.
// URI versioning در Spring MVC
@RestController
@RequestMapping("/api/v2/orders")
public class OrderV2Controller {
@GetMapping("/{id}")
public OrderV2Dto get(@PathVariable String id) { ... }
}
تفاوتِ کلیدی که خیلیها اشتباه میکنند: افزودن یک فیلدِ اختیاریِ جدید به پاسخ یک تغییرِ backward-compatible است و نیاز به نسخهٔ جدید ندارد (کلاینتِ قدیمی فیلدِ نو را نمیبیند و مشکلی نیست) — بهشرطی که کلاینتهایت با فیلدهایِ ناشناخته مدارا کنند. اما حذفِ یک فیلد، تغییرِ نام، تغییرِ نوع (string به number)، یا سختگیرترکردنِ validation همه breaking اند و نسخهٔ جدید میخواهند. یک اشتباهِ رایجِ تولید: تیم فیلدی را «فقط تغییرِ نام» میدهد و فکر میکند بیضرر است — و یک کلاینتِ قدیمی که هنوز نامِ قبلی را میخواند خاموش میشکند. قبل از هر تغییر بپرس: «آیا این additive است؟»
seniorها بهندرت بهسختی به v2 میپرند. الگویِ محبوب expand and contract (یا parallel change) است: اول فیلد/رفتارِ جدید را کنارِ قدیمی اضافه کن (expand)، همه را مهاجرت بده، بعد قدیمی را حذف کن (contract). و وقتی مجبوری یک نسخه را بازنشسته کنی، ناگهانی خاموشش نکن: یک هدرِ Deprecation و Sunset (با تاریخ) بفرست تا کلاینتها هشدار بگیرند، مدتی هردو نسخه را زنده نگه دار، متریکِ استفاده از نسخهٔ قدیمی را ببین، و وقتی به صفر رسید خاموش کن. «نسخهبندی» فقط شمارهٔ نسخه نیست؛ یک فرآیندِ مهاجرتِ مهربان است.
اول تفکیک کن بینِ تغییرِ backward-compatible (افزودنِ فیلدِ اختیاری، افزودنِ endpoint) که نسخهٔ جدید نمیخواهد، و تغییرِ breaking (حذف/تغییرِ نام/تغییرِ نوعِ فیلد، سختکردنِ validation) که میخواهد. برایِ تغییراتِ breaking یک نسخهٔ جدید معرفی کن — رایجترین سبک URI versioning (/v2) است چون صریح و دیدنی است، اگرچه header/content-negotiation هم مرسوم است. کلید این است که هردو نسخه را موازی زنده نگه داری تا consumerها فرصتِ مهاجرت داشته باشند (الگویِ expand/contract). موقعِ بازنشستگی، از هدرهایِ Deprecation و Sunset و مانیتورینگِ استفاده کمک بگیر تا خاموشکردن بدونِ حادثه باشد. هدفِ نهایی: هیچ consumer ای هرگز با یک deployِ تو غافلگیر نشود.
۱۱) تلهٔ N+1 و «سرویسِ پرحرف» (chatty service)
آخرین و شاید موذیترین تله. در دنیایِ ORM، مشکلِ N+1 query را میشناسی: یک کوئری لیستِ ۱۰۰ سفارش را میگیرد، بعد در یک حلقه برایِ هر سفارش یک کوئریِ جدا برایِ گرفتنِ مشتری میزند — جمعاً ۱۰۱ کوئری بهجایِ ۲. در microservices همین بلا با شدتِ صد برابر رخ میدهد، چون هر «کوئری» حالا یک تماسِ شبکه است، نه یک تماسِ حافظه.
تصور کن آشپزی میخواهد املت برایِ ۵۰ نفر بپزد. راهِ درست: یکبار به مغازه برود و ۵۰ تخممرغ بخرد. راهِ N+1: برایِ هر پُرس یکبار تا مغازه برود و برگردد. مغازه (سرویسِ downstream) نزدیک باشد یا دور، این رفتوبرگشتها زمان را میبلعند. یک تماسِ شبکه شاید فقط ۵ms طول بکشد، ولی ۱۰۰ تای پشتِسرِهم میشود نیم ثانیه — و کاربر منتظر است.
مثالِ کلاسیک: صفحهٔ «سفارشهایِ من» باید ۲۰ سفارش را با نامِ محصولِ هرکدام نشان دهد. کدِ سادهلوح:
// ضدالگو: N+1 روی شبکه — یک تماس برایِ هر سفارش
List<Order> orders = orderClient.findByUser(userId); // ۱ تماس
for (Order o : orders) {
Product p = productClient.findById(o.productId()); // N تماسِ جدا! (۲۰ بار)
o.setProductName(p.name());
}
// جمعاً ۲۱ تماسِ شبکه برایِ یک صفحه
راهحلها:
- Batch/bulk endpoint: بهجایِ N تماسِ تکی، یک تماس با لیستِ id ها:
productClient.findByIds(ids). یک رفتوبرگشت بهجایِ N تا. - Data aggregation / BFF: یک لایهٔ Backend-For-Frontend یا aggregator که چند تماسِ downstream را (ترجیحاً موازی) جمع میکند و یک پاسخِ آمادهٔ صفحه میدهد.
- denormalization / رویداد: سرویسِ سفارش نامِ محصول را در لحظهٔ ثبت (از طریقِ event) کش کند تا موقعِ خواندن اصلاً نیاز به تماس نباشد.
// درست: یک تماسِ bulk بهجایِ N تماس
List<Order> orders = orderClient.findByUser(userId); // ۱ تماس
Set<String> ids = orders.stream().map(Order::productId).collect(toSet());
Map<String, Product> products = productClient.findByIds(ids) // ۱ تماس
.stream().collect(toMap(Product::id, identity()));
orders.forEach(o -> o.setProductName(products.get(o.productId()).name()));
// جمعاً ۲ تماس — نه ۲۱
ریشهٔ عمیقِ سرویسِ پرحرف اغلب یک مرزبندیِ اشتباه است. اگر دو دادهٔ همیشه-با-هم-خواندهشونده را به دو سرویسِ جدا تقسیم کنی، محکومی به تماسهایِ chatty بیپایان بینشان. senior موقعِ طراحیِ مرزها فقط به «دامنهٔ منطقی» فکر نمیکند، بلکه به الگویِ دسترسیِ داده هم نگاه میکند: چه دادههایی همیشه با هم درخواست میشوند؟ آنها را در یک مرز نگه دار. اگر خودت را در حالِ ساختنِ دهها bulk endpoint و aggregator دیدی تا chattiness را جبران کنی، این یک بویِ بد (smell) است که مرزهایت اشتباهاند — و درمانِ واقعی شاید ادغامِ دو سرویس باشد، نه بهینهسازیِ تماسها.
همان N+1 آشنایِ ORM است ولی روی شبکه، پس صدبرابر گرانتر: یک تماس لیستی از N آیتم میآورد، بعد در حلقه برایِ هر آیتم یک تماسِ جدا به سرویسِ دیگر میزنی — یعنی N+1 تماسِ شبکه، هرکدام با latency و احتمالِ خطایِ خودش. راهِ رفع: (۱) یک bulk/batch endpoint بساز که همهٔ id ها را یکجا بگیرد و یک تماس بزن؛ (۲) از یک aggregator/BFF استفاده کن که تماسهایِ downstream را (بهشکلِ موازی) جمع میکند؛ (۳) با denormalization دادهٔ پرتکرار را از طریقِ event در سرویسِ خودت کش کن تا اصلاً تماس لازم نشود. و مهمترین لایهٔ عمیق: اگر مدام مجبوری chattiness را با bulk endpoint جبران کنی، احتمالاً مرزِ سرویسها اشتباه است و دو سرویس باید یکی شوند.
- ارتباط دو خانواده دارد: synchronous (تلفن: REST/RestClient/OpenFeign/gRPC) برایِ پاسخِ فوری، و asynchronous (نامه: Kafka/RabbitMQ) برایِ کارِ پسزمینه و کاهشِ coupling. RabbitMQ صفِ کار است؛ Kafka لاگِ رویدادِ ماندگار.
- مسیرِ کاملِ درخواست: client → gateway (auth، trace-id، routing) → serviceA → serviceB → DB → پاسخ. hot path را کوتاه نگه دار و بقیه را async کن؛ هر hopِ synchronous شکنندگی را ضرب میکند.
- هماهنگی: choreography (بدونِ رهبر، event-driven) برایِ جریانهایِ ساده؛ orchestration (رهبرِ مرکزی) برایِ جریانهایِ پیچیده. تراکنشِ توزیعشده را با Saga و compensating transaction میسازی، نه ACID.
- جعبهابزارِ بقا: idempotency key (چون تحویل at-least-once است)، timeout صریح، retry فقط با backoff + jitter + روی عملیاتِ idempotent، و deadline propagation برایِ نسوزاندنِ منابعِ تهزنجیره.
- قراردادها را با contract testing (Spring Cloud Contract یا Pact) قفل کن، و API را با آگاهی از backward-compatibility نسخهبندی کن (expand/contract، هدرِ Sunset).
- مراقبِ تلهٔ N+1 / chatty service باش: bulk endpoint، aggregator/BFF، denormalization — و اگر chattiness علاجناپذیر بود، مرزِ سرویسهایت را بازبینی کن.
When you split an application from a monolith into microservices, something strange happens: code that yesterday was a plain method call — orderService.charge(user) — becomes a full journey across the network, with latency, errors, timeouts, dropped packets, and a service on the far end that might not answer at all. This chapter is precisely about that "gap between services": how two services talk, what path a single request travels from the moment a user taps a button until the response comes back, and how to make that path survive the real world — where the network is fundamentally unreliable.
In a monolith, calling a method is like sitting in a room and asking the colleague next to you "what's the price of this item?" — the answer is instant, always arrives, and if they have a stroke so do you (same process). In microservices that same question becomes either a phone call (synchronous; you wait until they answer) or a letter in the mailbox (asynchronous; you drop it and walk away, the reply arrives later). The phone is fast, but if nobody picks up you're stuck on the line; the letter is slow, but if the recipient is traveling it waits patiently in the mailbox. This entire chapter is about choosing correctly between "phone" and "letter" and managing the consequences of each.
In this chapter we build these from zero to senior:
- The two families of communication: synchronous (REST, OpenFeign, RestClient/WebClient, gRPC) versus asynchronous (Kafka, RabbitMQ), and when each is right.
- The full path of a request: client → gateway → serviceA → serviceB → DB → response, with a sequence diagram.
- Two coordination styles: choreography versus orchestration, and an intro to the saga idea for distributed transactions.
- The production survival kit: idempotency keys, timeouts, retries with backoff and jitter, deadlines and deadline propagation.
- Contracts: contract testing with Spring Cloud Contract and Pact, plus API versioning.
- The classic trap: the N+1 problem and the "chatty service".
The versions we anchor to are real and current: Spring Boot 3.5 with Spring Cloud 2025.0 (codename Northfields), released in 2025, and the official Spring gRPC 1.0 GA, which graduated from experimental to a full member of the Spring portfolio.
1) The two big families: synchronous and asynchronous
First, let's split the world in half, because 90% of architectural decisions start from this one choice.
Synchronous (request-response): service A sends a request and blocks until B's answer arrives. Like a phone call. The timing relationship is tight: both must be alive and ready at the same instant. Protocols: HTTP/REST, gRPC.
Asynchronous (message-driven): service A drops a message into an intermediary (a broker like Kafka or RabbitMQ) and returns immediately to its own work. Service B picks the message up and processes it whenever it can. Like a letter. The timing relationship is loose: sender and receiver need not be alive simultaneously.
When a senior designs, the mental default is: "If an immediate response is needed for the user (reading a profile, validating a payment) → synchronous. If it's work that can happen slightly later and must not slow the user's path (sending email, updating stats, indexing) → asynchronous." The golden rule: reduce coupling. Every synchronous call forges a rigid chain between two services; if B slows down, A slows down, and that slowness climbs like dominoes. Asynchronous breaks that chain but brings the complexity of eventual consistency.
Topology overview: services, gateway and broker | نمای کلیِ توپولوژی — سرویسها، gateway و broker.
flowchart LR
Client([Mobile / Web]) --> GW[API Gateway]
GW --> Order[Order Service]
GW --> User[User Service]
Order -->|sync REST/gRPC| Payment[Payment Service]
Order --> OrderDB[(Order DB)]
User --> UserDB[(User DB)]
Order -->|async event| Broker[(Kafka / RabbitMQ)]
Broker --> Notify[Notification Service]
Broker --> Analytics[Analytics Service]
Notice: we call payment synchronously because the user must know right then whether the transaction succeeded; but we push "send notification" and "analytics" asynchronously because the user need not wait for them. This separation is the heart of good design.
| Property | Synchronous (REST/gRPC) | Asynchronous (Kafka/RabbitMQ) |
|---|---|---|
| Timing model | request-response, blocking | fire-and-forget, non-blocking |
| Temporal coupling | high (both must be up) | low (broker mediates) |
| Consistency | strong / immediate | eventual |
| Error handling | timeout/retry at caller | retry/DLQ at consumer |
| Backpressure | hard; caller drowns | natural; queue buffers |
| Good for | instant reads, payment, auth | events, background work, fan-out |
| Debuggability | easier (one stack trace) | harder (path is spread out) |
The core criterion: "does the user need to see the result right now, and is that result part of their response?" If yes (payment validation, reading data shown on the page, auth) → synchronous, because you need an immediate, definite answer. If no — work that can happen slightly later and must not add to the user's path latency (email, notifications, stats, indexing, fan-out to many consumers) → asynchronous, because it lowers coupling and keeps the user's path fast and resilient. The second decision factor is fault tolerance: a synchronous call means if the downstream falls over, so do you; async with a broker breaks that temporal dependency, at the cost of accepting eventual consistency. A senior often builds a hybrid path: the transactional core synchronous, and everything peripheral async.
2) Synchronous communication in Spring
2-1) REST: from RestTemplate to RestClient
For years, Spring's default tool for calling HTTP was the RestTemplate class. But RestTemplate is in maintenance mode and is not recommended for new code. From Spring Framework 6.1 onward there's a modern, fluent synchronous client called RestClient that has all of RestTemplate's power but with a readable, chained API — and it reuses the same message-converter infrastructure.
// A RestClient with a base URL and a default header
@Configuration
public class HttpClients {
@Bean
RestClient paymentClient(RestClient.Builder builder) {
return builder
.baseUrl("http://payment-service") // resolved via service discovery
.requestInterceptor(new TraceIdPropagator()) // carry trace context forward
.build();
}
}
@Service
public class OrderService {
private final RestClient paymentClient;
public OrderService(RestClient paymentClient) {
this.paymentClient = paymentClient;
}
public PaymentResult charge(ChargeRequest req) {
return paymentClient.post()
.uri("/payments")
.contentType(MediaType.APPLICATION_JSON)
.header("Idempotency-Key", req.idempotencyKey())
.body(req)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (request, response) -> {
throw new PaymentRejectedException(response.getStatusText());
})
.body(PaymentResult.class);
}
}
RestClient (synchronous, blocking) is the right pick for most ordinary MVC services — the code is simple and linear. WebClient is non-blocking and reactive (on Project Reactor) and pays off when your whole stack is reactive (Spring WebFlux) or you must fire hundreds of parallel requests without burning hundreds of threads. A common mistake: pulling in WebClient in a fully blocking app just because it's "more modern," then calling .block() everywhere and getting the worst of both worlds. For MVC, use RestClient.
2-2) OpenFeign: the declarative client
Hand-writing the URL and body for every call is tedious. Spring Cloud OpenFeign lets you declare an HTTP call as an interface and Spring generates the implementation. You just write the contract.
@FeignClient(name = "payment-service", path = "/payments")
public interface PaymentClient {
@PostMapping
PaymentResult charge(@RequestHeader("Idempotency-Key") String key,
@RequestBody ChargeRequest req);
@GetMapping("/{id}")
PaymentResult find(@PathVariable("id") String id);
}
# timeout config — without this, defaults can be dangerous
spring:
cloud:
openfeign:
client:
config:
payment-service:
connect-timeout: 1000 # ms; establishing the connection
read-timeout: 2000 # ms; waiting for the response
default:
logger-level: basic
The most common Feign production incident is this: a team writes a @FeignClient, never sets read-timeout, and one day a downstream service slows down. Now every thread that called that Feign blocks for minutes, the service's thread pool fills up, and a service that had no bug of its own falls over too — this is called a "cascading failure." Always put an explicit connect-timeout and read-timeout on every synchronous client. An infinite timeout means "wait forever," and forever, in production, means disaster.
RestClient is a synchronous, fluent client (Spring 6.1+), the modern replacement for RestTemplate; it's great for simple blocking calls in MVC. WebClient is non-blocking and reactive (WebFlux/Reactor); reach for it when you want thousands of concurrent calls on a few threads or the whole stack is reactive. OpenFeign is a declarative layer on top: instead of imperative code you declare an interface and Spring generates the call; it gives readability and integration with service discovery and load balancing, but adds a Spring Cloud dependency. A senior says: RestClient for a few simple calls, OpenFeign for clean repeated interfaces, WebClient for high concurrency.
2-3) gRPC: when speed and contract matter
REST runs on HTTP/1.1 and JSON: readable, simple, universal — but JSON is bulky and parsing it costs. gRPC is a protocol over HTTP/2 that encodes messages as compact binary with Protocol Buffers (protobuf) and generates both server and client code from a single .proto contract file. Benefits: faster, lighter, strongly typed contract, and native bidirectional streaming.
// payment.proto — the source of truth for the contract between two services
syntax = "proto3";
package payment;
service PaymentService {
rpc Charge (ChargeRequest) returns (ChargeResponse);
}
message ChargeRequest {
string order_id = 1;
int64 amount_cents = 2;
string idempotency_key = 3;
}
message ChargeResponse {
string payment_id = 1;
bool approved = 2;
}
Since May 2025 the official Spring gRPC project reached 1.0 GA and became a full member of the Spring portfolio; there's now an official starter org.springframework.grpc:spring-grpc-spring-boot-starter that covers both server and client with autoconfiguration.
// Server side: implement the service generated from the proto
@Service
public class PaymentGrpcService extends PaymentServiceGrpc.PaymentServiceImplBase {
@Override
public void charge(ChargeRequest req, StreamObserver<ChargeResponse> obs) {
var response = ChargeResponse.newBuilder()
.setPaymentId(UUID.randomUUID().toString())
.setApproved(true)
.build();
obs.onNext(response); // send the response
obs.onCompleted(); // close the stream
}
}
gRPC truly shines in internal service-to-service communication, where performance and a rigid contract matter and you control both ends. For public APIs consumed by browsers and external teams, REST/JSON is still king because it works everywhere, is debuggable in a browser, and has a zero learning curve. Browsers don't support gRPC directly (you need gRPC-Web and a proxy). The rule: REST outward, gRPC inward — especially on hot, high-traffic paths.
| Criterion | REST (HTTP/1.1 + JSON) | gRPC (HTTP/2 + protobuf) |
|---|---|---|
| Format | textual, readable | binary, compact |
| Contract | loose (OpenAPI optional) | rigid (.proto file) |
| Performance | moderate | high (faster, lighter) |
| Streaming | limited (SSE) | native, bidirectional |
| Browser support | full | needs gRPC-Web |
| curl-debuggable | yes | no (special tooling) |
| Best use | public API | internal mesh, low latency |
3) Asynchronous communication: Kafka and RabbitMQ
When we say asynchronous, we mean we put a broker in the middle. But Kafka and RabbitMQ embody two different philosophies, and confusing them is one of the most common design mistakes.
RabbitMQ is like a smart post office: it takes letters, routes them by address (routing key) into different queues, and the moment the mail carrier (consumer) picks up a letter and acknowledges it (ack), the letter is deleted from the queue. Its focus is "handing a piece of work to a worker." Kafka is like a newspaper's event ledger that writes everything in order and retains it (say, 7 days). Each reader (consumer group) keeps a marker (offset) saying "I've read up to here." A message isn't deleted after being read; ten different groups can read the same event independently. Its focus is "the stream of events and replay."
Two broker topologies: queue vs log | توپولوژیِ دو broker — صف در برابر لاگ.
flowchart TB
subgraph RabbitMQ [RabbitMQ: routing then delete]
P1[Producer] --> EX{Exchange}
EX -->|key=email| Q1[Queue email]
EX -->|key=sms| Q2[Queue sms]
Q1 --> C1[Worker A]
Q2 --> C2[Worker B]
end
subgraph Kafka [Kafka: append-only log, retained]
P2[Producer] --> T[(Topic: orders)]
T --> G1[Consumer Group: billing]
T --> G2[Consumer Group: analytics]
end
3-1) Kafka with Spring
// Producer: publish an event
@Service
public class OrderEventPublisher {
private final KafkaTemplate<String, OrderPlaced> kafka;
public OrderEventPublisher(KafkaTemplate<String, OrderPlaced> kafka) {
this.kafka = kafka;
}
public void publish(OrderPlaced event) {
// key = orderId; all events for one order go to one partition => order preserved
kafka.send("orders", event.orderId(), event);
}
}
// Consumer
@Component
public class BillingListener {
@KafkaListener(topics = "orders", groupId = "billing")
public void onOrder(OrderPlaced event) {
// must be idempotent! this message may be delivered more than once
billing.charge(event.orderId(), event.amount());
}
}
By default Kafka and RabbitMQ deliver at-least-once: they guarantee a message arrives at least once, but it may arrive several times (e.g., if a consumer processed it but crashed before committing the offset). So every consumer must be idempotent. Exactly-once is possible in Kafka with transactions, but it's costly and complex and effectively doesn't exist at the boundary with external systems (like calling a payment gateway). The rule: assume at-least-once and build idempotency. We'll complete this later.
3-2) RabbitMQ with Spring
@Component
public class EmailListener {
@RabbitListener(queues = "email.welcome")
public void onSignup(UserSignedUp event) {
emailService.sendWelcome(event.email());
}
}
Both are brokers, but their mental models differ. RabbitMQ is a traditional message broker (queue model): great for distributing work (task queues) among workers, complex routing via exchanges and routing keys, and request/reply. A message is deleted after ack. Kafka is a distributed append-only log: messages are retained, throughput is very high, it supports replay and multiple independent consumer groups on the same data, and it fits event streaming and event sourcing. A senior says: "If you want to hand one job to one worker and be done → RabbitMQ. If you want to publish an event that several different systems consume now and in the future, and maybe replay history → Kafka." Scale matters too: Kafka is built for millions of messages per second.
4) The full path of a request: from client to DB and back
Now let's follow a real request step by step. The user taps "Place Order." What layers does this request traverse?
- The Client (browser/mobile) sends an HTTPS request to a domain.
- The API Gateway (e.g., Spring Cloud Gateway) is the single entry point: it terminates TLS, checks the token with auth, applies rate limiting, generates or injects a trace id, and routes the request to the right service.
- The Order Service receives the request, validates it, and calls the Payment Service synchronously to charge the money.
- The Payment Service works with its own DB and returns the result.
- The Order Service saves the order in its own DB, publishes an event (
OrderPlaced) to Kafka (for async work like email), and returns the response to the gateway and from there to the client.
End-to-end trace of one request, sync path plus one async branch | ردیابیِ end-to-end یک درخواست، مسیرِ sync بههمراهِ یک شاخهٔ async.
sequenceDiagram
autonumber
participant C as Client
participant G as API Gateway
participant O as Order Service
participant P as Payment Service
participant DB as Order DB
participant K as Kafka
C->>G: POST /orders (JWT, Idempotency-Key)
G->>G: authN/authZ, rate limit, inject trace-id
G->>O: POST /orders (+ trace-id, deadline)
O->>P: charge(order) [sync, timeout 2s]
P->>P: check Idempotency-Key
P-->>O: 200 paymentId (approved)
O->>DB: INSERT order (status=PAID)
DB-->>O: ok
O-->>K: publish OrderPlaced (fire-and-forget)
O-->>G: 201 Created {orderId}
G-->>C: 201 Created {orderId}
Note over K: Notification & Analytics<br/>consume asynchronously later
Notice what's on the user's response path and what isn't. Payment is on the path because the user must see its result. But sending the confirmation email, updating the sales dashboard, and refreshing the search index must not be on the path — we hand them off via an event to Kafka and close the user's path immediately. The less work you put on the hot path, the lower the latency and the higher the resilience. This is the simplest and biggest performance win in microservices.
The scary math of microservices: if each service is 99.9% available (a good uptime), and a request passes through 5 synchronous services in a row, the availability of the whole path is 0.999^5 ≈ 99.5% — meaning the error rate went up fivefold. A long chain of synchronous calls multiplies fragility. This isn't just theory; it's the core reason seniors keep chains short, parallelize independent calls, and migrate to async wherever possible.
With distributed tracing. At the entry point (the gateway) a unique trace id is injected into the request, and this id is propagated through every hop via headers; each service also creates a span (a timed interval of its own work) with a span id tied to the trace id. The modern standard for this is W3C Trace Context (the traceparent header) with OpenTelemetry as the collection tooling; in Spring Boot these are integrated via Micrometer Tracing and shipped to backends like Zipkin/Jaeger/Tempo. The result: you can see a single request as a waterfall chart — which service took how long, where it errored, where it was slow. A senior note: also put the trace id in your logs (as a correlation id) so all services' logs for one request stitch together; without it, debugging a distributed incident is a nightmare.
5) Coordination: choreography vs orchestration
Suppose placing an order has several steps: charge payment, reserve inventory, create shipment. These steps are spread across several services. How do we coordinate them? There are two styles.
Choreography (a coordinated dance with no leader): there's no central brain. Each service listens to events, and when it sees the relevant event, does its own work and publishes a new event. Like a dance troupe where each dancer makes the next move by watching their neighbor.
Orchestration (an orchestra with a conductor): a central service (the orchestrator) stands like a conductor and commands each service in turn: "you charge the payment," "now you reserve the inventory." It knows and manages the flow.
Two coordination styles: leaderless (left) vs central conductor (right) | دو سبکِ هماهنگی — چپ بدونِ رهبر، راست با رهبرِ مرکزی.
flowchart LR
subgraph Choreography
O1[Order] -->|OrderPlaced| Pay1[Payment]
Pay1 -->|Paid| Inv1[Inventory]
Inv1 -->|Reserved| Ship1[Shipping]
end
subgraph Orchestration
Orc[Order Orchestrator] --> Pay2[Payment]
Orc --> Inv2[Inventory]
Orc --> Ship2[Shipping]
end
| Dimension | Choreography | Orchestration |
|---|---|---|
| Control | distributed (event-driven) | central (orchestrator) |
| Coupling | loose between services | services depend on orchestrator |
| Flow visibility | hard; logic is spread | easy; you see it in one place |
| Adding a step | a new consumer | change the orchestrator |
| Debugging | harder (implicit path) | easier (explicit path) |
| Good for | simple, independent flows | complex, conditional flows |
Beginners wage holy wars between the two. A senior knows both are tools. Choreography is great for simple 2–3 step flows where services really are independent and it keeps coupling low. But when a flow grows (7–8 steps with conditional logic and compensation), choreography becomes a "murder mystery": nobody knows where the overall process is, because the logic is scattered across ten places. There, orchestration wins because it makes the flow explicit and traceable in one place. Practical rule: simple flow → choreography; complex, transaction-like flow → orchestration.
In choreography control is distributed: each service listens to events and reacts independently, with no central coordinator. Pros: low coupling, adding a new consumer without touching the rest, natural scalability. Cons: the whole flow's logic is explicit nowhere — it's spread across services — so understanding "where is the process now" and debugging get hard, and there's a risk of hidden event loops. In orchestration a central service (the orchestrator) knows the flow and commands each service. Pros: an explicit, traceable flow, easy for conditional logic and compensation (a good fit for sagas); cons: the orchestrator becomes a coupling point and a potential bottleneck / single point of failure, and must itself be designed resilient and stateless-heavy-free. The rule: simple, independent flow → choreography; a complex multi-step business process with compensation → orchestration.
6) The Saga idea: transactions when you don't have ACID
In a monolith you'd slap on a @Transactional and if any step failed the database would roll everything back — atomicity was free. But when "charge payment" lives in the Payment database and "reserve inventory" in the Inventory database, a single transaction across both is impossible. This is where Saga enters.
You book a trip: flight, hotel, car. If, after booking the flight and hotel, no car is available, you can't undo everything with one magic button. You have to cancel each one separately: cancel the hotel, cancel the flight. A Saga is exactly that: instead of one atomic rollback, you have a sequence of local transactions, and for each step you define a compensating transaction that "undoes" it if a later step fails.
Saga: PlaceOrder
T1: charge payment → C1: refund payment
T2: reserve inventory → C2: release inventory
T3: create shipment → C3: cancel shipment
If T3 fails → run C2 then C1 (in reverse order)
Saga state machine: forward progress and compensation on failure | ماشینِ حالتِ saga — پیشروی و جبران در صورتِ شکست.
stateDiagram-v2
[*] --> PaymentCharged: T1 charge
PaymentCharged --> InventoryReserved: T2 reserve
InventoryReserved --> ShipmentCreated: T3 ship
ShipmentCreated --> Completed: success
InventoryReserved --> CompensatingT1: T3 failed
CompensatingT1 --> Failed: refund done
PaymentCharged --> Failed: T2 failed -> refund
Completed --> [*]
Failed --> [*]
A dangerous mental mistake: thinking a compensating transaction is the same as a rollback. It isn't. Rollback means "as if nothing happened." Compensation means "a new action that neutralizes the effect of the previous one" — and it leaves a trace. A "refund," for example, is a new row in the account, not the deletion of the previous row. Worse: some actions can't be compensated — an email you sent doesn't come back, an SMS that went out is gone. A senior defers these irreversible actions to the last possible step of the saga, to minimize the chance you'll ever need to compensate them. Saga deserves a full chapter; here we've only planted the idea.
Because each service has its own database (the database-per-service pattern), and an ACID transaction only means something within one database's boundary. The classic "two-phase commit" (2PC/XA) across multiple databases is theoretically possible but abandoned in practice: long locks, a single point of failure in the coordinator, and poor scalability. Instead we adopt the Saga pattern: a sequence of local transactions, each atomic within its own service, using compensating transactions for failure. The price we pay: the system is no longer strongly consistent but eventually consistent, and we must accept intermediate states (like "paid but not yet shipped") in our design.
7) Idempotency: the cornerstone of survival
We said the network is unreliable and messages get duplicated. Now the vital question: if the same "charge $100" request arrives twice, what happens? If you wrote it naively, it charges twice and the customer is furious. The solution: idempotency.
An operation is idempotent if running it once or a hundred times yields the same final result. GET is naturally idempotent (reading changes nothing). DELETE is usually idempotent too. But a POST that charges money is inherently not idempotent — so we must make it idempotent manually. The tool: an Idempotency-Key.
How it works: the client (or the calling service) generates a unique key for each logical operation (e.g., a UUID) and sends it in an Idempotency-Key header. Before doing the work, the receiving server checks this key in a table/cache: if it has seen it before, it returns the same previous response without re-executing; if it's new, it does the work and stores the result under that key.
@PostMapping("/payments")
public ResponseEntity<PaymentResult> charge(
@RequestHeader("Idempotency-Key") String key,
@RequestBody ChargeRequest req) {
// if this key was already processed, return the stored response
var existing = idempotencyStore.find(key);
if (existing != null) {
return ResponseEntity.ok(existing.result());
}
var result = paymentEngine.charge(req); // the real work
idempotencyStore.save(key, result); // store for duplicate requests
return ResponseEntity.ok(result);
}
To make storing the key safe against concurrent races, use a database unique constraint — and here's where dialects differ.
-- PostgreSQL: insert with idempotency; if the key exists, do nothing
INSERT INTO idempotency_keys (key, response, created_at)
VALUES (:key, :response, now())
ON CONFLICT (key) DO NOTHING;
-- Oracle: MERGE for the same behavior (Oracle traditionally lacked ON CONFLICT before 23ai)
MERGE INTO idempotency_keys t
USING (SELECT :key AS key FROM dual) s
ON (t.key = s.key)
WHEN NOT MATCHED THEN
INSERT (key, response, created_at) VALUES (:key, :response, SYSTIMESTAMP);
PostgreSQL has the clean INSERT ... ON CONFLICT (...) DO NOTHING | DO UPDATE. Oracle traditionally uses MERGE (although Oracle 23ai added ON CONFLICT too, in most production environments you're still on 19c, so MERGE is the safest portable choice). Portability tip: isolate the upsert code in a repository layer so switching dialects touches only one file.
Remember we said Kafka/RabbitMQ are at-least-once. That means the same listener may receive an event twice. If your "decrement inventory" listener runs twice, inventory goes wrong. So idempotency isn't just an HTTP story; every event consumer must be idempotent. Common pattern: put a unique event_id in each message and keep a processed_events table in the consumer; if you've seen the id before, skip the message. This is a critical line of defense many teams forget until their first "double data" incident.
It matters because the network inherently retries, and without idempotency each retry can produce a duplicate side effect (double charge, double order). The approach: get a unique Idempotency-Key from the client for each logical operation; on the server, before executing, check whether this key was already processed; if yes, return the stored response without re-executing; if no, execute and store the result under the key. For safety against concurrent races, use a database unique constraint (ON CONFLICT/MERGE) rather than a naive in-memory check. Implement the same pattern in Kafka/RabbitMQ consumers with an event_id and a processed_events table, because at-least-once delivery means duplicates are guaranteed.
8) Timeouts, retries with backoff and jitter
Three small tools that make the difference between a resilient system and a chain-reaction disaster.
8-1) Timeout: never wait forever
Every synchronous call must have a time ceiling. Without it, one slow downstream service can lock your threads one by one until the whole service goes to sleep. Two timeouts matter: connect-timeout (how long to wait to establish a connection) and read-timeout (how long to wait for a response).
8-2) Retry: try again, but carefully
Many errors are transient: a momentary network blip, a temporary 503. For these, retrying is reasonable. But naive retries are dangerous.
Imagine the downstream service slowed down under load. Now a thousand callers get errors simultaneously and immediately retry. That suddenly doubles the load on a service already under pressure — which finishes it off completely. This is called a "retry storm." Retry without backoff is like slamming a stuck door harder and faster. Three rules: (1) only retry transient errors, not 400s (broken logic won't fix itself with repetition), (2) wait between attempts (backoff), (3) make that wait random (jitter).
Exponential backoff: wait between each attempt, and grow the wait exponentially: 200ms, 400ms, 800ms... This gives the downstream service room to breathe.
Jitter: if all callers retry exactly 400ms later together, they form another synchronized wave. By adding a small randomness to each wait, you spread that wave out. Without jitter, all instances fail together and retry together; with jitter, attempts are spread across a wider time window.
With Resilience4j (the standard resilience library in the Spring ecosystem), these are just a few lines of config:
resilience4j:
retry:
instances:
paymentApi:
maxAttempts: 4 # 4 attempts total (1 original + 3 retries)
waitDuration: 200ms # base wait
enableExponentialBackoff: true # 200 → 400 → 800 ...
exponentialBackoffMultiplier: 2
enableRandomizedWait: true # turn jitter on
randomizedWaitFactor: 0.5 # ±50% random jitter
retryExceptions:
- java.io.IOException
- java.util.concurrent.TimeoutException
ignoreExceptions:
- com.acme.PaymentRejectedException # business error: do not retry
@Retry(name = "paymentApi")
public PaymentResult charge(ChargeRequest req) {
return paymentClient.charge(req.idempotencyKey(), req);
}
Here two earlier topics tie together: retry without idempotency means charging money multiple times. If the first attempt actually reached the server and executed, but its response was lost on the way back, the caller thinks it failed and retries — now it's charged twice. That's why the very Idempotency-Key we built in the previous section becomes vital here: the retry's second attempt sends the same key, the server recognizes it as a duplicate and doesn't charge again. The iron rule: only enable retries on idempotent operations.
8-3) Deadline and deadline propagation
A timeout is usually "local": each hop has its own timeout. But in a chain A → B → C a problem arises. Suppose the client has a 3-second timeout. A calls B with a 3-second timeout, B calls C with a 3-second timeout. Now if B itself took 2.9 seconds and then calls C, C may work for another 3 seconds — 5.9 seconds total — while the client gave up long ago. C is doing pointless work, burning resources for a response nobody is waiting for anymore.
The fix is to think in deadlines instead of timeouts. Instead of "wait 3 seconds," say "you have until 10:00:03." Carry this deadline (an absolute point in time) with the request through every hop (e.g., in a header). Before starting work, each service checks: "is there still time until the deadline? If not, don't even start and fail immediately." And when it calls the next service, it sets that call's timeout to the remaining time, not a fixed number. gRPC supports this out of the box with the concept of a deadline; in REST you must implement it manually with a deadline header.
// A simple pattern for deadline propagation in REST
long remainingMs = deadline.toEpochMilli() - System.currentTimeMillis();
if (remainingMs <= 0) {
throw new DeadlineExceededException("no time budget left");
}
// next call's timeout = remaining time (with a small margin)
var result = downstream.callWithTimeout(request, remainingMs - 50, deadline);
A timeout is a relative interval that resets at each hop: "3 seconds from now." The problem is that in a multi-service chain these timeouts stack and can far exceed the client's original budget, so services at the tail may work on a request the original caller abandoned long ago. A deadline is an absolute point in time ("until such-and-such moment") that propagates through the whole chain. Each service computes the time remaining until the deadline, fails fast immediately if it's exhausted, and derives its downstream calls' timeouts from that remaining budget. Result: less wasted work and more predictable behavior. gRPC has this built in; in REST you build it manually.
Full resilience pattern: deadline, timeout, retry, circuit breaker | الگویِ کاملِ resilience — deadline، timeout، retry، circuit breaker.
flowchart TD
A[Incoming request + deadline] --> B{time budget left?}
B -->|no| X[fail fast 504]
B -->|yes| C[call downstream with remaining budget]
C --> D{success?}
D -->|yes| E[return result]
D -->|transient error| F{retries left AND idempotent?}
F -->|yes| G[wait backoff + jitter] --> B
F -->|no| H[open circuit / fallback]
9) Contract testing: catch the broken contract before it breaks prod
The biggest fear in microservices: the Payment team changes a field in its response (amount becomes amountCents), deploys, and suddenly Order — which reads that field — breaks in production. No test caught it because each service passed its own tests. The solution: contract testing.
It's like a work contract between a supplier (provider) and a customer (consumer). The customer says "I expect that when I send this request, I get a response of this shape." That expectation is written as a contract. Now both sides can be tested independently: the consumer against a fake provider that answers per the contract, and the provider with a test proving it really fulfills the contract. If the provider breaks the contract, its own test goes red — before it reaches production.
Two main tools: Spring Cloud Contract and Pact.
- Spring Cloud Contract: JVM-native. You write the contract with a DSL (Groovy/YAML). It supports both producer-driven and consumer-driven. From the contract it generates provider tests and ready stubs for the consumer. Best pick when all parties are Spring/JVM and you don't want a separate broker.
- Pact: consumer-driven and polyglot. The contract is generated from the consumer's tests and is in JSON, so it's language-agnostic — great when consumers are heterogeneous (e.g., a React frontend + a Java backend + a Python data service). It usually works with a central Pact Broker.
// Spring Cloud Contract — a contract in the Groovy DSL (provider side)
Contract.make {
request {
method 'POST'
url '/payments'
headers { contentType('application/json') }
body([ orderId: 'o-1', amountCents: 5000 ])
}
response {
status 200
headers { contentType('application/json') }
body([ paymentId: anyUuid(), approved: true ])
}
}
| Dimension | Spring Cloud Contract | Pact |
|---|---|---|
| Philosophy | producer- and consumer-driven | consumer-driven only |
| Language | JVM-centric | polyglot (JSON) |
| Writing the contract | Groovy/YAML DSL | generated from consumer tests |
| Broker | not required (optional) | Pact Broker (common) |
| Best use | pure Spring shop | polyglot teams |
A mental trap: thinking contract tests cover everything. No. Contract tests only verify the shape of the interaction (schema, fields, status code), not deep business logic. You still need unit tests for logic and a few end-to-end tests for critical paths. The real value of contract testing is that it's fast (no need to spin up the whole system) and catches breaking changes in CI — exactly the layer where slow end-to-end integration tests are always weak.
The problem of "silent contract changes between services." A unit test verifies each service correctly in isolation, but doesn't notice that the provider changed a field. An end-to-end integration test catches this but is slow, brittle, and expensive, and you must spin up the whole system. Contract testing is the middle ground: you agree on a contract between consumer and provider and verify each side independently for fidelity to that contract — fast and in CI. If the provider breaks the contract, the provider's build goes red before deploy. The two common tools are Spring Cloud Contract (JVM-native, producer/consumer-driven) and Pact (consumer-driven, polyglot, with a broker).
10) API versioning: change without breaking
Services deploy independently, so you can't force every consumer to update in lockstep with you. You must be able to evolve your API without breaking old clients. This is where versioning enters.
Three common styles:
- URI versioning:
/api/v1/ordersvs/api/v2/orders. Explicit, simple, cacheable, and visible in logs — the most widely used. - Header versioning: a custom header like
X-API-Version: 2or viaAccept: application/vnd.acme.v2+json(known as "content negotiation"). The URL stays clean but it's less visible. - Query param:
/orders?version=2. Simple but pollutes caching and less recommended.
// URI versioning in Spring MVC
@RestController
@RequestMapping("/api/v2/orders")
public class OrderV2Controller {
@GetMapping("/{id}")
public OrderV2Dto get(@PathVariable String id) { ... }
}
The key distinction many get wrong: adding a new optional field to a response is a backward-compatible change and needs no new version (an old client just doesn't see the new field and is fine) — provided your clients tolerate unknown fields. But removing a field, renaming, changing a type (string to number), or tightening validation are all breaking and need a new version. A common production mistake: a team "just renames" a field thinking it's harmless — and an old client still reading the old name breaks silently. Before any change, ask: "is this additive?"
Seniors rarely jump hard to v2. The favored pattern is expand and contract (a.k.a. parallel change): first add the new field/behavior alongside the old one (expand), migrate everyone, then remove the old one (contract). And when you must retire a version, don't kill it abruptly: send a Deprecation and Sunset header (with a date) so clients are warned, keep both versions alive for a while, watch the usage metric of the old version, and turn it off when it hits zero. "Versioning" isn't just a version number; it's a gentle migration process.
First distinguish between a backward-compatible change (adding an optional field, adding an endpoint) that needs no new version, and a breaking change (removing/renaming/retyping a field, tightening validation) that does. For breaking changes, introduce a new version — the most common style is URI versioning (/v2) because it's explicit and visible, though header/content-negotiation is also common. The key is keeping both versions alive in parallel so consumers have time to migrate (the expand/contract pattern). At retirement, use Deprecation and Sunset headers and usage monitoring so the shutdown is incident-free. The ultimate goal: no consumer is ever surprised by one of your deploys.
11) The N+1 trap and the "chatty service"
The last and perhaps most insidious trap. In the ORM world you know the N+1 query problem: one query fetches a list of 100 orders, then in a loop it fires a separate query per order to fetch its customer — 101 queries total instead of 2. In microservices the same disaster strikes a hundred times harder, because each "query" is now a network call, not a memory access.
Imagine a cook wants to make omelets for 50 people. The right way: go to the store once and buy 50 eggs. The N+1 way: go to the store and back once per serving. Whether the store (the downstream service) is near or far, those round trips devour time. One network call might take just 5ms, but 100 back-to-back becomes half a second — and the user is waiting.
Classic example: the "my orders" page must show 20 orders with each one's product name. The naive code:
// Anti-pattern: N+1 over the network — one call per order
List<Order> orders = orderClient.findByUser(userId); // 1 call
for (Order o : orders) {
Product p = productClient.findById(o.productId()); // N separate calls! (20 times)
o.setProductName(p.name());
}
// 21 network calls total for one page
The solutions:
- Batch/bulk endpoint: instead of N single calls, one call with a list of ids:
productClient.findByIds(ids). One round trip instead of N. - Data aggregation / BFF: a Backend-For-Frontend or aggregator layer that gathers several downstream calls (ideally in parallel) and returns one page-ready response.
- Denormalization / events: the order service caches the product name at write time (via an event) so that reads need no call at all.
// Correct: one bulk call instead of N calls
List<Order> orders = orderClient.findByUser(userId); // 1 call
Set<String> ids = orders.stream().map(Order::productId).collect(toSet());
Map<String, Product> products = productClient.findByIds(ids) // 1 call
.stream().collect(toMap(Product::id, identity()));
orders.forEach(o -> o.setProductName(products.get(o.productId()).name()));
// 2 calls total — not 21
The deep root of a chatty service is often a wrong boundary. If you split two always-read-together pieces of data into two separate services, you're doomed to endless chatty calls between them. A senior, when designing boundaries, doesn't only think about the "logical domain" but also looks at the data access pattern: which data is always requested together? Keep it in one boundary. If you find yourself building dozens of bulk endpoints and aggregators just to compensate for chattiness, that's a smell that your boundaries are wrong — and the real cure may be merging two services, not optimizing the calls.
It's the familiar ORM N+1 but over the network, so a hundred times more expensive: one call fetches a list of N items, then in a loop you fire a separate call to another service per item — N+1 network calls, each with its own latency and failure probability. The fix: (1) build a bulk/batch endpoint that takes all the ids at once and make a single call; (2) use an aggregator/BFF that gathers downstream calls (in parallel); (3) use denormalization to cache frequently-needed data in your own service via events so no call is needed. And the deepest layer: if you keep having to compensate for chattiness with bulk endpoints, the service boundaries are probably wrong and two services should be merged.
- Communication has two families: synchronous (the phone: REST/RestClient/OpenFeign/gRPC) for immediate answers, and asynchronous (the letter: Kafka/RabbitMQ) for background work and lower coupling. RabbitMQ is a work queue; Kafka is a durable event log.
- The full request path: client → gateway (auth, trace-id, routing) → serviceA → serviceB → DB → response. Keep the hot path short and push the rest async; every synchronous hop multiplies fragility.
- Coordination: choreography (leaderless, event-driven) for simple flows; orchestration (central conductor) for complex ones. Build distributed transactions with the Saga pattern and compensating transactions, not ACID.
- The survival kit: idempotency keys (because delivery is at-least-once), explicit timeouts, retries only with backoff + jitter + on idempotent operations, and deadline propagation so tail services don't burn resources.
- Lock down contracts with contract testing (Spring Cloud Contract or Pact), and version your API with awareness of backward compatibility (expand/contract, the Sunset header).
- Watch out for the N+1 / chatty service trap: bulk endpoints, aggregator/BFF, denormalization — and if chattiness is incurable, revisit your service boundaries.