Synchronous communication: REST and gRPC
Since Chapter 2, order-service has called inventory-service over plain JSON REST. This chapter examines whether that was the right choice for every caller, and introduces gRPC for the one call path in Northwind’s system where it demonstrably is.
1. Problem the Pattern Solves
order-service’s reservation call to inventory-service (Chapter 2) is small and infrequent enough that REST’s overhead has never mattered. But Northwind’s warehouse-management integration, added after Chapter 5, is different: it calls inventory-service to check stock levels for every SKU on every page load of the internal warehouse dashboard, hundreds of times per second, and each JSON payload carries the same five fixed fields, hand-parsed on both ends from a loosely-typed Map<String, Any> because the two teams’ REST contract drifted out of sync twice already — a field renamed on one side without the other noticing until a null-pointer exception in production.
The team also just discovered a subtler problem: nothing enforced that inventory-service’s REST response actually matched what order-service’s DTO expected, until a runtime deserialization failure. A schema existed only informally, in each team’s memory and a wiki page nobody kept current.
Forces in tension:
- Ubiquity and tooling vs. efficiency. REST over JSON is human-readable, curl-able, cacheable by generic HTTP infrastructure, and understood by every tool and every engineer without extra setup — but it pays a real, measurable cost in payload size and (de)serialization time versus a binary protocol, at high call volumes.
- Loose coupling vs. compile-time safety. REST’s typical “here’s a JSON shape, good luck” contract (even with OpenAPI) is looser and more forgiving of small drift than a compiled protobuf contract — which is either a feature (tolerant evolution) or a liability (silent drift), depending on how disciplined the teams are.
- Streaming and multiplexing vs. simplicity. gRPC (built on HTTP/2) supports bidirectional streaming and multiplexed calls over one connection natively; REST over HTTP/1.1 typically doesn’t, and bolting streaming onto REST is awkward.
- Browser and firewall friendliness vs. internal efficiency. REST/JSON works everywhere, including directly from a browser or through any corporate proxy, with zero special tooling. gRPC needs a gRPC-Web proxy to reach browsers directly and can have friction through some older network appliances — a real deployment consideration, not just a preference.
2. Core Idea
Both REST and gRPC are synchronous, request/response protocols for direct service-to-service calls (as opposed to the asynchronous, event-driven communication covered starting next chapter). The choice between them is about where each fits, not which one is universally better:
- REST over JSON: resource-oriented, human-readable, HTTP/1.1-native, contract expressed informally (docs, OpenAPI) or loosely (JSON Schema) — the right default for anything crossing a trust or organizational boundary where flexibility and universal tooling matter more than raw throughput: the API gateway’s edge, BFF-to-client, and most service-to-service calls that aren’t on a hot path.
- gRPC over Protocol Buffers: contract-first (a
.protofile compiles into strongly-typed client and server stubs in both Kotlin and any other supported language), binary-encoded (smaller, faster to (de)serialize than JSON), HTTP/2-native (multiplexed, supports streaming) — earns its added build-tooling complexity for high-volume, low-latency internal service-to-service calls where both ends are under your own team’s control.
Both transports coexist against the same inventory-service in this chapter’s example — the REST endpoint from Chapter 2 keeps serving order-service’s low-volume reservation calls unchanged; a new gRPC endpoint serves the high-volume stock-lookup path. This is a deliberate point: introducing gRPC doesn’t mean migrating everything to it, and a single service can expose both where each genuinely earns its place.
Commonly confused with:
- GraphQL. GraphQL solves a different problem — client-driven, flexible querying, typically at the edge for BFF-like use cases — not high-throughput internal service-to-service calls. It’s not a substitute for either REST or gRPC in this chapter’s scope.
- Message queues / event-driven communication (next chapter). REST and gRPC are both synchronous — the caller blocks waiting for a response, and both services must be up simultaneously. Asynchronous messaging removes that temporal coupling entirely; it’s a different trade-off, not a faster version of the same one.
- “gRPC is just a faster REST.” gRPC’s speed comes bundled with a stricter contract (schema-first, versioned
.protofiles, generated code) and different operational tooling (it isn’t curl-able or cacheable by a generic HTTP cache the same way). Choosing gRPC “for speed” without accounting for the contract and tooling shift is how teams end up disappointed by the migration cost.
3. When to Use It
Strong indicators for gRPC specifically:
- Measured, not guessed, evidence of a hot internal call path — Northwind’s warehouse dashboard doing hundreds of calls per second is the kind of number that justifies the switch; “it might be faster” is not.
- Both endpoints are owned by teams willing to adopt
.proto-based contract-first development and the build tooling that comes with it (a protobuf compiler step, generated code review practices). - Streaming or bidirectional communication is a real requirement (e.g., a live stock-level feed to the dashboard) rather than a one-shot request/response.
Strong indicators for REST (the default):
- The call crosses a trust boundary (client-facing, partner-facing, or between organizations) where ubiquity, human-readability, and tooling compatibility matter more than raw throughput.
- Call volume and latency sensitivity are unremarkable — most internal service-to-service calls, including Northwind’s own order-placement path, fall here.
Concrete use cases for gRPC:
- Fintech: a risk-scoring service called synchronously on every transaction, at high volume, with strict latency budgets, is a strong gRPC candidate between two internal services.
- Logistics: real-time vehicle-location streaming from a fleet-tracking service to a dispatch service benefits directly from gRPC’s native streaming support.
- Gaming/real-time platforms: internal matchmaking or state-sync services with tight latency budgets and high call volume are classic gRPC territory.
Prerequisites for adopting gRPC:
- A
.protocontract review process, since the generated stubs make the contract far more binding than a REST JSON shape — a breaking.protochange breaks compilation for every consumer, which is a feature, but only if the team has a versioning discipline ready for it (see Section 7). - Both services on a network that supports HTTP/2 cleanly end-to-end (most modern service meshes and Kubernetes CNIs do; verify rather than assume for older infrastructure).
4. When Not to Use It
- Any client-facing or partner-facing contract without a strong, specific reason. Forcing a mobile app or a third-party integrator to consume gRPC (requiring gRPC-Web or a proxy layer) trades away REST’s universal accessibility for a performance benefit that rarely matters at typical client call volumes.
- A call path with no measured throughput or latency problem. Converting
order-service’s low-volume reservation call to gRPC “for consistency” with the warehouse dashboard path adds.protomaintenance and generated-code build steps for zero measurable benefit — this is the overengineering trap for this pattern. - Teams unwilling to adopt schema-first development. If a team resists the discipline of updating
.protofiles and regenerating stubs as part of their normal workflow, gRPC will be fought against rather than embraced, and REST’s looser contract will serve them better regardless of throughput. - Cost/risk: gRPC’s compiled-stub model means a contract change requires coordinated regeneration and redeployment on both ends in a way JSON’s structural flexibility (extra fields ignored, missing optional fields defaulted) often tolerates without any code change at all — a real velocity cost when contracts are still evolving quickly.
5. Implementation Example
The .proto contract, defining inventory-service’s high-volume stock-lookup path:
syntax = "proto3";
package in.o612.eng.northwind.inventory.grpc;
option java_multiple_files = true;option java_package = "in.o612.eng.northwind.inventory.grpc";
service StockLookupService { rpc GetStockLevel (StockLevelRequest) returns (StockLevelResponse); rpc StreamStockLevels (StreamStockRequest) returns (stream StockLevelUpdate);}
message StockLevelRequest { string sku = 1;}
message StockLevelResponse { string sku = 1; int32 available_quantity = 2;}
message StreamStockRequest { repeated string skus = 1;}
message StockLevelUpdate { string sku = 1; int32 available_quantity = 2; int64 updated_at_epoch_millis = 3;}StreamStockLevels is a server-streaming RPC — the warehouse dashboard opens one connection and receives a continuous feed of stock updates for its watched SKUs, something that would require polling or a separate WebSocket layer to approximate over REST.
plugins { id("com.google.protobuf") version "0.9.4"}
dependencies { implementation("io.grpc:grpc-kotlin-stub:1.4.1") implementation("io.grpc:grpc-protobuf:1.66.0") implementation("com.google.protobuf:protobuf-kotlin:4.28.2") implementation("net.devh:grpc-server-spring-boot-starter:3.1.0.RELEASE")}
protobuf { protoc { artifact = "com.google.protobuf:protoc:4.28.2" } plugins { create("grpc") { artifact = "io.grpc:protoc-gen-grpc-java:1.66.0" } create("grpckt") { artifact = "io.grpc:protoc-gen-grpc-kotlin:1.4.1:jdk8@jar" } } generateProtoTasks { all().forEach { it.plugins { create("grpc"); create("grpckt") } it.builtins { create("kotlin") } } }}Server implementation — the generated StockLookupServiceGrpcKt.StockLookupServiceCoroutineImplBase base class integrates naturally with Kotlin coroutines, unlike the REST controller’s blocking style:
package `in`.o612.eng.northwind.inventory.grpc
import `in`.o612.eng.northwind.inventory.internal.StockRepositoryimport kotlinx.coroutines.flow.Flowimport kotlinx.coroutines.flow.flowimport kotlinx.coroutines.delayimport net.devh.boot.grpc.server.service.GrpcService
@GrpcServiceclass StockLookupGrpcService( private val stockRepository: StockRepository,) : StockLookupServiceGrpcKt.StockLookupServiceCoroutineImplBase() {
override suspend fun getStockLevel(request: StockLevelRequest): StockLevelResponse = stockLevelResponse { sku = request.sku availableQuantity = stockRepository.available(request.sku) }
override fun streamStockLevels(request: StreamStockRequest): Flow<StockLevelUpdate> = flow { while (currentCoroutineContext().isActive) { request.skusList.forEach { sku -> emit(stockLevelUpdate { this.sku = sku availableQuantity = stockRepository.available(sku) updatedAtEpochMillis = System.currentTimeMillis() }) } delay(2_000) } }}StockRepository — the exact same class from Chapter 1 — is reused unchanged, same as every transport change so far in this series; only the front door differs.
Kotlin gRPC client, in order-service (used only by the warehouse dashboard’s BFF-adjacent read path, while the reservation call from Chapter 2 keeps using REST):
package `in`.o612.eng.northwind.warehousebff
import `in`.o612.eng.northwind.inventory.grpc.StockLevelRequestimport `in`.o612.eng.northwind.inventory.grpc.StockLookupServiceGrpcKtimport io.grpc.ManagedChannelBuilder
class StockGrpcClient(host: String, port: Int) { private val channel = ManagedChannelBuilder.forAddress(host, port).usePlaintext().build() private val stub = StockLookupServiceGrpcKt.StockLookupServiceCoroutineStub(channel)
suspend fun getStockLevel(sku: String): Int = stub.getStockLevel(StockLevelRequest.newBuilder().setSku(sku).build()).availableQuantity}Production note.
usePlaintext()disables TLS — acceptable only inside a trusted internal network segment or behind a service mesh’s mTLS (a later chapter), never across an untrusted boundary. This example keeps it plain to isolate the gRPC concept from the mesh chapter’s concerns.
A contract test verifying the .proto-generated stub actually matches server behavior — the gRPC equivalent of Chapter 2’s WireMock-based REST contract test, using an in-process gRPC server:
package `in`.o612.eng.northwind.inventory.grpc
import io.grpc.inprocess.InProcessChannelBuilderimport io.grpc.inprocess.InProcessServerBuilderimport kotlinx.coroutines.runBlockingimport org.junit.jupiter.api.Testimport org.assertj.core.api.Assertions.assertThat
class StockLookupGrpcServiceTest {
@Test fun `returns current stock level for a known sku`() = runBlocking { val serverName = io.grpc.inprocess.InProcessServerBuilder.generateName() val server = InProcessServerBuilder.forName(serverName) .directExecutor() .addService(StockLookupGrpcService(fakeStockRepositoryWith("WIDGET-1" to 42))) .build().start() val channel = InProcessChannelBuilder.forName(serverName).directExecutor().build() val stub = StockLookupServiceGrpcKt.StockLookupServiceCoroutineStub(channel)
val response = stub.getStockLevel(StockLevelRequest.newBuilder().setSku("WIDGET-1").build())
assertThat(response.availableQuantity).isEqualTo(42) server.shutdownNow() }}6. Step-by-Step Flow
Tracing the warehouse dashboard’s live stock-level feed, side by side with the still-REST order-placement path:
- Client action. The dashboard opens a live view;
order-service’s checkout flow, unaffected, keeps using REST exactly as in Chapter 2. - API request.
warehouse-bffopens one long-lived gRPC stream instead of polling a REST endpoint every few seconds — one connection, multiplexed, instead of repeated HTTP/1.1 round trips. - Service behavior.
inventory-service’sstreamStockLevelscoroutine emits updates from the sameStockRepositorythe REST and gRPC paths both use. - Database interaction. Identical read path to the REST reservation flow — same repository, same schema, same query.
- Inter-service communication. This flow never touches
order-service; it’s an entirely separate consumer ofinventory-service, on a separate transport, proving the two can coexist without interfering. - Error or failure handling. A dropped gRPC stream (network blip) surfaces as a coroutine
Flowexception onwarehouse-bff’s side; the client reconnects and resumes — gRPC’s HTTP/2 foundation makes reconnect-and-resume more natural here than re-establishing a REST polling loop would be, but doesn’t eliminate the need to handle it explicitly. - Observability signals. gRPC calls are traced the same way as REST calls once instrumented (this series’ Observability chapter covers OpenTelemetry’s gRPC interceptors specifically) — don’t assume tracing “just works” across a transport change without verifying the interceptor is wired up.
- Final response/outcome. The dashboard shows near-real-time stock levels with far less network chatter than a REST polling equivalent would generate at the same update frequency.
7. Production Concerns
- Timeouts and retries. gRPC has built-in deadline propagation (
Context.current().withDeadlineAfter(...)) that’s more ergonomic than REST’s per-call timeout configuration, but the same discipline applies: every gRPC call needs an explicit deadline, or a hung downstream call can hold client resources indefinitely. - Data consistency. Unaffected by transport choice — both the REST and gRPC paths read the same underlying data with the same consistency guarantees; this pattern is purely about the wire protocol, not the data model.
- API versioning and backward compatibility. Protobuf’s field-numbering scheme (
= 1,= 2) is designed for additive evolution — adding a new field with a new number is safe for old clients (they ignore it) and new clients reading old data (they get the field’s default). Removing or renumbering a field is a breaking change enforced at compile time on every consumer, which is stricter than REST’s typical “extra JSON fields are silently ignored” tolerance — a real trade-off, not a strict improvement. - Authentication and service-to-service trust. gRPC calls need the same authentication rigor as REST calls — don’t treat
usePlaintext()internal traffic as implicitly trusted; a service mesh’s mTLS (a later chapter) or explicit per-call credentials should authenticate gRPC calls exactly as REST calls are. - Logging, metrics, tracing, correlation IDs. Propagate correlation IDs via gRPC metadata (the equivalent of an HTTP header) — this doesn’t happen automatically just because both transports exist in the same request’s life; wire an interceptor explicitly on both client and server.
- Kubernetes deployment. gRPC’s HTTP/2 foundation means load balancing needs to be connection-aware, not just request-aware — a naive L4 load balancer can pin a long-lived gRPC connection to one pod indefinitely, defeating even distribution. Kubernetes
Serviceobjects handle this correctly for gRPC when paired with an HTTP/2-aware ingress or a service mesh; verify this explicitly rather than assuming default L4 round-robin behaves the same as it does for REST. - Testing strategy. In-process gRPC servers (as shown above) make gRPC contract tests fast and hermetic, without needing a real network — prefer them over spinning up a full server for most test scenarios, reserving Testcontainers-style full integration tests for the smaller number of true end-to-end checks.
- Migration strategy. Introduce gRPC alongside an existing REST endpoint for the same underlying service, as this chapter does, rather than replacing REST wholesale — this lets you measure the actual throughput/latency benefit on the specific call path that motivated the change, without forcing every consumer through a migration they don’t need.
8. Common Mistakes
- Converting every internal call to gRPC “for consistency.” Applying gRPC to
order-service’s low-volume reservation call adds.protomaintenance and build complexity with no measurable benefit. Fix: let measured throughput or latency data justify each individual conversion, one call path at a time, as this chapter did for the warehouse dashboard specifically. - Exposing gRPC directly to browser or partner clients without a proxy. Browsers can’t speak raw gRPC natively — attempting this without gRPC-Web or a translating proxy produces confusing client-side failures. Fix: keep gRPC to internal, team-controlled service-to-service calls; use REST (possibly via the gateway/BFF from Chapter 4) for anything client-facing.
- No deadline on a gRPC call. Omitting
withDeadlineAfterreproduces REST’s “no timeout configured” failure mode — a hung downstream call ties up caller resources indefinitely, worse on a multiplexed HTTP/2 connection where it can affect other in-flight calls sharing the channel. Fix: set an explicit deadline on every call, the gRPC equivalent of Section 7’s REST timeout discipline. - Skipping schema review discipline for
.protochanges. Treating a.protofile like disposable code rather than a versioned public contract leads to breaking changes shipped without warning consuming teams. Fix: review.protochanges with the same rigor as a REST OpenAPI contract change, and follow protobuf’s additive-evolution conventions (new field numbers, never reused or renumbered). - Assuming tracing propagates automatically across the REST-to-gRPC boundary. A request that starts as REST at the BFF and continues as gRPC to
inventory-serviceneeds its correlation ID and trace context explicitly carried across the transport switch — it does not happen by default. Fix: wire gRPC interceptors for context propagation explicitly, and verify with an actual trace, not an assumption. - Using a plain L4 load balancer for gRPC traffic without checking connection pinning. This silently concentrates load onto whichever pods happened to receive the long-lived connections first. Fix: verify your load balancer or ingress is HTTP/2-aware for gRPC traffic specifically, not just carried over from REST configuration.
9. Decision Guide
| Problem signal | Use this pattern? | Why | Alternative |
|---|---|---|---|
| Measured high-volume, latency-sensitive internal call path | Yes (gRPC) | Binary framing and HTTP/2 multiplexing reduce real, measured overhead | — |
| Streaming or bidirectional communication needed | Yes (gRPC) | Native HTTP/2 streaming support, no bolted-on workaround needed | — |
| Client-facing or partner-facing contract | No (gRPC) | Universal tooling and accessibility outweigh throughput gains for typical client volumes | REST/JSON, optionally behind a gateway/BFF |
| Low-volume internal call, contract still evolving quickly | No (gRPC) | JSON’s structural flexibility tolerates drift better during active development | REST/JSON |
Team unwilling to adopt schema-first .proto workflow | No (gRPC) | Tooling friction will outweigh performance benefit if the team fights the workflow | REST/JSON with OpenAPI |
10. Hands-On Exercise
Extend it: add a BatchGetStockLevels unary RPC to inventory.proto that accepts a list of SKUs and returns all their levels in one call, and have warehouse-bff use it instead of N sequential GetStockLevel calls. Measure (or reason through) the round-trip reduction.
Simulate a failure: kill inventory-service mid-stream while the dashboard’s StreamStockLevels connection is active. Observe what warehouse-bff sees, and implement a reconnect-with-backoff strategy on the client side.
Decision question, with justification required: payment-service is considering exposing a gRPC endpoint for order-service to call during checkout, arguing “it’s more consistent with the warehouse dashboard’s inventory path.” Order placement happens at a few requests per second, well below any measured throughput concern. Should this conversion happen? Weigh Section 4’s overengineering warning against Section 3’s contract-discipline prerequisite, and state what evidence, if any, would change your answer.
11. Key Takeaways
- REST and gRPC are both synchronous, blocking protocols — the choice between them is about payload efficiency, contract strictness, and streaming needs, not about synchronous versus asynchronous communication (that’s the next chapter’s question).
- Default to REST/JSON for anything client-facing, partner-facing, or without measured throughput pressure — it wins on tooling, accessibility, and structural flexibility during active contract evolution.
- Reach for gRPC only where measured evidence (call volume, latency budget, streaming need) justifies its schema-first discipline and build tooling — Northwind’s warehouse dashboard, not its checkout flow.
- A single service can expose both transports for different call paths simultaneously — this isn’t an all-or-nothing migration.
- Protobuf’s compiled, versioned contract is stricter than REST’s typical tolerance for drift — a real trade-off that helps disciplined teams and hurts teams not ready for schema-first review practices.
- Deadlines, authentication, and correlation-ID propagation all need explicit attention on gRPC calls — none of it is inherited automatically from having solved it for REST elsewhere in the system.
- Never expose gRPC directly to a browser or an external partner without a translating proxy — it isn’t natively reachable from those clients.