Skip to content

Service Communication — Microservices Interview

Target: Senior Engineer · Engineering Lead · Pre-Architect Focus: gRPC vs REST, reliability patterns, API versioning, service mesh


Q: How do you ensure reliable inter-service communication with retries and timeouts?

Why interviewers ask this: Network failures are inevitable. Tests understanding of timeout strategies, exponential backoff, and when retries are safe.

Answer

The problem: Networks are unreliable. A 99.9% reliable service calling 10 services = 99% overall reliability (all succeed). Each retry risks cascading failures.

Solution hierarchy:

Failure handling order:
1. Timeout — don't wait forever (2-5 sec typical)
2. Detect failure → fast-fail
3. Retry with backoff (exponential: 100ms → 200ms → 400ms)
4. Circuit breaker — stop retrying if service is down
5. Fallback — return degraded response

Spring Boot example:

@Service
public class PaymentClient {

    @Retry(name = "payment", fallbackMethod = "paymentFallback")
    @CircuitBreaker(name = "payment")
    @TimeLimiter(name = "payment")
    public CompletableFuture<PaymentResponse> charge(PaymentRequest req) {
        return CompletableFuture.supplyAsync(() ->
            webClient.post()
                .uri("http://payment-service/charge")
                .bodyValue(req)
                .retrieve()
                .bodyToMono(PaymentResponse.class)
                .timeout(Duration.ofSeconds(2))
                .block()
        );
    }

    public CompletableFuture<PaymentResponse> paymentFallback(
            PaymentRequest req, Exception ex) {
        // Return degraded response
        return CompletableFuture.completedFuture(
            PaymentResponse.pending(req.orderId)
        );
    }
}

Configuration:

resilience4j:
  retry:
    instances:
      payment:
        max-attempts: 3
        wait-duration: 100ms
        exponential-backoff-multiplier: 2
        retry-exceptions:

          - java.net.ConnectException
          - java.io.IOException

  timelimiter:
    instances:
      payment:
        timeout-duration: 2s

Backoff strategy:

Attempt 1: immediate
Attempt 2: wait 100ms
Attempt 3: wait 200ms
Total max wait: 300ms before failing

Common Mistake

Retry on any exception = disaster. SocketTimeoutException → safe to retry. PaymentAlreadyProcessedException → fail immediately. Design idempotent operations before enabling retries on mutations.


Q: When should you use gRPC vs REST for inter-service communication?

Why interviewers ask this: Tech choice has cascading implications for performance, debugging, and team expertise. Tests architectural trade-off thinking.

Answer

Criteria REST gRPC
Serialization JSON (text) Protocol Buffers (binary)
Size Large (~200 bytes) Small (~50 bytes) — 4x smaller
Speed Slower parsing Fast binary parsing
Protocol HTTP/1.1 (one request at a time) HTTP/2 (multiplexed)
Streaming No native support Bidirectional streaming
Debugging Easy (curl, browser) Harder (need grpcurl)
Ecosystem Mature, widely supported Growing but less mature
Browser clients ✅ Native ❌ Requires gRPC-Web proxy
Use case Public APIs, third-party clients Internal service-to-service

Performance comparison:

Service A → Service B (processing 10,000 requests)

REST:

- Request size: 200 bytes × 10k = 2 MB
- Response size: 300 bytes × 10k = 3 MB
- Total: 5 MB over network
- Latency: ~50 ms per request (JSON parsing)

gRPC:

- Request size: 50 bytes × 10k = 500 KB
- Response size: 75 bytes × 10k = 750 KB
- Total: 1.25 MB over network (-75%)
- Latency: ~5 ms per request (binary parsing, HTTP/2)
- 10x faster per request

gRPC service definition:

syntax = "proto3";

service OrderService {
  rpc GetOrder(OrderId) returns (Order);
  rpc CreateOrder(OrderRequest) returns (OrderResponse);
  // Bidirectional streaming
  rpc ProcessOrders(stream Order) returns (stream OrderStatus);
}

message Order {
  string id = 1;
  int64 amount = 2;
  string status = 3;
}

gRPC + Spring Boot:

@GrpcService
public class OrderServiceImpl extends OrderServiceGrpc.OrderServiceImplBase {

    @Override
    public void getOrder(OrderId request, 
                        StreamObserver<Order> responseObserver) {
        Order order = orderService.findById(request.getId());
        responseObserver.onNext(order);
        responseObserver.onCompleted();
    }
}

Recommendation:

  • Use REST — Public APIs, third-party clients, simplicity required
  • Use gRPC — Internal service-to-service (10+ services), high throughput, streaming needs
  • HybridREST for edge (API Gateway) → gRPC internally

Q: How do you implement API versioning without breaking clients?

Answer

Three versioning strategies:

Strategy Example Pros Cons
URL path /v1/orders, /v2/orders Explicit, works with proxies Duplicate code, routing complexity
Header Accept: application/json; version=2 Single codebase, clean URLs Less discoverable, cache issues
Query param /orders?api_version=2 Flexible, easy to test Cache-key issues, non-standard

Best practice — URL path with header fallback:

@RestController
@RequestMapping("/api")
public class OrderController {

    @GetMapping(
        "/v1/orders/{id}",
        produces = "application/json"
    )
    public OrderV1 getOrderV1(@PathVariable String id) {
        Order order = orderService.findById(id);
        // Map internal model to V1 (no new fields)
        return OrderV1.from(order);
    }

    @GetMapping(
        "/v2/orders/{id}",
        produces = "application/json"
    )
    public OrderV2 getOrderV2(@PathVariable String id) {
        Order order = orderService.findById(id);
        // Map to V2 (includes new fields, e.g., "estimatedDelivery")
        return OrderV2.from(order);
    }
}

API evolution best practices:

  1. Add fields, never remove — Old clients ignore unknown fields
  2. Default old fields — Always include fields from V1 in V2
  3. Deprecation timeline — Announce 6–12 months before retiring API version
  4. Semantic versioning — MAJOR.MINOR.PATCH (v1, v2, v1.1)

Deprecation example:

2024-01: Release /v2/orders (new field: tracking_id)
2025-01: Announce deprecation of /v1/orders
2025-07: Retire /v1/orders (clients must migrate)

Q: Should you implement a service mesh (Istio, Linkerd)?

Why interviewers ask this: Service mesh is a major operational investment. Tests cost/benefit thinking and maturity assessment.

Answer

Service mesh = sidecar proxies + control plane that handle:

  • Retries, circuit breaking, timeouts (without code changes)
  • Load balancing, traffic splitting (canary deployments)
  • mTLS encryption between all services
  • Distributed tracing, observability

Decision matrix:

Organization Stage Use Service Mesh? Why
< 5 microservices ❌ No Overkill — use libraries (Resilience4j)
5-20 microservices ⚠️ Maybe Only if you have Kubernetes + experienced ops team
20-50 microservices ✅ Yes Centralized policy enforcement pays off
50+ microservices ✅ Definitely Manual resilience in each service becomes unmaintainable

Tradeoffs:

Aspect Pro Con
Resilience Automatic circuit breakers, retries in proxy Added complexity, learning curve
Observability Automatic tracing, metrics without code changes More infrastructure to operate
Overhead Centralized policy, no library duplication ~10% latency penalty, memory per pod
Debugging Flow is visible in mesh More tools to learn (istioctl)

Recommendation:

  • Start with Resilience4j libraries (simpler, less overhead)
  • Migrate to service mesh when you have 15+ services AND a dedicated platform team
  • Use managed service mesh (AWS App Mesh, Google Anthos) to reduce operational burden

Diagram — Complete Service Communication Architecture

graph LR
    Client["Client"]
    Gateway["API Gateway\n· REST /v2/\n· Rate limit"]
    SvcA["Service A"]
    SvcB["Service B"]
    Mesh["Service Mesh · Istio\n· mTLS\n· Circuit breaker\n· Tracing"]

    Client -->|REST\nHTTP/1.1| Gateway
    Gateway -->|gRPC\nHTTP/2| SvcA
    SvcA -->|gRPC\nHTTP/2| SvcB
    Mesh -.->|Sidecar proxy\nretry + circuit breaker| SvcA
    Mesh -.->|Sidecar proxy\nretry + circuit breaker| SvcB

    style Gateway fill:#4ecdc4
    style SvcA fill:#51cf66
    style SvcB fill:#ffe066
    style Mesh fill:#9b59b6

Advanced API Design


Q: What are the core best practices for designing a production-grade REST API?

Why interviewers ask this: REST is ubiquitous but poorly designed REST APIs cause breaking changes, client confusion, and performance problems. Tests pragmatic API craft beyond just "use HTTP verbs correctly".

Answer

HTTP methods and idempotency contract:

Method Safe? Idempotent? Use Case
GET Read resource
HEAD Read headers only
PUT Full replace — same result if repeated
PATCH ❌ (by default) Partial update — use idempotency key
DELETE Delete — 200 or 404 on repeat
POST Create — must add idempotency key for safety

Idempotency key pattern for non-safe operations:

// Client sends: POST /orders + Idempotency-Key: uuid-1234
@PostMapping("/orders")
public ResponseEntity<Order> createOrder(
        @RequestHeader("Idempotency-Key") String idempotencyKey,
        @RequestBody OrderRequest req) {

    // Check if this key was already processed
    Optional<Order> cached = idempotencyStore.get(idempotencyKey);
    if (cached.isPresent()) {
        return ResponseEntity.ok(cached.get());  // Return same response, no double-process
    }

    Order order = orderService.create(req);
    idempotencyStore.store(idempotencyKey, order, Duration.ofHours(24));
    return ResponseEntity.status(201).body(order);
}

HTTP status codes — use them precisely:

Code Meaning When to Use
200 OK Success with body GET, PUT, PATCH response
201 Created Resource created POST creating a new resource
202 Accepted Async processing started Long-running job queued
204 No Content Success, no body DELETE
400 Bad Request Client sent invalid data Validation error
401 Unauthorized Not authenticated Missing/invalid token
403 Forbidden Authenticated but not authorised Wrong role/scope
404 Not Found Resource doesn't exist
409 Conflict State conflict Duplicate creation, optimistic lock fail
422 Unprocessable Entity Semantically invalid Business rule violation
429 Too Many Requests Rate limited Add Retry-After header
503 Service Unavailable Downstream down Circuit open

Pagination — always paginate collection endpoints:

// Cursor-based pagination (preferred for large datasets)
GET /orders?after=ord-cursor-xyz&limit=20

// Response includes navigation cursors
{
  "data": [...],
  "pagination": {
    "limit": 20,
    "next_cursor": "ord-cursor-abc",  // Opaque — don't expose DB offset
    "has_more": true
  }
}

// Spring Boot example
@GetMapping("/orders")
public PagedResponse<Order> getOrders(
        @RequestParam(required = false) String after,
        @RequestParam(defaultValue = "20") int limit) {

    List<Order> orders = orderRepository.findAfterCursor(after, limit + 1);
    boolean hasMore = orders.size() > limit;
    String nextCursor = hasMore ? orders.get(limit - 1).getId() : null;
    return new PagedResponse<>(orders.subList(0, Math.min(limit, orders.size())),
                               nextCursor, hasMore);
}

Why cursor over offset pagination:

Offset: GET /orders?page=5&size=20
  Problem: INSERT during pagination shifts rows → page 5 shows wrong data
  Problem: OFFSET 100 = DB scans and discards 100 rows (expensive at scale)

Cursor: GET /orders?after=last-seen-id&limit=20
  ✅ Stable — inserts don't affect cursor position
  ✅ O(1) — WHERE id > cursor uses index directly
  ❌ Can't jump to arbitrary page (rare need)

Content negotiation:

// Support multiple response formats via Accept header
@GetMapping(value = "/orders/{id}",
    produces = { MediaType.APPLICATION_JSON_VALUE, "application/vnd.api+json" })
public Order getOrder(@PathVariable String id) { ... }

// Versioning via media type (preferred for major breaking changes)
@GetMapping(value = "/orders/{id}",
    produces = "application/vnd.orders.v2+json")
public OrderV2 getOrderV2(@PathVariable String id) { ... }

Architect Insight

Use 202 Accepted + a job ID for any operation taking > 500ms. Return GET /jobs/{id} for the client to poll status. This prevents gateway timeouts, enables retries, and gives clients progress visibility — much better than holding the HTTP connection open.


Q: When would you choose GraphQL over REST or gRPC? What are the trade-offs?

Why interviewers ask this: GraphQL is increasingly common in BFF (Backend for Frontend) layers. Tests understanding of when client-driven queries add value versus complexity.

Answer

GraphQL lets clients specify exactly what data they need in a single query — solving over-fetching and under-fetching problems common with REST.

Criteria REST gRPC GraphQL
Data shape Fixed per endpoint Fixed per proto Client-defined per query
Over-fetching Common (GET /users returns all fields) Reduced (proto fields) Eliminated — client selects fields
Under-fetching N+1 requests for related data Multiple calls Single query for nested data
Performance Good Best (binary, HTTP/2) Overhead from query parsing; use DataLoader
Type safety No (OpenAPI optional) Strong (protobuf) Strong (schema-first)
Browser clients ✅ Native ❌ Needs proxy ✅ Native (POST /graphql)
Caching HTTP cache (GET) Manual ❌ POST requests, manual persisted queries
Best for Public APIs, CRUD Internal high-throughput BFF, aggregation layers, mobile clients

Spring Boot GraphQL example:

// Schema
// type Query { order(id: ID!): Order }
// type Order { id: ID!, status: String!, customer: Customer! }
// type Customer { id: ID!, name: String!, email: String! }

@Controller
public class OrderGraphQLController {

    @QueryMapping
    public Order order(@Argument String id) {
        return orderService.findById(id);
    }

    @SchemaMapping(typeName = "Order", field = "customer")
    public Customer customer(Order order) {
        // DataLoader batches N customer lookups into 1 query
        return customerService.findById(order.getCustomerId());
    }
}

N+1 problem — always use DataLoader in GraphQL:

// Without DataLoader: 1 query for orders + N queries for customers
// With DataLoader: 1 query for orders + 1 batch query for all customers

@Bean
public BatchLoaderRegistry batchLoaderRegistry() {
    return registry -> registry.<String, Customer>forTypePair(String.class, Customer.class)
        .registerMappedBatchLoader((customerIds, env) ->
            customerService.findAllByIds(customerIds)  // Single DB call
                .collectMap(Customer::getId)
        );
}

When to choose GraphQL:

✅ Use GraphQL when:
  - Mobile clients need bandwidth-optimised responses (request only needed fields)
  - BFF layer aggregates data from 5+ microservices into one response
  - Rapid product iteration — clients evolve queries without API changes
  - Complex nested entity graphs (social graphs, product catalogues with variants)

❌ Avoid GraphQL when:
  - Simple CRUD services (REST is simpler and more cacheable)
  - High-performance internal service calls (use gRPC)
  - Team unfamiliar with schema design and DataLoader patterns
  - Public rate-limited APIs (POST semantics break HTTP caching)

Architect Insight

A common architecture is REST or gRPC for internal service-to-service communication, and GraphQL only at the BFF/API Gateway layer facing the client. This keeps internal APIs simple and lets the edge layer optimise for client needs independently.