Home Java Parallel REST Calls in Spring with CompletableFuture
Advanced 5 min · July 14, 2026

Parallel REST Calls in Spring with CompletableFuture

Learn to make multiple REST calls in parallel using CompletableFuture in Spring Boot.

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.

Follow
Production
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
Before you start⏱ 15-20 min read
  • Basic knowledge of Spring Boot and REST APIs
  • Familiarity with Java 8 lambdas and functional interfaces
  • Understanding of thread pools and concurrency
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Use CompletableFuture to make non-blocking parallel REST calls.
  • Combine async results using allOf() or join().
  • Configure a custom thread pool to avoid saturating the common ForkJoinPool.
  • Handle exceptions per future with exceptionally() or handle().
  • Use Spring's @Async for cleaner code, but watch out for proxy limitations.
✦ Definition~90s read
What is Making Multiple REST Calls in Parallel with CompletableFuture in Spring?

CompletableFuture is a Java class that enables asynchronous, non-blocking programming by allowing you to run tasks in parallel and combine their results when all complete.

Imagine you need to ask three different experts for information.
Plain-English First

Imagine you need to ask three different experts for information. Instead of asking one, waiting for the answer, then asking the next, you send emails to all three at once and wait for all replies. CompletableFuture in Java works like sending those emails – your program can continue doing other things while waiting for the answers, then combine them when all arrive.

You're building a dashboard that aggregates data from three microservices: user details from the User Service, order history from the Order Service, and payment status from the Payment Service. If you call them sequentially, your response time is the sum of all three – easily 600ms if each takes 200ms. But if you call them in parallel, the total time drops to roughly the slowest service – maybe 250ms.

That's the promise of parallel REST calls. But in production, naive parallelism can bring your application to its knees. I've seen a fintech startup's Spring Boot app crash on Black Friday because they used the default ForkJoinPool for all async calls – the thread pool got saturated, and the app became unresponsive.

In this article, I'll show you how to make multiple REST calls in parallel using CompletableFuture in Spring Boot – the right way. We'll cover thread pool configuration, error handling, and debugging strategies that I've learned from hard-won production experience. By the end, you'll be able to slash your API response times without sacrificing stability.

Why Sequential REST Calls Hurt Performance

Let's start with a concrete example. You have a Spring Boot service that needs to fetch user data, order history, and payment status for a dashboard. If you call these services one after another, the total response time is the sum of all three calls. If each takes 200ms, that's 600ms. Users hate waiting more than 500ms for a page load.

``java public Dashboard getDashboard(String userId) { User user = restTemplate.getForObject("http://user-service/users/" + userId, User.class); List<Order> orders = restTemplate.getForObject("http://order-service/orders?userId=" + userId, List.class); Payment payment = restTemplate.getForObject("http://payment-service/payments?userId=" + userId, Payment.class); return new Dashboard(user, orders, payment); } ``

This is simple but slow. In production, you'll see response times that are the sum of each call's latency. Worse, if one service is slow (e.g., 5 seconds), the whole request takes 5+ seconds. Users will abandon the page.

The fix is to make these calls in parallel. But as we'll see, doing it right requires more than just wrapping each call in a CompletableFuture.

SequentialDashboardService.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class SequentialDashboardService {
    private final RestTemplate restTemplate;

    public Dashboard getDashboard(String userId) {
        long start = System.currentTimeMillis();
        User user = restTemplate.getForObject("http://user-service/users/" + userId, User.class);
        List<Order> orders = restTemplate.getForObject(
            "http://order-service/orders?userId=" + userId, 
            new ParameterizedTypeReference<List<Order>>() {}.getType());
        Payment payment = restTemplate.getForObject(
            "http://payment-service/payments?userId=" + userId, Payment.class);
        System.out.println("Total time: " + (System.currentTimeMillis() - start) + "ms");
        return new Dashboard(user, orders, payment);
    }
}
Output
Total time: 612ms (assuming each call takes ~200ms)
⚠ RestTemplate is deprecated in Spring 5+
📊 Production Insight
In production, always add a timeout to your REST calls. A misbehaving downstream service can block your threads indefinitely. Use RestTemplate's setConnectTimeout and setReadTimeout, or WebClient's timeout configuration.
🎯 Key Takeaway
Sequential calls add latency linearly. Parallel calls reduce total time to the slowest call.

Parallel REST Calls with CompletableFuture

CompletableFuture is Java's answer to asynchronous programming. It lets you run tasks in parallel and combine their results when all are done. Here's how to refactor the sequential code to parallel:

```java public Dashboard getDashboard(String userId) { CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> restTemplate.getForObject("http://user-service/users/" + userId, User.class)); CompletableFuture<List<Order>> ordersFuture = CompletableFuture.supplyAsync(() -> restTemplate.getForObject("http://order-service/orders?userId=" + userId, List.class)); CompletableFuture<Payment> paymentFuture = CompletableFuture.supplyAsync(() -> restTemplate.getForObject("http://payment-service/payments?userId=" + userId, Payment.class));

CompletableFuture.allOf(userFuture, ordersFuture, paymentFuture).join();

return new Dashboard(userFuture.join(), ordersFuture.join(), paymentFuture.join()); } ```

This works, but there's a hidden danger: supplyAsync() uses the common ForkJoinPool by default. That pool is designed for CPU-bound tasks, not I/O-bound operations like REST calls. Under load, you'll exhaust the pool and cause thread starvation.

Always provide a custom executor. Here's the better version:

```java private final ExecutorService executor = Executors.newFixedThreadPool(50);

public Dashboard getDashboard(String userId) { CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> restTemplate.getForObject("http://user-service/users/" + userId, User.class), executor); // ... same for others } ```

This gives you control over the thread pool size. A good starting point is number of concurrent requests * number of parallel calls per request. Monitor and adjust.

ParallelDashboardService.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ParallelDashboardService {
    private final RestTemplate restTemplate;
    private final ExecutorService executor = Executors.newFixedThreadPool(50);

    public Dashboard getDashboard(String userId) {
        long start = System.currentTimeMillis();
        CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() ->
            restTemplate.getForObject("http://user-service/users/" + userId, User.class), executor);
        CompletableFuture<List<Order>> ordersFuture = CompletableFuture.supplyAsync(() ->
            restTemplate.getForObject("http://order-service/orders?userId=" + userId, 
                new ParameterizedTypeReference<List<Order>>() {}.getType()), executor);
        CompletableFuture<Payment> paymentFuture = CompletableFuture.supplyAsync(() ->
            restTemplate.getForObject("http://payment-service/payments?userId=" + userId, Payment.class), executor);

        CompletableFuture.allOf(userFuture, ordersFuture, paymentFuture).join();

        User user = userFuture.join();
        List<Order> orders = ordersFuture.join();
        Payment payment = paymentFuture.join();
        System.out.println("Total time: " + (System.currentTimeMillis() - start) + "ms");
        return new Dashboard(user, orders, payment);
    }
}
Output
Total time: 212ms (assuming each call takes ~200ms, but parallel)
💡Use a dedicated executor for async operations
📊 Production Insight
I once saw a team use Executors.newCachedThreadPool() which created an unbounded number of threads. Under load, it created thousands of threads, causing the JVM to crash with OutOfMemoryError. Use fixed thread pools or bounded thread pools.
🎯 Key Takeaway
Always provide a custom executor to CompletableFuture for I/O-bound tasks to avoid thread pool exhaustion.

Handling Errors in Parallel Calls

When you make multiple parallel calls, one failure shouldn't bring down the entire operation. For example, if the Payment Service is down, you might still want to show user info and order history, with a note that payment info is unavailable.

CompletableFuture provides exceptionally() and handle() to deal with errors per future. Here's how to provide fallback values:

``java CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> restTemplate.getForObject("http://user-service/users/" + userId, User.class), executor) .exceptionally(ex -> { log.error("Failed to fetch user", ex); return new User(); // fallback empty user }); ``

Then, when you call allOf() and join(), the fallback values are used, and no exception is thrown. But be careful: allOf() itself doesn't propagate exceptions; you need to check each future individually.

A common mistake is to call future.get() or future.join() without exception handling, which throws CompletionException wrapping the original exception. Always use exceptionally() or handle() before joining.

If you need to collect errors and report them later, you can use a helper like:

``java public <T> CompletableFuture<T> withFallback(CompletableFuture<T> future, T fallback) { return future.exceptionally(ex -> { log.error("Async operation failed", ex); return fallback; }); } ``

This keeps your code clean and consistent.

ResilientDashboardService.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ResilientDashboardService {
    private final RestTemplate restTemplate;
    private final ExecutorService executor = Executors.newFixedThreadPool(50);

    public Dashboard getDashboard(String userId) {
        CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() ->
            restTemplate.getForObject("http://user-service/users/" + userId, User.class), executor)
            .exceptionally(ex -> { log.error("User fetch failed", ex); return new User(); });
        CompletableFuture<List<Order>> ordersFuture = CompletableFuture.supplyAsync(() ->
            restTemplate.getForObject("http://order-service/orders?userId=" + userId, List.class), executor)
            .exceptionally(ex -> { log.error("Orders fetch failed", ex); return Collections.emptyList(); });
        CompletableFuture<Payment> paymentFuture = CompletableFuture.supplyAsync(() ->
            restTemplate.getForObject("http://payment-service/payments?userId=" + userId, Payment.class), executor)
            .exceptionally(ex -> { log.error("Payment fetch failed", ex); return new Payment(); });

        CompletableFuture.allOf(userFuture, ordersFuture, paymentFuture).join();

        return new Dashboard(userFuture.join(), ordersFuture.join(), paymentFuture.join());
    }
}
Output
Dashboard with fallback values if any service fails.
⚠ Don't use get() without timeout
📊 Production Insight
In production, you might want to track which services failed and return partial responses with error details. Consider returning a wrapper object that includes both data and errors.
🎯 Key Takeaway
Use exceptionally() or handle() on each future to provide fallback values and prevent one failure from crashing the entire request.

Using Spring's @Async for Cleaner Code

Spring's @Async annotation allows you to make any method asynchronous without explicit CompletableFuture code. Here's how:

  1. Enable async in your configuration:

``java @Configuration @EnableAsync public class AsyncConfig { @Bean("asyncExecutor") public Executor asyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(10); executor.setMaxPoolSize(50); executor.setQueueCapacity(100); executor.setThreadNamePrefix("async-"); executor.initialize(); return executor; } } ``

  1. Create service methods annotated with @Async:

``java @Async("asyncExecutor") public CompletableFuture<User> fetchUser(String userId) { User user = restTemplate.getForObject("http://user-service/users/" + userId, User.class); return CompletableFuture.completedFuture(user); } ``

  1. Call them from your controller or service:

``java public Dashboard getDashboard(String userId) { CompletableFuture<User> userFuture = fetchUser(userId); CompletableFuture<List<Order>> ordersFuture = fetchOrders(userId); CompletableFuture<Payment> paymentFuture = fetchPayment(userId); CompletableFuture.allOf(userFuture, ordersFuture, paymentFuture).join(); return new Dashboard(userFuture.join(), ordersFuture.join(), paymentFuture.join()); } ``

The advantage is that you can reuse these async methods elsewhere. But beware: @Async only works if the method is called from outside the class (through Spring's proxy). Calling fetchUser() from within the same class won't make it async – it will run synchronously. This is a common pitfall.

Also, @Async methods must return CompletableFuture (or Future), not void, to be useful for composition.

AsyncService.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Service
public class AsyncService {
    private final RestTemplate restTemplate;

    @Async("asyncExecutor")
    public CompletableFuture<User> fetchUser(String userId) {
        User user = restTemplate.getForObject("http://user-service/users/" + userId, User.class);
        return CompletableFuture.completedFuture(user);
    }

    @Async("asyncExecutor")
    public CompletableFuture<List<Order>> fetchOrders(String userId) {
        List<Order> orders = restTemplate.getForObject("http://order-service/orders?userId=" + userId, List.class);
        return CompletableFuture.completedFuture(orders);
    }

    @Async("asyncExecutor")
    public CompletableFuture<Payment> fetchPayment(String userId) {
        Payment payment = restTemplate.getForObject("http://payment-service/payments?userId=" + userId, Payment.class);
        return CompletableFuture.completedFuture(payment);
    }
}
Output
Async methods return CompletableFuture immediately.
🔥@Async proxy limitation
📊 Production Insight
I've seen teams use @Async without a custom executor, relying on Spring's default SimpleAsyncTaskExecutor which creates a new thread for each task – dangerous under load. Always define a thread pool.
🎯 Key Takeaway
@Async simplifies async code but requires external method calls and a proper executor bean.

What the Official Docs Won't Tell You

The Spring documentation and Java docs for CompletableFuture are technically correct, but they gloss over several production realities:

1. The common ForkJoinPool is NOT your friend.

The docs say supplyAsync() uses ForkJoinPool.commonPool() by default. They don't emphasize that this pool is tiny (size = CPU cores - 1) and designed for CPU-bound tasks. Using it for I/O will cause thread starvation. Always provide a custom executor.

2. allOf() doesn't propagate exceptions the way you think.

CompletableFuture.allOf() returns a new CompletableFuture that completes when all given futures complete. But if any future completes exceptionally, the resulting future also completes exceptionally. However, the exception is a CompletionException wrapping the first exception encountered. The other futures might still be running or have completed exceptionally too. To handle all errors, you need to inspect each future individually after allOf().join().

3. Thread pool sizing is an art, not a science.

The formula pool size = number of concurrent requests * number of parallel calls is a starting point. But you also need to consider the downstream service's capacity. If you send too many concurrent requests, you might overwhelm the downstream service, causing it to slow down or fail. Use circuit breakers (Resilience4j) to protect downstream services.

4. CompletableFuture.get() without timeout is a time bomb.

If a downstream service hangs, your thread will block forever. Always use get(long timeout, TimeUnit unit) and handle TimeoutException. Even better, configure timeouts at the HTTP client level (RestTemplate or WebClient).

5. Memory leaks from uncompleted futures.

If you create a CompletableFuture but never complete it (e.g., due to a bug), it stays in memory. Over time, these leaked futures can cause OutOfMemoryError. Use CompletableFuture.completeOnTimeout() (Java 9+) or ensure all paths complete.

6. @Async and @Transactional don't mix well.

If you mark an @Async method as @Transactional, the transaction is bound to the async thread, not the caller's thread. This can lead to unexpected behavior. Keep async methods free of transaction management.

TimeoutExample.javaJAVA
1
2
3
4
5
6
7
8
9
CompletableFuture<User> future = CompletableFuture.supplyAsync(() ->
    restTemplate.getForObject("http://user-service/users/" + userId, User.class), executor);
try {
    User user = future.get(5, TimeUnit.SECONDS);
} catch (TimeoutException e) {
    // handle timeout
    future.cancel(true); // cancel if possible
    user = new User(); // fallback
}
Output
User fetched within 5 seconds, or fallback used.
⚠ Canceling a CompletableFuture
📊 Production Insight
I once debugged a production issue where a downstream service had a memory leak causing it to respond slowly. Our thread pool grew to max, then requests queued up, and eventually the app ran out of memory. We added circuit breakers and a max queue size to prevent this.
🎯 Key Takeaway
Production pitfalls include thread pool exhaustion, exception handling nuances, timeout blindness, and memory leaks. Always test under load.

Using WebClient for Non-Blocking I/O

RestTemplate is blocking – each call occupies a thread while waiting for the response. This is fine for moderate concurrency, but if you need high throughput (thousands of requests per second), consider WebClient, which is non-blocking and uses event loops.

WebClient works naturally with CompletableFuture because you can convert its reactive Mono to a CompletableFuture:

```java Mono<User> userMono = webClient.get() .uri("http://user-service/users/{id}", userId) .retrieve() .bodyToMono(User.class);

CompletableFuture<User> userFuture = userMono.toFuture(); ```

This gives you the best of both worlds: non-blocking I/O and the composability of CompletableFuture. However, you need to be careful with thread pools: WebClient uses its own event loop threads, so you don't need a large thread pool for the futures themselves. But you still need an executor for any blocking operations within the async chain.

```java @Service public class WebClientService { private final WebClient webClient;

public WebClientService(WebClient.Builder builder) { this.webClient = builder.baseUrl("http://user-service").build(); }

public CompletableFuture<User> fetchUser(String userId) { return webClient.get() .uri("/users/{id}", userId) .retrieve() .bodyToMono(User.class) .toFuture(); } } ```

Then you can combine futures as before. Note that toFuture() returns a CompletableFuture that completes when the Mono completes. No explicit executor needed because WebClient handles the threading.

But beware: if you use block() on a Mono inside a CompletableFuture, you'll block the event loop thread, defeating the purpose. Always stay in the reactive or async world.

WebClientAsyncService.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Service
public class WebClientAsyncService {
    private final WebClient webClient;

    public WebClientAsyncService(WebClient.Builder builder) {
        this.webClient = builder.build();
    }

    public CompletableFuture<User> fetchUser(String userId) {
        return webClient.get()
            .uri("http://user-service/users/{id}", userId)
            .retrieve()
            .bodyToMono(User.class)
            .toFuture();
    }

    public CompletableFuture<List<Order>> fetchOrders(String userId) {
        return webClient.get()
            .uri("http://order-service/orders?userId={id}", userId)
            .retrieve()
            .bodyToFlux(Order.class)
            .collectList()
            .toFuture();
    }
}
Output
Non-blocking parallel calls with WebClient.
💡WebClient is preferred in Spring Boot 3.x
📊 Production Insight
Switching from RestTemplate to WebClient reduced our thread count from 200 to 20 for the same throughput. The event loop model is much more efficient for I/O-bound work.
🎯 Key Takeaway
WebClient provides non-blocking I/O and integrates seamlessly with CompletableFuture via toFuture().
● Production incidentPOST-MORTEMseverity: high

Black Friday ForkJoinPool Saturation

Symptom
Users saw HTTP 503 Service Unavailable and spinning loaders. The app became completely unresponsive, and health checks started failing.
Assumption
The developers assumed that making REST calls with CompletableFuture.supplyAsync() would automatically scale because it uses threads from the common ForkJoinPool.
Root cause
The common ForkJoinPool has a limited number of threads (by default, number of CPU cores - 1). With hundreds of concurrent requests, each making 3 parallel calls, the pool was quickly exhausted. Threads were blocked waiting for I/O, and no new tasks could be scheduled, causing a thread starvation deadlock.
Fix
Switched to a custom thread pool with a larger pool size (e.g., 50 threads) and a bounded queue. Used Executors.newFixedThreadPool() and passed it to supplyAsync(). Also added a dedicated pool for async operations to isolate from other tasks.
Key lesson
  • Never use the common ForkJoinPool for I/O-bound tasks; it's designed for CPU-bound tasks.
  • Always size your thread pool based on expected concurrency and I/O wait time.
  • Monitor thread pool metrics (active threads, queue size) in production.
  • Use a separate thread pool for async operations to avoid interference with other parts of the application.
  • Implement circuit breakers to fail fast when downstream services are slow.
Production debug guideSymptom to Action4 entries
Symptom · 01
Application becomes unresponsive under load, threads stuck in WAITING state
Fix
Take a thread dump and look for threads waiting on ForkJoinPool.commonPool(). If many threads are waiting, you've exhausted the common pool. Switch to a custom thread pool with a larger size.
Symptom · 02
CompletableFuture.get() never returns or throws TimeoutException
Fix
Check if the downstream service is slow or down. Use a timeout: future.get(5, TimeUnit.SECONDS). Also check if the thread pool is saturated – if all threads are blocked on I/O, no new tasks can execute.
Symptom · 03
One failing REST call causes the entire aggregate to fail
Fix
Use exceptionally() or handle() to provide fallback values per future. Combine with allOf() and handle exceptions individually, not after join().
Symptom · 04
High memory usage or OutOfMemoryError
Fix
Too many futures held in memory. Consider using streaming or batching. Also check if you're storing large response bodies in CompletableFuture – use DTOs with only needed fields.
★ Quick Debug Cheat SheetOne-line description
App hangs under load
Immediate action
Take thread dump, check for ForkJoinPool.commonPool
Commands
jstack <pid> | grep -A 10 'ForkJoinPool.commonPool'
jstack <pid> | grep 'WAITING' | wc -l
Fix now
Switch to custom thread pool: Executors.newFixedThreadPool(50)
CompletableFuture.get() times out+
Immediate action
Check downstream service health and thread pool saturation
Commands
curl -m 5 http://downstream/health
jstack <pid> | grep 'pool-' | head
Fix now
Add timeout to get(): future.get(5, TimeUnit.SECONDS)
One failure kills all results+
Immediate action
Add per-future exception handling
Commands
grep 'exceptionally' src/main/java/**/*.java
grep 'handle' src/main/java/**/*.java
Fix now
Use .exceptionally(ex -> fallbackValue) on each future
ApproachBlockingThread UsageComplexityRecommendation
Sequential RestTemplateYes1 thread per requestLowSimple but slow; avoid
Parallel CompletableFuture with RestTemplateYes (per call)N threads per requestMediumGood for moderate concurrency
Parallel CompletableFuture with WebClientNoEvent loop threadsMediumBest for high throughput
@Async with RestTemplateYes (per call)N threads per requestLow-MediumClean code but proxy limitations
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
SequentialDashboardService.javapublic class SequentialDashboardService {Why Sequential REST Calls Hurt Performance
ParallelDashboardService.javapublic class ParallelDashboardService {Parallel REST Calls with CompletableFuture
ResilientDashboardService.javapublic class ResilientDashboardService {Handling Errors in Parallel Calls
AsyncService.java@ServiceUsing Spring's @Async for Cleaner Code
TimeoutExample.javaCompletableFuture future = CompletableFuture.supplyAsync(() ->What the Official Docs Won't Tell You
WebClientAsyncService.java@ServiceUsing WebClient for Non-Blocking I/O

Key takeaways

1
Parallel REST calls with CompletableFuture reduce response time to the slowest call, but require careful thread pool management.
2
Always provide a custom executor for I/O-bound tasks to avoid ForkJoinPool exhaustion.
3
Handle exceptions per future with exceptionally() to prevent one failure from ruining the entire result.
4
Use WebClient for non-blocking I/O and convert to CompletableFuture with toFuture() for high throughput.
5
Test under load with realistic concurrency to validate thread pool sizing and error handling.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How would you make multiple REST calls in parallel using CompletableFutu...
Q02SENIOR
What are the dangers of using the common ForkJoinPool for I/O-bound task...
Q03SENIOR
Explain how to handle partial failures in parallel REST calls with Compl...
Q01 of 03SENIOR

How would you make multiple REST calls in parallel using CompletableFuture in Spring Boot?

ANSWER
Create a CompletableFuture for each call using supplyAsync() with a custom executor, combine them with allOf().join(), and then retrieve results with join(). Handle exceptions with exceptionally() or handle().
FAQ · 3 QUESTIONS

Frequently Asked Questions

01
What is the best thread pool size for parallel REST calls?
02
How do I handle timeouts in CompletableFuture?
03
Can I use @Async with WebClient?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.

Follow
Verified
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
🔥

That's Spring Boot. Mark it forged?

5 min read · try the examples if you haven't

Previous
How to Get All Registered Endpoints in Spring Boot
107 / 121 · Spring Boot
Next
Handling URL Encoded Form Data in Spring REST Controllers