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، از دست رفتنِ بسته، و سرویسی که آن‌طرفِ خط ممکن است اصلاً جواب ندهد. این فصل دقیقاً دربارهٔ همان «فاصلهٔ بینِ سرویس‌ها» است: چطور دو سرویس با هم حرف می‌زنند، یک درخواست از لحظه‌ای که کاربر روی دکمه می‌زند تا لحظه‌ای که پاسخ برمی‌گردد چه مسیری طی می‌کند، و چطور کاری کنیم که این مسیر در دنیایِ واقعی — که شبکه غیرقابل‌اعتماد است — نشکند.

از method call تا نامه‌نگاری

در 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: پیش‌فرضِ ذهنی‌ات چه باشد؟

یک 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) سخت‌تر (مسیرِ پیام پخش است)
کِی synchronous و کِی asynchronous را انتخاب می‌کنی؟

معیارِ اصلی این است: «آیا کاربر باید همین‌الان نتیجه را ببیند و آیا آن نتیجه بخشی از پاسخِ اوست؟» اگر بله (اعتبارسنجیِ پرداخت، خواندنِ داده‌ای که در صفحه نمایش داده می‌شود، 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 در برابر WebClient

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
تلهٔ timeoutِ نامرئیِ Feign

پرتکرارترین حادثهٔ تولید با Feign این است: تیم یک @FeignClient می‌نویسد، read-timeout را ست نمی‌کند، و یک روز سرویسِ downstream کند می‌شود. حالا هر thread ای که آن Feign را صدا زده تا دقیقه‌ها بلاک می‌ماند، thread poolِ سرویس پُر می‌شود، و سرویسی که هیچ باگی نداشت هم می‌افتد — این را «cascading failure» یا آبشارِ خرابی می‌گویند. همیشه روی هر کلاینتِ synchronous یک connect-timeout و read-timeoutِ صریح بگذار. تایم‌اوتِ بی‌نهایت یعنی «صبر کن تا ابد» و ابد در تولید یعنی فاجعه.

فرقِ RestClient، WebClient و OpenFeign چیست و کی کدام؟

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، کِی REST؟

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());
    }
}
تضمینِ تحویل: at-least-once تقریباً همیشه یعنی «تکراری»

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());
    }
}
Kafka یا RabbitMQ — کدام و چرا؟

هردو 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
مفهومِ کلیدی: مسیرِ داغ (hot path) کوتاه، بقیه async

دقت کن که چه چیزهایی رویِ مسیرِ پاسخِ کاربر اند و چه چیزهایی نیستند. پرداخت روی مسیر است چون کاربر باید نتیجه‌اش را ببیند. اما ارسالِ ایمیلِ تأیید، به‌روزرسانیِ داشبوردِ فروش، و ایندکسِ جستجو نباید روی مسیر باشند — آن‌ها را با یک event به Kafka می‌سپاریم و مسیرِ کاربر بلافاصله بسته می‌شود. هرچه کارِ کمتری روی hot path بگذاری، latency پایین‌تر و مقاومتِ سیستم بالاتر است. این ساده‌ترین و بزرگ‌ترین برندهٔ کارایی در microservices است.

هر hop یک ضربدر در احتمالِ خطاست

ریاضیِ ترسناکِ microservices: اگر هر سرویس ۹۹.۹٪ در دسترس باشد (uptime خوبی است)، و یک درخواست از ۵ سرویسِ synchronous پشتِ‌سرِهم عبور کند، در دسترس بودنِ کلِ مسیر می‌شود ۰.۹۹۹ به توانِ ۵ ≈ ۹۹.۵٪ — یعنی خطا پنج برابر شد. زنجیرهٔ طولانیِ تماس‌هایِ synchronous شکنندگی را ضرب می‌کند. این تنها یک استدلالِ نظری نیست؛ دلیلِ اصلیِ این است که seniorها زنجیره را کوتاه نگه می‌دارند، تماس‌ها را موازی می‌کنند وقتی مستقل‌اند، و هرجا شد به async مهاجرت می‌کنند.

یک درخواست را چطور در سراسرِ چند سرویس ردیابی (trace) می‌کنی؟

با 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: نه یک اصلِ مذهبی، بلکه ابزار

تازه‌کارها بینِ این دو جنگِ مذهبی راه می‌اندازند. senior می‌داند که هردو ابزارند. choreography برایِ جریان‌هایِ سادهٔ ۲-۳ مرحله‌ای که سرویس‌ها واقعاً مستقل‌اند عالی است و coupling را کم نگه می‌دارد. اما وقتی جریان بزرگ می‌شود (۷-۸ مرحله با منطقِ شرطی و جبران)، choreography به یک «قتلِ مرموز» تبدیل می‌شود: هیچ‌کس نمی‌داند کلِ فرآیند کجاست، چون منطق در ده جایِ مختلف پخش شده. آنجا orchestration برنده است چون جریان را در یک جا صریح و قابلِ ردیابی می‌کند. قاعدهٔ عملی: جریانِ ساده → 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 --> [*]
جبران، rollback نیست — و همه‌چیز جبران‌پذیر نیست

یک اشتباهِ ذهنیِ خطرناک: فکر کنی compensating transaction همان rollback است. نیست. rollback یعنی «انگار هیچ اتفاقی نیفتاد». جبران یعنی «یک عملِ جدید که اثرِ عملِ قبلی را خنثی می‌کند» — و رد پایش می‌ماند. مثلاً «واریزِ برگشتی» یک ردیفِ جدید در حساب است، نه پاک‌شدنِ ردیفِ قبلی. بدتر: بعضی کارها جبران‌پذیر نیستند — ایمیلی که فرستادی برنمی‌گردد، پیامکی که رفت رفته. senior این کارهایِ برگشت‌ناپذیر را تا آخرین مرحلهٔ ممکن در saga عقب می‌اندازد، تا احتمالِ اینکه لازم شود جبرانشان کنی کمینه شود. saga موضوعِ یک فصلِ کامل است؛ اینجا فقط ایده را کاشتیم.

چرا در microservices نمی‌توانیم فقط یک تراکنشِ ACID داشته باشیم؟

چون هر سرویس دیتابیسِ خودش را دارد (الگویِ database-per-service) و یک تراکنشِ ACID فقط داخلِ مرزِ یک دیتابیس معنا دارد. راهِ حلِ کلاسیکِ «two-phase commit» (2PC/XA) روی چند دیتابیس از نظرِ تئوری ممکن است اما در عمل کنار گذاشته می‌شود: قفل‌هایِ طولانی، نقطهٔ شکستِ واحد در coordinator، و مقیاس‌ناپذیری. به‌جایش الگویِ Saga را می‌پذیریم: دنباله‌ای از تراکنش‌هایِ محلی که هرکدام داخلِ سرویسِ خودش اتمی است، و برایِ خطا از compensating transaction استفاده می‌کنیم. قیمتی که می‌پردازیم: سیستم دیگر strongly consistent نیست بلکه eventually consistent است، و باید حالت‌هایِ میانی (مثلِ «پرداخت شد ولی هنوز ارسال نشده») را در طراحی بپذیریم.


۷) Idempotency: سنگِ بنایِ بقا

گفتیم شبکه غیرقابل‌اعتماد است و پیام‌ها تکرار می‌شوند. حالا سؤالِ حیاتی: اگر همان درخواستِ «کسرِ ۱۰۰ دلار» دوبار برسد چه می‌شود؟ اگر ساده نوشته باشی، دوبار کسر می‌شود و مشتری عصبانی. راهِ‌حل: 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);
تفاوتِ دیالکت: upsert

PostgreSQL دستورِ تمیزِ INSERT ... ON CONFLICT (...) DO NOTHING | DO UPDATE را دارد. Oracle به‌طورِ سنتی از MERGE استفاده می‌کند (اگرچه Oracle 23ai هم ON CONFLICT را اضافه کرده، اما در بیشترِ محیط‌هایِ تولید هنوز روی 19c هستی، پس MERGE امن‌ترین انتخابِ قابلِ‌حمل است). نکتهٔ portability: کدِ upsert را در یک لایهٔ repository جدا کن تا تعویضِ دیالکت فقط یک فایل را لمس کند.

idempotency فقط برایِ پول نیست — برایِ هر consumerِ پیام هم هست

یادت باشد گفتیم Kafka/RabbitMQ حالتِ at-least-once دارند. یعنی همان listener ممکن است یک event را دوبار بگیرد. اگر listenerِ تو «موجودی را کم کن» را دوبار اجرا کند، موجودی اشتباه می‌شود. پس idempotency فقط یک ماجرایِ HTTP نیست؛ هر consumerِ event باید idempotent باشد. الگویِ رایج: یک event_id یکتا در هر پیام بگذار و در consumer یک جدولِ processed_events نگه دار؛ اگر id را قبلاً دیدی، پیام را نادیده بگیر (skip). این یک خطِ دفاعیِ حیاتی است که خیلی از تیم‌ها فراموشش می‌کنند تا اولین حادثهٔ «دیتایِ دوبل».

چطور یک API را idempotent می‌کنی و چرا مهم است؟

مهم است چون شبکه ذاتاً 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ِ خام خطرناک است.

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 امن است

اینجا دو مبحثِ قبلی به هم گره می‌خورند: 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 propagation: بودجهٔ زمانی را حمل کن

راهِ‌حل، تفکر با 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 و 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 testing جایگزینِ integration test نیست

دامِ ذهنی: فکر کنی contract test همه‌چیز را پوشش می‌دهد. نه. contract test فقط شکلِ تعامل (schema، فیلدها، status code) را تأیید می‌کند، نه منطقِ عمیقِ کسب‌وکار را. هنوز به تستِ واحد برایِ منطق و به چند تستِ end-to-end برایِ مسیرهایِ حیاتی نیاز داری. ارزشِ واقعیِ contract testing این است که سریع است (نیازی به بالا آوردنِ کلِ سیستم نیست) و در CI جلویِ breaking changeها را می‌گیرد — یعنی همان لایه‌ای که integration testهایِ کندِ end-to-end همیشه در آن ضعیف‌اند.

contract testing چه مشکلی را حل می‌کند که unit و integration test حل نمی‌کنند؟

مشکلِ «تغییرِ خاموشِ قرارداد بینِ سرویس‌ها». 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) { ... }
}
قانونِ طلایی: تغییراتِ additive بشکن‌نیستند، حذف و تغییرِ نام هستند

تفاوتِ کلیدی که خیلی‌ها اشتباه می‌کنند: افزودن یک فیلدِ اختیاریِ جدید به پاسخ یک تغییرِ backward-compatible است و نیاز به نسخهٔ جدید ندارد (کلاینتِ قدیمی فیلدِ نو را نمی‌بیند و مشکلی نیست) — به‌شرطی که کلاینت‌هایت با فیلدهایِ ناشناخته مدارا کنند. اما حذفِ یک فیلد، تغییرِ نام، تغییرِ نوع (string به number)، یا سخت‌گیرترکردنِ validation همه breaking اند و نسخهٔ جدید می‌خواهند. یک اشتباهِ رایجِ تولید: تیم فیلدی را «فقط تغییرِ نام» می‌دهد و فکر می‌کند بی‌ضرر است — و یک کلاینتِ قدیمی که هنوز نامِ قبلی را می‌خواند خاموش می‌شکند. قبل از هر تغییر بپرس: «آیا این additive است؟»

استراتژیِ واقعیِ senior: expand/contract و آفتابِ‌لبِ‌بام (sunset)

seniorها به‌ندرت به‌سختی به v2 می‌پرند. الگویِ محبوب expand and contract (یا parallel change) است: اول فیلد/رفتارِ جدید را کنارِ قدیمی اضافه کن (expand)، همه را مهاجرت بده، بعد قدیمی را حذف کن (contract). و وقتی مجبوری یک نسخه را بازنشسته کنی، ناگهانی خاموشش نکن: یک هدرِ Deprecation و Sunset (با تاریخ) بفرست تا کلاینت‌ها هشدار بگیرند، مدتی هردو نسخه را زنده نگه دار، متریکِ استفاده از نسخهٔ قدیمی را ببین، و وقتی به صفر رسید خاموش کن. «نسخه‌بندی» فقط شمارهٔ نسخه نیست؛ یک فرآیندِ مهاجرتِ مهربان است.

چطور یک microservice API را نسخه‌بندی می‌کنی بدونِ اینکه consumerها را بشکنی؟

اول تفکیک کن بینِ تغییرِ 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 در microservices را توضیح بده و چطور رفعش می‌کنی؟

همان 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 callorderService.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.

From a method call to writing letters

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.

Roadmap

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.

Senior judgment: what should your default be?

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)
When do you choose synchronous vs asynchronous communication?

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 vs WebClient

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 invisible Feign timeout trap

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.

What's the difference between RestClient, WebClient, and OpenFeign, and when do you use each?

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
    }
}
When gRPC, when REST?

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.

A newspaper log vs a mail carrier's queue

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());
    }
}
Delivery guarantee: at-least-once almost always means "duplicated"

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());
    }
}
Kafka or RabbitMQ — which and why?

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?

  1. The Client (browser/mobile) sends an HTTPS request to a domain.
  2. 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.
  3. The Order Service receives the request, validates it, and calls the Payment Service synchronously to charge the money.
  4. The Payment Service works with its own DB and returns the result.
  5. 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
Key idea: keep the hot path short, push the rest async

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.

Every hop is a multiplier on the probability of failure

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.

How do you trace one request across several services?

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
Senior judgment: not a religion, just a tool

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.

Compare choreography and orchestration — pros and cons of each?

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.

Booking a multi-step trip

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 --> [*]
Compensation is not rollback — and not everything is compensatable

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.

Why can't we just have one ACID transaction in microservices?

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.

Definition of 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);
Dialect difference: upsert

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.

Idempotency isn't only for money — it's for every message consumer too

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.

How do you make an API idempotent and why does it matter?

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.

Naive retry = a cascading-failure bomb

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);
}
Retry is only safe with idempotency

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.

Deadline propagation: carry the time budget

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);
What's the difference between a timeout and a 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.

A work contract between two teams

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
Contract testing doesn't replace integration tests

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.

What problem does contract testing solve that unit and integration tests don't?

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/orders vs /api/v2/orders. Explicit, simple, cacheable, and visible in logs — the most widely used.
  • Header versioning: a custom header like X-API-Version: 2 or via Accept: 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) { ... }
}
Golden rule: additive changes aren't breaking; removals and renames are

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?"

The real senior strategy: expand/contract and sunset

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.

How do you version a microservice API without breaking consumers?

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.

A cook who goes to the store once per egg

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
Draw service boundaries by "access pattern," not just by domain

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.

Explain the N+1 problem in microservices and how you fix it.

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.


Chapter capsule
  • 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.