Inter-Service Communication: REST vs Messaging vs gRPC
Introduction
Inside one application a class calls another with a method call, and nobody thinks about it. Between two services the same call crosses a network, and the way it crosses is a design decision with lasting effects. Three styles cover almost every case: REST over HTTP with JSON, gRPC with binary messages and a strict contract, and messaging through a broker. The first two are synchronous - the caller waits for an answer. The third is asynchronous - the caller hands over a message and moves on. This tutorial builds all three between the same two services, an order service and a catalog service, runs them, then stops the catalog service to see which style survives. It ends with a comparison and a way to choose. The code runs on Spring Boot 4.1.2 with Spring gRPC 1.1.2 (gRPC 1.84.1) and ActiveMQ Artemis 2.53.0.Synchronous and asynchronous
The first question is not REST or gRPC. It is whether the caller needs the answer now.
- Synchronous - request and response. The order service asks "what is the price of this book?" and cannot continue without the answer. Both services must be up at the same moment, and the caller is as slow as the service it calls.
- Asynchronous - a message. The order service announces "an order was placed" and does not care when, or by how many services, this is acted upon. A broker in the middle keeps the message until the receiver takes it.
REST: the default choice
The catalog service offers the price of a book over HTTP, as in every earlier tutorial of this series -catalog-service: BookController.java
//BookController.java
package com.javabrahman.cloud.comm.catalog;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class BookController {
record Book(String isbn, String title, int price) {}
@GetMapping("/books/{isbn}")
Book book(@PathVariable String isbn) {
return new Book(isbn, "Effective Java", 450);
}
}
order-service: CatalogClient.java
//CatalogClient.java
package com.javabrahman.cloud.comm.order;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.service.annotation.GetExchange;
public interface CatalogClient {
record Book(String isbn, String title, int price) {}
@GetExchange("/books/{isbn}")
Book book(@PathVariable String isbn);
}
curl can call it.
gRPC: a contract first
gRPC starts from the other end. The two services first agree on a contract, written in a.proto file in the Protocol Buffers language -
src/main/proto/price.proto
syntax = "proto3";
package catalog;
option java_multiple_files = true;
option java_package = "com.javabrahman.cloud.comm.grpc";
// The contract between the two services: one unary call and one server-streaming call.
service PriceService {
rpc GetPrice (PriceRequest) returns (PriceReply);
rpc WatchPrice (PriceRequest) returns (stream PriceReply);
}
message PriceRequest {
string isbn = 1;
}
message PriceReply {
string isbn = 1;
string title = 2;
int32 price = 3;
}
- A
servicelists remote methods; amessageis a data structure. The numbers are field tags: on the wire a field is identified by its number, not its name, which keeps messages small and lets fields be renamed safely. - At build time a Maven plugin runs the
protoccompiler on this file and generates Java code: the classesPriceRequestandPriceReply, a base class for the server and stubs for the client. Both services compile against the generated code, so a mismatch is a compile error, not a surprise in production. For this tutorial the code was generated withprotobuf-maven-pluginand theprotoc-gen-grpc-javaplugin. WatchPricereturns astream: one request, many replies over time. gRPC runs on HTTP/2 and has streaming in both directions built in.
pom.xml (extract)
<!-- catalog-service -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-grpc-server</artifactId>
</dependency>
<!-- order-service -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-grpc-client</artifactId>
</dependency>
catalog-service: PriceGrpcService.java
//PriceGrpcService.java
package com.javabrahman.cloud.comm.catalog;
import com.javabrahman.cloud.comm.grpc.PriceReply;
import com.javabrahman.cloud.comm.grpc.PriceRequest;
import com.javabrahman.cloud.comm.grpc.PriceServiceGrpc;
import io.grpc.stub.StreamObserver;
import org.springframework.stereotype.Service;
@Service
public class PriceGrpcService extends PriceServiceGrpc.PriceServiceImplBase {
@Override
public void getPrice(PriceRequest request, StreamObserver<PriceReply> response) {
response.onNext(reply(request.getIsbn(), 450));
response.onCompleted();
}
@Override
public void watchPrice(PriceRequest request, StreamObserver<PriceReply> response) {
for (int price : new int[] {450, 430, 399}) { // three price changes, pushed one by one
response.onNext(reply(request.getIsbn(), price));
pause();
}
response.onCompleted();
}
private PriceReply reply(String isbn, int price) {
return PriceReply.newBuilder().setIsbn(isbn).setTitle("Effective Java").setPrice(price).build();
}
private void pause() {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
StreamObserver - once for a simple call, several times for a stream - and then completes it.
Messaging: tell, don't ask
For the third style the order service sends a message to a queue namedorders.placed, and the catalog service listens on that queue to reserve stock -
catalog-service: StockListener.java
//StockListener.java
package com.javabrahman.cloud.comm.catalog;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jms.annotation.JmsListener;
import org.springframework.stereotype.Component;
@Component
public class StockListener {
private static final Logger log = LoggerFactory.getLogger(StockListener.class);
@JmsListener(destination = "orders.placed")
public void onOrderPlaced(String message) {
log.info("Reserving stock for [{}]", message);
}
}
catalog-service: CatalogApp.java
//CatalogApp.java
package com.javabrahman.cloud.comm.catalog;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class CatalogApp {
public static void main(String[] args) {
SpringApplication.run(CatalogApp.class, args);
}
}
catalog-service: application.properties
spring.application.name=catalog-service
server.port=8081
# the gRPC server listens on its own port
spring.grpc.server.port=9090
# connect to the message broker
spring.artemis.mode=native
spring.artemis.broker-url=tcp://localhost:61616
logging.level.com.javabrahman=INFO
The order service: three clients
The order service sets up one client for each style -order-service: OrderApp.java
//OrderApp.java
package com.javabrahman.cloud.comm.order;
import com.javabrahman.cloud.comm.grpc.PriceServiceGrpc;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.artemis.autoconfigure.ArtemisConfigurationCustomizer;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.grpc.client.GrpcChannelFactory;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
@SpringBootApplication
public class OrderApp {
// 1. REST - an HTTP interface client on top of RestClient
@Bean
CatalogClient catalogClient(RestClient.Builder builder, @Value("${catalog.url}") String url) {
RestClient client = builder.baseUrl(url).build();
return HttpServiceProxyFactory.builderFor(RestClientAdapter.create(client)).build()
.createClient(CatalogClient.class);
}
// 2. gRPC - a generated stub on a channel that is configured in the properties
@Bean
PriceServiceGrpc.PriceServiceBlockingStub priceStub(GrpcChannelFactory channels) {
return PriceServiceGrpc.newBlockingStub(channels.createChannel("catalog"));
}
// 3. Messaging - JmsTemplate is autoconfigured; this only opens the embedded broker to other processes
@Bean
ArtemisConfigurationCustomizer brokerPort() {
return configuration -> {
try {
configuration.addAcceptorConfiguration("tcp", "tcp://localhost:61616");
} catch (Exception e) {
throw new IllegalStateException(e);
}
};
}
public static void main(String[] args) {
SpringApplication.run(OrderApp.class, args);
}
}
order-service: application.properties
spring.application.name=order-service
server.port=8080
# REST
catalog.url=http://localhost:8081
# gRPC - the channel named "catalog"
spring.grpc.client.channel.catalog.target=static://localhost:9090
# messaging - the embedded broker and its queue
spring.artemis.mode=embedded
spring.artemis.embedded.queues=orders.placed
order-service: OrderController.java
//OrderController.java
package com.javabrahman.cloud.comm.order;
import com.javabrahman.cloud.comm.grpc.PriceReply;
import com.javabrahman.cloud.comm.grpc.PriceRequest;
import com.javabrahman.cloud.comm.grpc.PriceServiceGrpc;
import io.grpc.StatusRuntimeException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.jms.core.JmsTemplate;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.ResourceAccessException;
@RestController
public class OrderController {
private final CatalogClient catalogClient;
private final PriceServiceGrpc.PriceServiceBlockingStub priceStub;
private final JmsTemplate jmsTemplate;
private final AtomicInteger orderIds = new AtomicInteger(1000);
OrderController(CatalogClient catalogClient, PriceServiceGrpc.PriceServiceBlockingStub priceStub,
JmsTemplate jmsTemplate) {
this.catalogClient = catalogClient;
this.priceStub = priceStub;
this.jmsTemplate = jmsTemplate;
}
// 1. REST: a synchronous call with JSON
@GetMapping("/rest/price/{isbn}")
Map<String, Object> priceOverRest(@PathVariable String isbn) {
CatalogClient.Book book = catalogClient.book(isbn);
return Map.of("via", "REST", "title", book.title(), "price", book.price());
}
// 2. gRPC: a synchronous call with protobuf - it reads like a local method call
@GetMapping("/grpc/price/{isbn}")
Map<String, Object> priceOverGrpc(@PathVariable String isbn) {
PriceReply reply = priceStub.getPrice(PriceRequest.newBuilder().setIsbn(isbn).build());
return Map.of("via", "gRPC", "title", reply.getTitle(), "price", reply.getPrice());
}
// 2b. gRPC server streaming: one request, several replies
@GetMapping("/grpc/watch/{isbn}")
Map<String, Object> watchOverGrpc(@PathVariable String isbn) {
List<Integer> prices = new ArrayList<>();
priceStub.watchPrice(PriceRequest.newBuilder().setIsbn(isbn).build())
.forEachRemaining(reply -> prices.add(reply.getPrice()));
return Map.of("via", "gRPC stream", "prices", prices);
}
// 3. Messaging: send and return at once - nobody is waited for
@PostMapping("/orders/{isbn}")
@ResponseStatus(HttpStatus.ACCEPTED)
Map<String, Object> placeOrder(@PathVariable String isbn) {
int orderId = orderIds.incrementAndGet();
jmsTemplate.convertAndSend("orders.placed", "order " + orderId + " for book " + isbn);
return Map.of("via", "message", "orderId", orderId, "status", "accepted");
}
// a synchronous call that cannot reach the other service
@ExceptionHandler({ResourceAccessException.class, StatusRuntimeException.class})
ResponseEntity<Map<String, String>> catalogDown(Exception e) {
String reason = e instanceof StatusRuntimeException grpc ? "gRPC status " + grpc.getStatus().getCode()
: "I/O error on HTTP request";
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(Map.of("error", "catalog-service cannot be reached", "reason", reason));
}
}
Running the three styles
Both services are started. The four endpoints are called; then the catalog service is stopped, the same calls are made again, and the catalog service is started again - OUTPUT of the three styles, with the catalog service up and then down> curl -i http://localhost:8080/rest/price/978-1
HTTP/1.1 200
Content-Length: 51
Content-Type: application/json
{"price":450,"title":"Effective Java","via":"REST"}
> curl -i http://localhost:8080/grpc/price/978-1
HTTP/1.1 200
Content-Length: 51
Content-Type: application/json
{"price":450,"title":"Effective Java","via":"gRPC"}
> curl -i http://localhost:8080/grpc/watch/978-1
HTTP/1.1 200
Content-Length: 44
Content-Type: application/json
{"via":"gRPC stream","prices":[450,430,399]}
> curl -i -X POST http://localhost:8080/orders/978-1
HTTP/1.1 202
Content-Length: 52
Content-Type: application/json
{"orderId":1001,"status":"accepted","via":"message"}
# the catalog service is stopped
> curl -i http://localhost:8080/rest/price/978-1
HTTP/1.1 503
Content-Type: application/json
{"error":"catalog-service cannot be reached","reason":"I/O error on HTTP request"}
> curl -i http://localhost:8080/grpc/price/978-1
HTTP/1.1 503
Content-Type: application/json
{"error":"catalog-service cannot be reached","reason":"gRPC status UNAVAILABLE"}
> curl -i -X POST http://localhost:8080/orders/978-1
HTTP/1.1 202
Content-Length: 52
Content-Type: application/json
{"orderId":1002,"status":"accepted","via":"message"}
# the catalog service is started again
2026-11-10T10:54:34.798+05:30 INFO 21220 --- [catalog-service] [ntContainer#0-2] c.j.cloud.comm.catalog.StockListener : Reserving stock for [order 1001 for book 978-1] 2026-11-10T10:54:51.288+05:30 INFO 18004 --- [catalog-service] [ntContainer#0-1] c.j.cloud.comm.catalog.StockListener : Reserving stock for [order 1002 for book 978-1]
- With both services up, REST and gRPC return the same price. From outside the two calls cannot be told apart; the difference is on the wire between the services, JSON text in one case and a binary protobuf message in the other.
/grpc/watchmade one gRPC call and received three replies,450,430and399, which the server pushed one after the other on the same call.- The first
POST /ordersreturned202 Acceptedwith order1001as soon as the message was on the queue. The first line of the catalog service's log shows the listener taking that message. - With the catalog service stopped, both synchronous calls failed.
RestClientthrew aResourceAccessExceptionand the gRPC stub aStatusRuntimeExceptionwith the statusUNAVAILABLE; the exception handler turned both into503. - The second
POST /ordersstill returned202, with order1002. Nobody was listening, and the order service neither knew nor cared. The broker kept the message. - The second line of the log, with a different process id and a later time, is the restarted catalog service receiving order
1002. The work was delayed, not lost.
The three compared
- REST - the widest reach and the least machinery. Text on the wire is larger and slower to parse than binary, and the contract is a matter of discipline, or of an OpenAPI document. Use it for anything called from browsers, partners or other teams, and as the default between services.
- gRPC - compact binary messages, one long-lived HTTP/2 connection for many calls, generated code on both sides and streaming. It costs a build step and tooling, a browser cannot call it directly, and a request cannot be read or sent by hand without special tools. Use it for internal calls that are frequent or need streaming, and between teams that want the contract enforced by the compiler.
- Messaging - the services need not be up together, a burst of work is absorbed by the queue, and new receivers can be added without touching the sender. The price is a broker to run, no immediate answer, and results that arrive later, so the system is eventually consistent. A receiver must also cope with the same message arriving twice. Use it for events - "order placed", "payment received" - and for work that can wait.
How to choose
- Does the caller need the answer to continue? If not, send a message.
- Is the caller outside your system - a browser, a mobile application, a partner? Use REST.
- Is it an internal call made very often, or does it need a stream of results? Consider gRPC.
- Otherwise use REST. It is the style that everybody on the team can debug.
Summary
In this tutorial the order service reached the catalog service in three ways. REST with an HTTP interface client needed only an interface and a URL. gRPC needed a.proto contract and generated code, and gave typed calls and a stream of replies in return. Messaging with JMS let the order service accept an order while the catalog service was down, and the catalog service caught up when it came back. The two synchronous styles failed at once in that situation. Messaging solves availability but leaves a new problem: when one business operation is spread over several services and several messages, there is no single transaction to roll back. The next tutorial deals with exactly that: the saga patternClick to read tutorial: Saga Pattern for Distributed Transactions.Spring Cloud and Microservices Tutorials
Distributed Tracing with Micrometer and OpenTelemetryClick to read tutorial: Distributed Tracing with Micrometer and OpenTelemetry Saga Pattern for Distributed TransactionsClick to read tutorial: Saga Pattern for Distributed Transactions Microservices with Spring: When and WhyClick to read tutorial: Microservices with Spring: When and Why Centralized Configuration with Spring Cloud ConfigClick to read tutorial: Centralized Configuration with Spring Cloud Config Service Discovery: Eureka vs KubernetesClick to read tutorial: Service Discovery: Eureka vs Kubernetes API Gateway with Spring Cloud GatewayClick to read tutorial: API Gateway with Spring Cloud Gateway Circuit Breakers and Retries with Resilience4jClick to read tutorial: Circuit Breakers and Retries with Resilience4j
Distributed Tracing with Micrometer and OpenTelemetryClick to read tutorial: Distributed Tracing with Micrometer and OpenTelemetry Saga Pattern for Distributed TransactionsClick to read tutorial: Saga Pattern for Distributed Transactions Microservices with Spring: When and WhyClick to read tutorial: Microservices with Spring: When and Why Centralized Configuration with Spring Cloud ConfigClick to read tutorial: Centralized Configuration with Spring Cloud Config Service Discovery: Eureka vs KubernetesClick to read tutorial: Service Discovery: Eureka vs Kubernetes API Gateway with Spring Cloud GatewayClick to read tutorial: API Gateway with Spring Cloud Gateway Circuit Breakers and Retries with Resilience4jClick to read tutorial: Circuit Breakers and Retries with Resilience4j