DEV Community

Cover image for REST vs. RPC in Java APIs: A Practical Guide to Choosing the Right Design Strategy
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

REST vs. RPC in Java APIs: A Practical Guide to Choosing the Right Design Strategy

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

When you build an API in Java, you're making a promise. You're creating an interface that other code, written by other people, will use to talk to your system. Over the years, I've seen many ways to structure this conversation. The debate often centers on two main ideas: RESTful design and RPC design. Each has its own strengths. Sometimes, the best solution is a mix of both.

Let's talk about what these words mean in practice, not just in theory. I'll show you code and explain the thinking behind it. You can then decide what fits your project.

Understanding the Core Difference

Think of it like this. A RESTful API is like a well-organized library. You have books (resources) on shelves. You can look at a specific book (GET), add a new book (POST), update a book's details (PUT/PATCH), or remove a book (DELETE). The focus is on the things—the nouns, like orders, users, or products.

An RPC-style API is more like calling a function or a method. You're asking the system to do something—a verb. You might call placeOrder(), calculateShipping(), or cancelSubscription(). The focus is on the actions.

Neither is universally "better." Your choice depends on what your system does and who is using your API.

Strategy 1: The RESTful Library

For systems centered around managing data entities, the RESTful pattern feels natural. You define your resources clearly. Each resource gets its own unique address, a URL. The type of HTTP request you make tells the server what to do with that resource.

Here’s a simple example using Spring WebFlux. Imagine we’re building an API for an order system.

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

    // Get a specific order by its ID
    @GetMapping("/{id}")
    public Mono<ResponseEntity<OrderResponse>> getOrder(@PathVariable String id) {
        return orderService.findById(id)
            .map(order -> ResponseEntity.ok(toResponse(order)))
            .defaultIfEmpty(ResponseEntity.notFound().build());
    }

    // Create a new order
    @PostMapping
    public Mono<ResponseEntity<OrderResponse>> createOrder(
            @Valid @RequestBody CreateOrderRequest request) {
        return orderService.create(request)
            .map(order -> ResponseEntity
                .created(URI.create("/api/v1/orders/" + order.id()))
                .body(toResponse(order)));
    }

    // Update just the status of an order
    @PatchMapping("/{id}/status")
    public Mono<ResponseEntity<Void>> updateStatus(
            @PathVariable String id,
            @Valid @RequestBody UpdateStatusRequest request) {
        return orderService.updateStatus(id, request.status())
            .thenReturn(ResponseEntity.noContent().build());
    }

    // List orders, possibly with filters
    @GetMapping
    public Flux<OrderResponse> listOrders(
            @RequestParam Optional<String> customerId,
            @RequestParam Optional<OrderStatus> status,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size) {

        return orderService.findByCriteria(customerId, status, page, size)
            .map(this::toResponse);
    }
}
Enter fullscreen mode Exit fullscreen mode

This is clear and predictable. If you know an order's ID is 123, you know its URL is /api/v1/orders/123. A client developer can guess how to interact with it. The structure itself teaches them how to use the API.

You can make this even more helpful by including links. This is called HATEOAS, and it guides the client on what they can do next.

@GetMapping("/{id}")
public Mono<ResponseEntity<EntityModel<OrderResponse>>> getOrderWithLinks(
        @PathVariable String id,
        ServerWebExchange exchange) {

    return orderService.findById(id)
        .map(order -> {
            OrderResponse response = toResponse(order);
            EntityModel<OrderResponse> model = EntityModel.of(response);

            // The link to this exact data
            model.add(Link.of(exchange.getRequest().getURI().toString(), "self"));

            // Links to related data
            model.add(Link.of("/api/v1/customers/" + response.customerId(), "customer"));
            model.add(Link.of("/api/v1/orders/" + id + "/items", "items"));

            // Action links that depend on the order's state
            if (response.status() == OrderStatus.PENDING) {
                model.add(Link.of("/api/v1/orders/" + id + "/cancel", "cancel"));
                model.add(Link.of("/api/v1/orders/" + id + "/process", "process"));
            }

            return ResponseEntity.ok(model);
        })
        .defaultIfEmpty(ResponseEntity.notFound().build());
}
Enter fullscreen mode Exit fullscreen mode

Now the response doesn't just give data; it gives options. The client discovers that a PENDING order can be cancelled or processed, and it knows exactly which URLs to call. This reduces the need for separate documentation and makes your API more resilient to change.

Strategy 2: The RPC Toolbox

Sometimes, your operations don't fit neatly into CREATE, READ, UPDATE, DELETE. Your business processes are complex verbs. Trying to force them into RESTful resource updates can make your API awkward.

Consider an order again. "Placing an order" isn't just creating an Order resource. It might involve checking inventory, processing a payment, sending a confirmation email, and creating a shipment record. This is a specific command.

This is where an RPC-style shines. You design your API as a set of explicit operations.

@RestController
@RequestMapping("/api/rpc")
public class OrderRpcController {

    @PostMapping("/placeOrder")
    public Mono<PlaceOrderResult> placeOrder(@RequestBody PlaceOrderCommand command) {
        return orderService.placeOrder(
            command.customerId(),
            command.items(),
            command.paymentMethod()
        );
    }

    @PostMapping("/cancelOrder")
    public Mono<CancelOrderResult> cancelOrder(@RequestBody CancelOrderCommand command) {
        return orderService.cancelOrder(
            command.orderId(),
            command.reason()
        );
    }

    @PostMapping("/applyDiscount")
    public Mono<ApplyDiscountResult> applyDiscount(
            @RequestBody ApplyDiscountCommand command) {
        return orderService.applyDiscount(
            command.orderId(),
            command.discountCode(),
            command.campaignId()
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Each endpoint name is a clear instruction. The request and response objects are tailored to that specific operation.

public record PlaceOrderCommand(
    @NotBlank String customerId,
    @NotEmpty List<OrderItem> items,
    @NotNull PaymentMethod paymentMethod,
    @Valid ShippingAddress shippingAddress,
    String promotionCode
) {}

public record PlaceOrderResult(
    String orderId,
    OrderStatus status,
    BigDecimal total,
    BigDecimal tax,
    BigDecimal shipping,
    Instant estimatedDelivery,
    List<OrderWarning> warnings  // Specific to the "place" operation
) {}
Enter fullscreen mode Exit fullscreen mode

The result can include exactly what the client needs to know after placing an order: the ID, the final total, the delivery estimate, and any warnings (like "item is backordered").

RPC is excellent for batch operations, where you need to process many items efficiently in one network call.

@PostMapping("/batchProcess")
public Flux<OrderProcessResult> batchProcess(@RequestBody BatchProcessCommand command) {
    return Flux.fromIterable(command.orderIds())
        .parallel() // Process in parallel for speed
        .runOn(Schedulers.parallel())
        .flatMap(orderId -> orderService.process(orderId)
            .map(result -> new OrderProcessResult(orderId, result.status()))
            .onErrorResume(error -> Mono.just(
                new OrderProcessResult(orderId, ProcessStatus.FAILED, error.getMessage()))
            )
        )
        .sequential();
}
Enter fullscreen mode Exit fullscreen mode

The clarity is the main benefit. A client developer sees POST /api/rpc/placeOrder and understands the intent immediately. There's no need to figure out which resource to POST to or what the payload should look like for a "cancel" action on an order resource.

Strategy 3: The Hybrid Approach – The Best of Both Worlds

In many real projects, I find a hybrid approach to be the most practical. You use RESTful principles for basic data management and RPC-style endpoints for complex business actions.

You keep your clean, predictable resource URLs for standard operations. Then you add specific action endpoints as sub-paths of that resource.

@RestController
@RequestMapping("/api/hybrid/orders")
public class HybridOrderController {

    // Standard RESTful operations
    @GetMapping("/{id}")
    public Mono<OrderResponse> getOrder(@PathVariable String id) {
        return orderService.findById(id).map(this::toResponse);
    }

    @PostMapping
    public Mono<OrderResponse> createOrder(@RequestBody CreateOrderRequest request) {
        return orderService.create(request).map(this::toResponse);
    }

    // RPC-style actions attached to the order resource
    @PostMapping("/{id}/actions/cancel")
    public Mono<CancelResult> cancelOrder(
            @PathVariable String id,
            @RequestBody CancelRequest request) {
        return orderService.cancel(id, request.reason());
    }

    @PostMapping("/{id}/actions/ship")
    public Mono<ShippingResult> shipOrder(
            @PathVariable String id,
            @RequestBody ShippingRequest request) {
        return orderService.ship(
            id,
            request.carrier(),
            request.trackingNumber(),
            request.shippedAt()
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

This gives you structure and clarity. The base /orders/{id} path is your resource. The /actions/cancel path is your specific command for that resource. It's intuitive. You can also add operations that don't fit CRUD at all, like search or analytics.

@PostMapping("/search")  // Not a GET because criteria can be complex
public Flux<OrderResponse> searchOrders(@RequestBody OrderSearchCriteria criteria) {
    return orderService.search(criteria).map(this::toResponse);
}

@PostMapping("/analytics/summary")
public Mono<OrderSummary> getSummary(@RequestBody SummaryRequest request) {
    return orderService.generateSummary(
        request.startDate(),
        request.endDate(),
        request.groupBy()
    );
}
Enter fullscreen mode Exit fullscreen mode

This hybrid model is what I use most often. It provides a sensible default structure but allows for the necessary complexity of real-world business logic.

Strategy 4: Planning for Change with Versioning

Your API will change. Requirements evolve. You learn better ways to structure data. If you have external clients, you can't just change things and break their code. You need a plan for versioning.

There are a few common ways to do this. The simplest for clients is to put the version in the URL.

@RestController
@RequestMapping("/api/v1/orders")
public class OrderControllerV1 {
    // Original implementation
}

@RestController
@RequestMapping("/api/v2/orders")
public class OrderControllerV2 {
    // New and improved version, possibly with breaking changes
    @GetMapping("/{id}")
    public Mono<OrderResponseV2> getOrder(@PathVariable String id) {
        return orderService.findById(id)
            .map(this::toV2Response); // Returns a new response format
    }
}
Enter fullscreen mode Exit fullscreen mode

The client explicitly chooses v1 or v2. It's very clear. Another method uses HTTP headers, like Accept or a custom header. This keeps URLs clean.

@GetMapping(value = "/{id}", 
            produces = {
                "application/vnd.company.order-v1+json",
                "application/json"  // Default to v1
            })
public Mono<OrderResponseV1> getOrderV1(@PathVariable String id) {
    return orderService.findById(id).map(this::toV1Response);
}

@GetMapping(value = "/{id}", 
            produces = "application/vnd.company.order-v2+json")
public Mono<OrderResponseV2> getOrderV2(@PathVariable String id) {
    return orderService.findById(id).map(this::toV2Response);
}
Enter fullscreen mode Exit fullscreen mode

You can also use a custom filter to read a version header and route the request appropriately.

@Component
public class ApiVersionFilter implements WebFilter {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        String version = exchange.getRequest().getHeaders()
            .getFirst("X-API-Version");

        if (version != null) {
            exchange.getAttributes().put("api.version", version);
        }
        return chain.filter(exchange);
    }
}
Enter fullscreen mode Exit fullscreen mode

Then, inside your service, you can check this attribute and format the response accordingly. The key is to pick a strategy early and stick to it. My advice: if your API is public and used by many different clients, URL versioning is the most straightforward for them.

Strategy 5: Optimizing for Performance

The style of your API influences how you optimize it.

RESTful APIs benefit heavily from HTTP's built-in features. You can use caching headers to tell clients (and intermediate proxies) how long they can store responses.

@GetMapping("/{id}")
public Mono<ResponseEntity<OrderResponse>> getOrderWithCaching(
        @PathVariable String id,
        ServerWebExchange exchange) {

    return orderService.findById(id)
        .flatMap(order -> {
            String etag = calculateETag(order); // A hash of the content

            // Client sends this header if it has a cached copy
            String ifNoneMatch = exchange.getRequest().getHeaders()
                .getFirst(HttpHeaders.IF_NONE_MATCH);

            // If the ETag matches, the data hasn't changed
            if (etag.equals(ifNoneMatch)) {
                return Mono.just(ResponseEntity
                    .status(HttpStatus.NOT_MODIFIED) // 304
                    .build());
            }

            // Send fresh data with caching instructions
            return Mono.just(ResponseEntity.ok()
                .header(HttpHeaders.ETAG, etag)
                .header(HttpHeaders.CACHE_CONTROL, "public, max-age=300") // 5 minutes
                .body(toResponse(order)));
        });
}
Enter fullscreen mode Exit fullscreen mode

This saves bandwidth and server load. For RPC-style calls, especially those that call other internal services, connection pooling is critical for performance.

@Service
public class OrderRpcService {
    private final WebClient webClient;

    public OrderRpcService() {
        ConnectionProvider provider = ConnectionProvider.builder("rpc-pool")
            .maxConnections(100)
            .maxIdleTime(Duration.ofMinutes(5))
            .build();

        this.webClient = WebClient.builder()
            .clientConnector(new ReactorClientHttpConnector(
                HttpClient.create(provider)
            ))
            .baseUrl("http://inventory-service:8080")
            .build();
    }
}
Enter fullscreen mode Exit fullscreen mode

Reusing connections avoids the overhead of setting up a new TCP handshake for every request. For APIs that return large lists of data, pagination is non-negotiable.

@GetMapping
public Mono<PageResponse<OrderResponse>> listOrders(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "50") int size) {

    return orderService.findPage(page, size)
        .map(pageResult -> new PageResponse<>(
            pageResult.content().map(this::toResponse).toList(),
            pageResult.page(),
            pageResult.size(),
            pageResult.totalElements(),
            pageResult.totalPages()
        ));
}
Enter fullscreen mode Exit fullscreen mode

And for real-time data feeds, you can use streaming.

@GetMapping(value = "/stream", produces = MediaType.APPLICATION_NDJSON_VALUE)
public Flux<OrderResponse> streamOrders(@RequestParam Optional<Instant> since) {
    return orderService.streamSince(since)
        .map(this::toResponse);
}
Enter fullscreen mode Exit fullscreen mode

This sends data as it becomes available, in a format called NDJSON (Newline-Delimited JSON), which clients can process incrementally.

Putting It All Together

So, how do you choose? From my experience, start by asking questions.

Is your domain mostly about managing data entities (like a content management system)? A RESTful approach will feel natural.
Is your domain defined by complex processes and commands (like a payment processing system)? Look at an RPC style.
Do you need both? Most of us do. The hybrid model is a very safe and effective starting point.

Think about your clients. Are they web browsers that understand HTTP caching well? RESTful with good headers helps. Are they other backend services that need to perform specific tasks? Clear RPC endpoints might be easier for them to integrate.

Finally, don't get trapped by dogma. The "right" design is the one that makes your system reliable, understandable, and easy to change. Java's ecosystem, with frameworks like Spring, gives you the tools to implement any of these patterns cleanly. Use that flexibility to build an API that serves your users, not one that just follows a rule.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)