Skip to content
  • architecture
  • grpc
  • rest
  • microservices
  • java

Best of Both Worlds: Combining REST and gRPC in Microservices

A request lands at the gateway: fetch an order, filter a month of transactions, cancel a failed payment. From the outside it is a plain HTTP call with a JSON body, the kind a mobile app or a partner’s backend makes without thinking about it. Inside, that one call turns into a handful of gRPC round-trips to the services that actually own the data. The client never sees any of it.

That split is the whole idea, and I want to talk about why it is worth the extra moving parts, because most “REST vs gRPC” writing treats it as a decision you make once and live with. In a microservices platform with a dozen or so services handling orders, inventory, payments, and notifications, it was never one or the other. REST and gRPC are good at different jobs, and the trick is putting each where it belongs.

Two sets of needs that don’t overlap

The thing forcing the design is that the edge and the interior want opposite properties.

At the edge you have browsers, mobile apps, and third-party integrators. They want something they can hit with curl, document with Swagger, and debug by reading the request in a proxy log. They do not want to compile a .proto file to talk to you, and half of them can’t speak HTTP/2 framing cleanly through their stack anyway. For the edge, REST over JSON is not a compromise. It is the correct answer.

Inside, none of that matters. No browser is calling the order service directly; the only callers are other services I control. What I want there is speed, because a single external request can fan out into several internal ones and the latency stacks up, and I want the compiler to catch a mismatched field before it ships, not a customer three days later when a report doesn’t add up. That is gRPC’s whole pitch: binary payloads over persistent HTTP/2 connections, and a contract that fails the build the moment two services disagree about a message.

No single protocol gives you both. So don’t ask it to.

RESTgRPC
SerializationJSON (text)Protobuf (binary)
ContractOpenAPI/Swagger (optional).proto files (required)
Toolingcurl, Postman, browserRequires generated clients
CachingNative HTTP cachingManual implementation
Browser supportNativeRequires grpc-web proxy
Type safetyRuntime validationCompile-time checks
StreamingWorkarounds (SSE, WebSocket)Native bidirectional

REST wins at the edge. gRPC wins inside. Use both.

The shape: REST outside, gRPC inside

REST outside, gRPC inside architecture

One gateway is the only thing the outside world reaches. It speaks REST to clients and gRPC to everything behind it. Everything past the gateway is a private mesh: services talking to services, never to the public. The gateway’s whole job is translation and the cross-cutting concerns that belong at a boundary, authentication, rate limiting, routing, and nothing more. I’ll come back to that “nothing more” because it is the rule that keeps the pattern from rotting.

The contract comes first

Everything downstream of the gateway starts as a .proto file. It defines the messages on the wire and the operations a service exposes, and it is the single source of truth both sides generate code from.

// order.proto
syntax = "proto3";

option java_multiple_files = true;
option java_package = "com.example.grpc.order";

package order;

service OrderService {
  rpc GetOrder (OrderId) returns (OrderResponse);
  rpc CreateOrder (CreateOrderRequest) returns (OrderResponse);
  rpc ListOrders (ListOrdersRequest) returns (OrderListResponse);
  rpc CancelOrder (CancelOrderRequest) returns (OrderResponse);
}

message OrderId {
  string id = 1;
}

message OrderResponse {
  string id = 1;
  string customerId = 2;
  string total = 3;
  OrderStatus status = 4;
  string createdAt = 5;
  repeated LineItem items = 6;
}

message LineItem {
  string productId = 1;
  int32 quantity = 2;
  string unitPrice = 3;
}

enum OrderStatus {
  PENDING = 0;
  CONFIRMED = 1;
  SHIPPED = 2;
  DELIVERED = 3;
  CANCELLED = 4;
}

message CreateOrderRequest {
  string customerId = 1;
  repeated LineItem items = 2;
}

message ListOrdersRequest {
  string customerId = 1;
  int32 page = 2;
  int32 pageSize = 3;
}

message OrderListResponse {
  repeated OrderResponse orders = 1;
  int32 totalCount = 2;
}

message CancelOrderRequest {
  string orderId = 1;
  string reason = 2;
}

The payoff is that nothing about this shape lives in anyone’s head. Change a field number, rename a message, and every service that references it stops compiling until it is fixed. That is the opposite of a JSON contract, where a renamed field is a runtime surprise that surfaces as a null two services deep.

One detail worth pausing on: total and unitPrice are strings, not floats. Protobuf has no decimal type. Its numeric types are ints and floats, and floats near money are a bug waiting for a rounding error. So money travels as a string and gets parsed into a BigDecimal on each side. It reads as a hack the first time you see it. It is actually the careful choice.

The gateway’s gRPC client

The gateway reaches each backend service through a generated stub, wrapped in a thin client class so the controllers never touch gRPC types directly. Here is a simplified version; we will add production hardening shortly:

public class OrderServiceClient {

    private final OrderServiceGrpc.OrderServiceBlockingStub stub;

    public OrderServiceClient(ManagedChannel channel) {
        this.stub = OrderServiceGrpc.newBlockingStub(channel);
    }

    public OrderResponse getOrder(String orderId) {
        return stub.getOrder(
            OrderId.newBuilder()
                .setId(orderId)
                .build()
        );
    }

    public OrderResponse createOrder(String customerId, List<LineItemRequest> items) {
        var builder = CreateOrderRequest.newBuilder()
            .setCustomerId(customerId);

        for (var item : items) {
            builder.addItems(LineItem.newBuilder()
                .setProductId(item.productId())
                .setQuantity(item.quantity())
                .setUnitPrice(item.price().toPlainString())
                .build());
        }

        return stub.createOrder(builder.build());
    }

    public OrderListResponse listOrders(String customerId, int page, int size) {
        return stub.listOrders(
            ListOrdersRequest.newBuilder()
                .setCustomerId(customerId)
                .setPage(page)
                .setPageSize(size)
                .build()
        );
    }
}

OrderServiceGrpc is generated straight from the proto; I never wrote it. The ManagedChannel holds a long-lived HTTP/2 connection to the order service, so calls reuse the same pipe instead of paying TCP and TLS setup every time. The toPlainString() call is where a BigDecimal becomes the string the proto expects, right at the boundary.

The word that matters in there is BlockingStub. It does what it says: the calling thread stops until the order service answers. That is fine, and it is also the thing that will hurt you later, so hold onto it.

The controller is a translator, and only that

At the edge, the controller exposes ordinary REST and hands straight off to the client:

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

    private final OrderServiceClient orderService;

    public OrderController(OrderServiceClient orderService) {
        this.orderService = orderService;
    }

    @GetMapping("/{orderId}")
    public ApiResponse<OrderResponse> getOrder(@PathVariable String orderId) {
        return ApiResponse.success(orderService.getOrder(orderId));
    }

    @PostMapping
    public ApiResponse<OrderResponse> createOrder(@RequestBody CreateOrderDto request) {
        return ApiResponse.success(
            orderService.createOrder(request.customerId(), request.items())
        );
    }

    @GetMapping
    public ApiResponse<OrderListResponse> listOrders(
            @RequestParam String customerId,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size) {
        return ApiResponse.success(
            orderService.listOrders(customerId, page, size)
        );
    }
}

Read the controller and notice what is not there. No pricing logic, no inventory checks, no payment processing. It accepts an HTTP request, calls the gRPC client, and wraps the reply. With the right Jackson configuration (the protobuf-java-util module or a custom serializer), the proto types serialize to JSON automatically, so there is no DTO-mapping layer to maintain.

That emptiness is deliberate, and it is the one rule I would enforce hardest: business logic lives in the services, never in the gateway. The moment someone calculates a discount or checks inventory in a controller, the gateway stops being a translator and becomes another service with none of the guardrails. Once logic is split across the boundary, no one can reason about where a decision actually gets made. Keep the gateway thin and it stays a thing you can delete and rebuild in an afternoon.

The part the tutorials skip

Everything above is the happy path, and the happy path is the easy 80%. Here is what actually bit once this was running in front of real traffic.

gRPC failures don’t speak HTTP

When the order service can’t find an order, it doesn’t return a 404. It throws a StatusRuntimeException carrying a gRPC status code: NOT_FOUND, UNAVAILABLE, DEADLINE_EXCEEDED, a different vocabulary from HTTP entirely. If you do nothing, that exception bubbles up through the controller and your caller gets a 500 for what was really a missing record or a service that was briefly down. Every client now sees “server error” for a dozen different situations, and their retry logic can’t tell a real fault from a temporary hiccup.

So the gateway needs one place that maps gRPC status codes onto HTTP: NOT_FOUND to 404, INVALID_ARGUMENT to 400, UNAVAILABLE to 503, and so on:

@RestControllerAdvice
public class GrpcExceptionHandler {

    @ExceptionHandler(StatusRuntimeException.class)
    public ResponseEntity<ApiError> handleGrpcException(StatusRuntimeException ex) {
        return switch (ex.getStatus().getCode()) {
            case NOT_FOUND -> ResponseEntity.status(404)
                .body(new ApiError("Not found", ex.getStatus().getDescription()));
            case INVALID_ARGUMENT -> ResponseEntity.status(400)
                .body(new ApiError("Bad request", ex.getStatus().getDescription()));
            case UNAUTHENTICATED -> ResponseEntity.status(401)
                .body(new ApiError("Unauthorized", "Authentication required"));
            case PERMISSION_DENIED -> ResponseEntity.status(403)
                .body(new ApiError("Forbidden", "Access denied"));
            case UNAVAILABLE -> ResponseEntity.status(503)
                .body(new ApiError("Service unavailable", "Please retry"));
            case DEADLINE_EXCEEDED -> ResponseEntity.status(504)
                .body(new ApiError("Timeout", "Request took too long"));
            default -> ResponseEntity.status(500)
                .body(new ApiError("Internal error", "Something went wrong"));
        };
    }
}

It is unglamorous plumbing and it is not optional. The translation the gateway performs is not just JSON to protobuf; it is one error model to another. Skip it and you have quietly flattened every failure your services can express down to a single meaningless 500.

A blocking stub with no deadline waits forever

Back to that BlockingStub. It parks the thread until a reply comes. Now suppose the order service is wedged: GC pause, a slow query, a downstream of its own hanging. Without a deadline, the stub waits. Not thirty seconds; as long as it takes. Requests stack up behind it, each holding a thread, and because the gateway is fielding real user traffic on a bounded pool, a pool of threads all blocked on one sick service is how a single slow dependency takes the whole gateway down with it.

Every outbound gRPC call needs a deadline:

public OrderResponse getOrder(String orderId) {
    return stub
        .withDeadlineAfter(3, TimeUnit.SECONDS)
        .getOrder(OrderId.newBuilder().setId(orderId).build());
}

Set it, and a stuck dependency fails fast with DEADLINE_EXCEEDED, which your exception handler turns into a clean 504 and frees the thread. Don’t, and you have built a system that is exactly as available as its slowest service on its worst day.

The proto library is the payoff and the coupling

A single shared library holds every .proto, and every service depends on it:

shared-protos/
├── common.proto          # PageRequest, Status, etc.
├── product/product.proto
├── user/user.proto
├── search/search.proto
└── shipping/shipping.proto

Common types, the ones every service touches, live in common.proto:

// common.proto
syntax = "proto3";

package common;

message PageRequest {
  int32 page = 1;
  int32 page_size = 2;
}

message Money {
  string amount = 1;
  string currency = 2;
}

enum Status {
  UNKNOWN = 0;
  ACTIVE = 1;
  INACTIVE = 2;
}

This is genuinely good. Nobody copies an enum between codebases and lets the two drift; there is one Status and everyone imports it. But be honest about the other edge: one library that everyone depends on is one place everyone is coupled. Touch common.proto and every service now has a reason to rebuild against the new version. Do it carelessly, renumber a field, reuse a retired tag, and you can break the whole mesh from a single commit.

Protobuf gives you the tools to avoid that, and you have to actually use them. Field numbers are the contract, not field names, so never reuse or renumber a tag; add new fields with new numbers and leave the old ones alone. Reserve the numbers of fields you delete so nobody reclaims them by accident. Treated that way the library is a superpower. Treated casually it is a single point of failure with company-wide blast radius.

When this is worth it

The pattern earns its complexity when the interior is busy and the edge is public. If services call each other constantly, and one external request routinely fans out into several internal ones, then the binary payloads, the reused connections, and the compile-time contracts pay for themselves many times a second. An order placement is exactly this: it touches inventory, payments, notifications, and fulfillment, and every hop is internal.

It is also worth it the moment more than one team is involved. The proto file is a contract two teams can’t quietly violate; it is far better than a wiki page describing a JSON shape that went stale months ago.

And it is worth nothing at all if you have one service. A monolith talking to itself over gRPC is ceremony for its own sake, and if you are handling a hundred requests a minute, JSON’s overhead is noise you will never measure. This is a pattern for a mesh under real load with a public edge to protect, not a default to reach for on day one. If you are prototyping, plain REST everywhere will let you move faster, and you can introduce the gateway the day the fan-out actually starts to hurt.

Getting started - here’s what I would do

If you’re adopting this pattern, start small and expand.

Week one: one service, one proto. Pick a single internal service. Define its contract in a .proto file, generate the stubs, and call it from your gateway. Get the build pipeline working. This is where you learn the tooling without betting the whole system on it.

Week two: add the error mapping. Build the exception handler that translates gRPC status codes to HTTP. Test it by deliberately triggering each error type. This is unglamorous but critical - skip it and you’ll debug mysterious 500s for months.

Week three: add deadlines everywhere. Go back through every gRPC call and add withDeadlineAfter. Three seconds is a reasonable default. Watch your logs for DEADLINE_EXCEEDED to find services that need optimization.

Then expand. Add more services to gRPC, one at a time. Keep REST at the edge. Resist the urge to expose gRPC directly to clients - the browser compatibility problems aren’t worth it.

The gRPC Java documentation covers the fundamentals. The Protocol Buffers style guide is worth reading before you define your first message.

The whole point, in one line

REST and gRPC were never competing for the same job. REST is the right verb at the edge, where reach and familiarity win. gRPC is the right one inside, where speed and a contract the compiler enforces win. The gateway is just the seam where one becomes the other, and getting the seam right, the error mapping, the deadlines, the discipline about what the library and the gateway are each allowed to hold, is most of the work. Do that, and the client keeps making the simple HTTP call it always made, while everything behind it runs on the faster, stricter wire it never has to know about.

Isaac Olanrewaju is a backend engineer in Lagos, Nigeria, building payment systems, transaction-heavy services, and financial infrastructure for fintechs, banks, and product teams.