Handling Responses
About
When consuming RESTful APIs using RestTemplate
, handling responses effectively is crucial to building reliable, observable, and maintainable applications. This includes reading the response body, capturing headers, dealing with different status codes, and handling typed or dynamic responses.
ResponseEntity: The Preferred Wrapper
ResponseEntity<T>
is the powerful and flexible way to capture the full HTTP response when making REST calls using RestTemplate
.
In a production-grade system, relying solely on response body (getForObject
) is often insufficient. We typically need access to status codes, response headers, or even empty bodies (204 No Content). ResponseEntity
wraps all of this in one convenient structure.
ResponseEntity represents the entire HTTP response:
ResponseEntity<T> {
HttpStatus statusCode;
HttpHeaders headers;
T body;
}
Unlike getForObject()
which returns only the body, ResponseEntity
is useful in real-world applications where the complete response context matters — not just the payload.
Example
ResponseEntity<UserDto> response = restTemplate.getForEntity(
"https://api.example.com/users/{id}",
UserDto.class,
userId
);
We can now extract
HttpStatus status = response.getStatusCode(); // e.g., 200 OK
HttpHeaders headers = response.getHeaders(); // Custom or standard headers
UserDto user = response.getBody(); // Actual payload
Handling 204 No Content
ResponseEntity<Void> response = restTemplate.getForEntity(url, Void.class);
if (response.getStatusCode() == HttpStatus.NO_CONTENT) {
// No body, but operation was successful
}
Extracting Pagination or Tracking Headers
String correlationId = response.getHeaders().getFirst("X-Correlation-ID");
String nextPageUrl = response.getHeaders().getFirst("Link");
Building Defensive Services
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
process(response.getBody());
} else {
throw new ExternalServiceException("Unexpected status: " + response.getStatusCode());
}
Mapping Response to Domain Object
When building enterprise-grade applications, it's critical to cleanly separate transport-layer data (e.g., JSON over HTTP) from internal domain models. While RestTemplate
allows direct mapping of HTTP response bodies to Java objects, blindly mapping external data structures into internal models can lead to tight coupling, maintenance issues, and data leakage between layers.
This is why response mapping should be treated as a formal step in our architecture — ensuring robustness, decoupling, and clarity.
Instead of directly consuming the HTTP response into our domain model, map the response to a dedicated DTO (Data Transfer Object) and then transform it into a domain entity or business object.
This two-step process:
Maps raw JSON to DTOs using RestTemplate.
Converts DTO to Domain Object using mappers (manual or libraries like MapStruct).
Reason
Details
Decoupling from External API Schema
If external APIs change, we only need to update the DTO, not the domain logic.
Avoid Overexposure
External fields we don’t care about stay out of our core logic.
Validation & Transformation
Apply domain rules or enrich data before creating core objects.
Reusability
Same DTO can be used by other layers or adapters (caching, logging, etc.).
Clean Layering
Keeps domain layer free of protocol-specific artifacts.
Example
1. Response JSON (from external API)
{
"user_id": 1001,
"full_name": "Alice Johnson",
"email": "[email protected]",
"status": "active"
}
2. DTO for External Response
public class UserResponseDto {
private Long userId;
private String fullName;
private String email;
private String status;
// Getters and setters
}
3. Domain Model
public class User {
private Long id;
private String name;
private String contactEmail;
private boolean active;
public User(Long id, String name, String contactEmail, boolean active) {
this.id = id;
this.name = name;
this.contactEmail = contactEmail;
this.active = active;
}
}
4. Mapping Logic
public class UserMapper {
public static User map(UserResponseDto dto) {
return new User(
dto.getUserId(),
dto.getFullName(),
dto.getEmail(),
"active".equalsIgnoreCase(dto.getStatus())
);
}
}
5. Usage with RestTemplate
ResponseEntity<UserResponseDto> response =
restTemplate.getForEntity("https://api.example.com/users/1001", UserResponseDto.class);
UserResponseDto dto = response.getBody();
User user = UserMapper.map(dto);
Handling JSON Arrays / Lists
When an API responds with a JSON array, it represents a collection of similar entities — like a list of users, orders, or products. In enterprise applications, it’s common to consume such arrays and map them to a list of Java objects for further processing.
Unlike single-object deserialization, handling arrays requires additional care with RestTemplate
, especially with Java generics and type erasure.
Example: External API Response
[
{
"id": 1,
"name": "Product A",
"price": 25.0
},
{
"id": 2,
"name": "Product B",
"price": 30.0
}
]
Approach 1: Using ResponseEntity<T[]>
ResponseEntity<T[]>
The simplest way to handle a JSON array is to map it into an array of objects:
ResponseEntity<ProductDto[]> response = restTemplate.getForEntity(
"https://api.example.com/products",
ProductDto[].class
);
ProductDto[] productArray = response.getBody();
List<ProductDto> productList = Arrays.asList(productArray);
Pros: Simple and readable.
Cons: Returns a fixed-size
List
(fromArrays.asList
) unless we wrap it withnew ArrayList<>(...)
.
Approach 2: Using ParameterizedTypeReference<List<T>>
ParameterizedTypeReference<List<T>>
This is the preferred approach in most production systems where generic typing or further abstraction is required.
ResponseEntity<List<ProductDto>> response = restTemplate.exchange(
"https://api.example.com/products",
HttpMethod.GET,
null,
new ParameterizedTypeReference<List<ProductDto>>() {}
);
List<ProductDto> productList = response.getBody();
Pros:
Supports generics cleanly.
Produces a mutable list.
Cons: Slightly more verbose.
Dynamic Responses (Map or Raw JSON)
In many real-world APIs, the structure of a response might not be strictly defined or may vary across use cases for instance:
Optional fields
Nested dynamic objects
Polymorphic types
Unknown keys or additional metadata
In such cases, mapping directly to a fixed DTO may not work. We need a more flexible way to process semi-structured or dynamic JSON, often using Map
, JsonNode
, or other raw representations.
Use Case
Why Dynamic
Webhook payloads
Fields can vary between events
External third-party APIs
Schema may not be fixed or may include nested data
Feature-flag responses or config APIs
Keys are often dynamic or environment-based
Error or metadata responses
Varying shape of error payloads
Analytics or audit streams
Fields evolve over time
Approach 1: Using Map<String, Object>
Map<String, Object>
This is the most direct approach to capturing the entire JSON response as a Map
.
ResponseEntity<Map> response = restTemplate.getForEntity(
"https://api.example.com/metadata",
Map.class
);
Map<String, Object> result = response.getBody();
Pros: Straightforward; handles flat or nested dynamic keys.
Cons: Type-safety is lost; casting required for deeper levels.
Nested Access Example
String version = (String) ((Map<String, Object>) result.get("app")).get("version");
Approach 2: Using JsonNode
(Jackson Tree Model)
JsonNode
(Jackson Tree Model)Jackson's JsonNode
allows for powerful navigation and inspection of arbitrary JSON.
ResponseEntity<JsonNode> response = restTemplate.exchange(
"https://api.example.com/data",
HttpMethod.GET,
null,
JsonNode.class
);
JsonNode root = response.getBody();
String status = root.path("status").asText();
JsonNode details = root.path("details");
Pros:
Non-breaking even when keys are missing.
Cleaner navigation and transformation.
Cons: Requires familiarity with
JsonNode
API.
Status Code Checks
When integrating with external systems or microservices, handling HTTP status codes correctly is a critical part of response handling. RestTemplate provides multiple ways to inspect and react to HTTP status codes — whether to retry, log, fail-fast, or switch business logic paths.
Approach 1: Using ResponseEntity
ResponseEntity
We can access the status code from the ResponseEntity
object.
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
if (response.getStatusCode().is2xxSuccessful()) {
// Process response
} else if (response.getStatusCode().is4xxClientError()) {
// Log and handle client-side error
} else if (response.getStatusCode().is5xxServerError()) {
// Retry or fallback
}
Example with switch-style logic
HttpStatus status = response.getStatusCode();
switch (status) {
case OK:
// Proceed
break;
case NOT_FOUND:
throw new ResourceNotFoundException();
case BAD_REQUEST:
log.warn("Validation error occurred");
break;
default:
throw new ServiceIntegrationException("Unexpected status: " + status);
}
Approach 2: Custom ResponseErrorHandler
ResponseErrorHandler
For centralizing status code handling logic, we can create and register a custom error handler.
public class CustomResponseErrorHandler implements ResponseErrorHandler {
@Override
public boolean hasError(ClientHttpResponse response) throws IOException {
return (
response.getStatusCode().series() == HttpStatus.Series.CLIENT_ERROR ||
response.getStatusCode().series() == HttpStatus.Series.SERVER_ERROR
);
}
@Override
public void handleError(ClientHttpResponse response) throws IOException {
// Custom logic based on status code
HttpStatus statusCode = response.getStatusCode();
if (statusCode == HttpStatus.NOT_FOUND) {
throw new NotFoundException("Resource not found");
} else if (statusCode == HttpStatus.UNAUTHORIZED) {
throw new AuthenticationException("Unauthorized");
} else {
throw new GeneralServiceException("Unexpected error: " + statusCode);
}
}
}
Register it with RestTemplate
restTemplate.setErrorHandler(new CustomResponseErrorHandler());
Extracting & Logging Response Headers
HTTP headers play a vital role in metadata exchange between services — including correlation IDs, content types, rate-limiting info, cache controls, and authentication details. When using RestTemplate
, it is common in production systems to extract headers for logging, diagnostics, tracing, or making conditional decisions.
Accessing Headers with ResponseEntity
ResponseEntity
The most straightforward way is via ResponseEntity.getHeaders()
:
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
HttpHeaders headers = response.getHeaders();
String requestId = headers.getFirst("X-Request-ID");
String contentType = headers.getContentType() != null ? headers.getContentType().toString() : "unknown";
logger.info("Received response: X-Request-ID={}, Content-Type={}", requestId, contentType);
Iterating Over All Headers
In enterprise applications, it’s often useful to log or analyze all response headers.
headers.forEach((key, values) -> {
values.forEach(value -> logger.debug("Header {}: {}", key, value));
});
Example: Handling Rate Limit Headers
Some APIs send rate-limiting metadata in headers:
String remaining = headers.getFirst("X-RateLimit-Remaining");
String resetTime = headers.getFirst("X-RateLimit-Reset");
if (remaining != null && Integer.parseInt(remaining) < 10) {
logger.warn("Approaching rate limit. Remaining calls: {}", remaining);
}
Accessing Headers in POST, PUT, DELETE Requests
Even when using methods like postForEntity
or exchange
, we can still access headers:
ResponseEntity<MyResponse> response = restTemplate.exchange(
url,
HttpMethod.POST,
new HttpEntity<>(payload),
MyResponse.class
);
HttpHeaders headers = response.getHeaders();
String apiVersion = headers.getFirst("X-API-Version");
Structured Header Logging
Enterprise services often use structured logs for better filtering and correlation. Here's how we would build a structured logging message:
Map<String, String> logHeaders = new HashMap<>();
headers.forEach((key, values) -> logHeaders.put(key, String.join(",", values)));
logger.info("External API response headers: {}", logHeaders);
Handling Empty Responses
In real-world service integrations, it is common for REST APIs to return responses with no body — either because:
The operation was successful but doesn’t return data (e.g.,
DELETE
,PUT
,204 No Content
)The response is dynamically constructed and might sometimes be empty (e.g., optional search results)
The downstream service failed silently or intentionally suppressed the payload
If not handled carefully, these empty responses can lead to NullPointerException
, HttpMessageNotReadableException
, or deserialization failures, especially when using generic or POJO-based deserialization.
Typical HTTP Scenarios
HTTP Status
Description
Behavior
200 OK
May contain an empty body
Safe to check for null or empty
204 No Content
No content expected
Response body will be null
404 Not Found
Often no body returned
Should handle gracefully if optional
500
/503
Error response, possibly empty
Fallback or error handling needed
Handling with ResponseEntity
ResponseEntity
ResponseEntity<MyResponse> response = restTemplate.exchange(
url,
HttpMethod.GET,
null,
MyResponse.class
);
if (response.getStatusCode() == HttpStatus.NO_CONTENT || response.getBody() == null) {
logger.info("No content returned from API");
return Optional.empty();
}
return Optional.of(response.getBody());
Handling with String.class
to Safely Inspect Body
String.class
to Safely Inspect BodyResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
if (response.getBody() == null || response.getBody().isBlank()) {
logger.warn("Empty response body from downstream");
return;
}
logger.info("Raw response: {}", response.getBody());
This approach is useful when:
The response format is dynamic or loosely typed
We want to inspect or log the raw body before parsing
Using ParameterizedTypeReference
with Exchange
ParameterizedTypeReference
with ExchangeWhen using generics (e.g., List<MyType>
), ensure safe parsing by checking body before access:
ResponseEntity<List<MyType>> response = restTemplate.exchange(
url,
HttpMethod.GET,
null,
new ParameterizedTypeReference<List<MyType>>() {}
);
List<MyType> result = response.getBody() != null ? response.getBody() : Collections.emptyList();
Avoiding HttpMessageNotReadableException
HttpMessageNotReadableException
Sometimes, RestTemplate
may try to parse an empty body into a class and fail.
To prevent this:
Option 1: Use String.class
and parse only if not empty
String.class
and parse only if not emptyResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
if (StringUtils.hasText(response.getBody())) {
MyResponse obj = objectMapper.readValue(response.getBody(), MyResponse.class);
}
Option 2: Catch parsing exceptions
try {
ResponseEntity<MyResponse> response = restTemplate.getForEntity(url, MyResponse.class);
return Optional.ofNullable(response.getBody());
} catch (HttpMessageNotReadableException ex) {
logger.warn("Empty or unreadable response received", ex);
return Optional.empty();
}
Centralized Response Handling Using Utility
In large-scale Spring applications, interactions with external APIs are widespread across various services. Managing response deserialization, error handling, status code verification, and empty responses individually can lead to duplicated logic and inconsistent behavior.
A centralized response handling utility abstracts this logic in a reusable, maintainable, and testable manner.
Basic Utility Design
Step 1: Generic Response Mapper
public class RestTemplateResponseHandler {
private final ObjectMapper objectMapper;
public RestTemplateResponseHandler(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public <T> Optional<T> handleResponse(ResponseEntity<String> response, Class<T> targetType) {
if (response.getStatusCode().is2xxSuccessful() && StringUtils.hasText(response.getBody())) {
try {
return Optional.of(objectMapper.readValue(response.getBody(), targetType));
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to parse response", e);
}
}
return Optional.empty();
}
}
Step 2: Generic Utility with Status Code Checks
public class RestResponseUtil {
public static <T> T extract(ResponseEntity<T> response) {
HttpStatus status = response.getStatusCode();
if (status.is2xxSuccessful()) {
T body = response.getBody();
if (body != null) {
return body;
}
throw new ResponseParsingException("Expected non-null response body");
}
throw new DownstreamException("Non-2xx status: " + status);
}
}
Step 3: Example Usage in Service Layer
public UserProfile getProfile(String userId) {
ResponseEntity<UserProfile> response = restTemplate.getForEntity(PROFILE_API + "/" + userId, UserProfile.class);
return RestResponseUtil.extract(response);
}
Variation for Optional Use Cases
public static <T> Optional<T> extractOptional(ResponseEntity<T> response) {
if (response.getStatusCode() == HttpStatus.NO_CONTENT || response.getBody() == null) {
return Optional.empty();
}
if (response.getStatusCode().is2xxSuccessful()) {
return Optional.of(response.getBody());
}
throw new DownstreamException("Unexpected HTTP Status: " + response.getStatusCode());
}
Example Use: Optional Behavior
public Optional<UserProfile> fetchProfile(String userId) {
ResponseEntity<UserProfile> response = restTemplate.getForEntity(PROFILE_API + "/" + userId, UserProfile.class);
return RestResponseUtil.extractOptional(response);
}
Deserializing Nested Responses
In real-world APIs, responses are often wrapped or nested, meaning the actual business data is embedded within a larger structure containing metadata, status codes, pagination info, or wrapper fields.
Instead of directly mapping to domain objects, we must first extract the inner content — and this requires custom deserialization logic or wrapper types.
Common Real-World API Pattern
Many APIs return data in the following shape:
{
"status": "SUCCESS",
"code": 200,
"data": {
"id": "123",
"name": "John Doe",
"email": "[email protected]"
}
}
Here, our domain object is just the "data"
part.
Approach 1: Wrapper POJO Mapping
Step 1: Define a Generic Wrapper
public class ApiResponse<T> {
private String status;
private int code;
private T data;
// getters and setters
}
Step 2: Define Domain Object
public class User {
private String id;
private String name;
private String email;
// getters and setters
}
Step 3: Parameterized Type Reference
ResponseEntity<ApiResponse<User>> response = restTemplate.exchange(
apiUrl,
HttpMethod.GET,
null,
new ParameterizedTypeReference<ApiResponse<User>>() {}
);
User user = response.getBody().getData();
Approach 2: Deserialize Using ObjectMapper
and Extract Inner Field
ObjectMapper
and Extract Inner FieldWhen type inference is complex, use JsonNode
or manual extraction:
ResponseEntity<String> response = restTemplate.getForEntity(apiUrl, String.class);
JsonNode root = objectMapper.readTree(response.getBody());
JsonNode dataNode = root.path("data");
User user = objectMapper.treeToValue(dataNode, User.class);
Handling Lists in Nested Structure
{
"status": "SUCCESS",
"code": 200,
"data": [
{ "id": "1", "name": "Alice" },
{ "id": "2", "name": "Bob" }
]
}
With Generic Wrapper
public class ApiResponseList<T> {
private String status;
private int code;
private List<T> data;
// getters and setters
}
ResponseEntity<ApiResponseList<User>> response = restTemplate.exchange(
apiUrl,
HttpMethod.GET,
null,
new ParameterizedTypeReference<ApiResponseList<User>>() {}
);
List<User> users = response.getBody().getData();
Advanced Use Case: Pagination with Metadata
{
"status": "SUCCESS",
"data": {
"items": [ {...}, {...} ],
"pagination": {
"page": 1,
"total": 5
}
}
}
POJO Structure
public class Pagination {
private int page;
private int total;
}
public class PagedData<T> {
private List<T> items;
private Pagination pagination;
}
public class ApiResponse<T> {
private String status;
private T data;
}
Then:
ResponseEntity<ApiResponse<PagedData<User>>> response = restTemplate.exchange(
apiUrl,
HttpMethod.GET,
null,
new ParameterizedTypeReference<ApiResponse<PagedData<User>>>() {}
);
List<User> users = response.getBody().getData().getItems();
Last updated