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. Two ways for the order service to reach the catalog service - on top a synchronous call, REST or gRPC, where the order service sends a request and waits for the response and fails when the catalog service is down; below an asynchronous message, where the order service puts a message on a queue in a broker and continues, and the catalog service takes it from the queue when it is running
  • 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.
A question needs a synchronous call. An announcement is better sent as a message. Most systems use both.

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);
  }
}
The order service calls it through an HTTP interface clientClick to read tutorial: Calling REST Services with RestClient and HTTP Interfaces: an interface describes the remote endpoint and Spring generates the implementation -
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);
}
Note what holds the two sides together: a URL, and field names in JSON, written once in each service. Nothing checks that they match. That is the weakness of REST between services, and also its strength - any language, any tool and any person with 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 service lists remote methods; a message is 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 protoc compiler on this file and generates Java code: the classes PriceRequest and PriceReply, 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 with protobuf-maven-plugin and the protoc-gen-grpc-java plugin.
  • WatchPrice returns a stream: one request, many replies over time. gRPC runs on HTTP/2 and has streaming in both directions built in.
Spring Boot has starters for both sides -
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>
On the server, the service is a Spring bean that extends the generated base class. Spring Boot finds it and starts a gRPC server for it -
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();
    }
  }
}
A method does not return its result. It pushes results into a 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 named orders.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 broker here is ActiveMQ Artemis with the JMS API. In a real system the broker is a server of its own, and it must be, since its whole purpose is to be available when the services are not. To keep this example to two processes, the broker is embedded in the order service and opened for connections on port 61616. Everything else is as it would be with a separate broker.

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
A controller offers one endpoint per style, so that each can be tried with a plain HTTP request -
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
And the lines that the listener wrote to the log of the catalog service, over both of its lives -  OUTPUT in the log of the catalog service
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]
Explanation of the output
  • 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/watch made one gRPC call and received three replies, 450, 430 and 399, which the server pushed one after the other on the same call.
  • The first POST /orders returned 202 Accepted with order 1001 as 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. RestClient threw a ResourceAccessException and the gRPC stub a StatusRuntimeException with the status UNAVAILABLE; the exception handler turned both into 503.
  • The second POST /orders still returned 202, with order 1002. 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.
One more thing happened in that log which is not shown above. A listener that cannot reach the broker does not give up: Spring's listener container logs an error and tries to connect again every five seconds, until the broker is there.

The three compared

Comparison of REST, gRPC and messaging in three columns - REST is synchronous, JSON over HTTP, contract by convention or OpenAPI, readable and callable from anywhere, best for public APIs and simple calls; gRPC is synchronous with streaming, binary protobuf over HTTP/2, contract in a proto file with generated code, best for frequent internal calls; messaging is asynchronous through a broker, the receiver may be down, best for events and work that can wait
  • 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.
Whatever the style, a synchronous call needs the protection described in circuit breakers and retriesClick to read tutorial: Circuit Breakers and Retries with Resilience4j: a timeout, a limited retry and a fallback.

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.
One caution on chains. If service A calls B, which calls C, which calls D, all synchronously, then one user request needs all four to be up and is as slow as the four together. Long synchronous chains are a sign that some of those questions should have been announcements.

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.