Building a REST API Client with Java HttpClient + Jackson
Building a REST API Client with Java HttpClient + Jackson Java's built-in HttpClient is great for sending HTTP requests. Jackson is great for converting JSON into Java objects. Put them together, and you have everyth
Building a REST API Client with Java HttpClient + Jackson
Java's built-in HttpClient is great for sending HTTP requests.
Jackson is great for converting JSON into Java objects.
Put them together, and you have everything you need to build a clean REST API client without introducing a heavyweight framework.
In this tutorial, we'll build a reusable API client that can:
- Send GET requests
- Send POST requests
- Convert JSON responses into Java objects
- Convert Java objects into JSON
- Deserialize JSON arrays into
List<T> - Work with Java records
- Handle HTTP errors
- Reuse
HttpClient - Reuse Jackson's
ObjectMapper - Support bearer-token authentication
- Add timeouts
- Build a cleaner generic REST client
By the end, we'll have a small client that feels much closer to production code than a simple one-off HTTP request.
1. The Problem
Suppose an API returns this JSON:
{
"id": 1,
"name": "Alice",
"email": "[email protected]"
}
Using only HttpClient, we can retrieve it as a String:
HttpResponse<String> response = client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
String json = response.body();
That works.
But now our application has this:
String json;
when what we really want is this:
User user;
That's where Jackson comes in.
Jackson can convert:
JSON
↓
Java object
and also:
Java object
↓
JSON
So the architecture becomes:
HttpClient
↓
HTTP request
↓
REST API
↓
JSON response
↓
Jackson
↓
Java object
2. Add Jackson to Your Project
If you're using Maven, add Jackson Databind:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.20.0</version>
</dependency>
If you're using Gradle:
implementation("com.fasterxml.jackson.core:jackson-databind:2.20.0")
Jackson Databind pulls in the core Jackson libraries we need for JSON serialization and deserialization.
3. Create a Java Model
Let's start with a simple API response:
{
"id": 1,
"name": "Alice",
"email": "[email protected]"
}
We can represent it using a Java record:
public record User(
int id,
String name,
String email
) {
}
Records are a great fit for API models because they are concise and immutable.
Instead of writing:
public class User {
private int id;
private String name;
private String email;
// constructor
// getters
// setters
}
we can simply write:
public record User(
int id,
String name,
String email
) {
}
4. Create an ObjectMapper
Jackson's main class is:
ObjectMapper
Create one like this:
import com.fasterxml.jackson.databind.ObjectMapper;
ObjectMapper mapper = new ObjectMapper();
You can think of ObjectMapper as the object that translates between Java and JSON.
For example:
JSON String
↓
ObjectMapper
↓
User
And in the opposite direction:
User
↓
ObjectMapper
↓
JSON String
Just like HttpClient, you should generally reuse one ObjectMapper instead of creating a new one for every request.
5. Converting JSON into a Java Object
Suppose we have:
String json = """
{
"id": 1,
"name": "Alice",
"email": "[email protected]"
}
""";
Jackson can convert it into a User:
User user = mapper.readValue(
json,
User.class
);
Now:
System.out.println(user.name());
prints:
Alice
And:
System.out.println(user.email());
prints:
This is called deserialization.
JSON
↓
Java object
6. Converting a Java Object into JSON
Jackson can also go the other way.
Suppose we have:
User user = new User(
1,
"Alice",
"[email protected]"
);
Convert it into JSON:
String json = mapper.writeValueAsString(user);
The result will look like:
{"id":1,"name":"Alice","email":"[email protected]"}
This is called serialization.
Java object
↓
JSON
7. Your First HttpClient + Jackson GET Request
Now let's combine both libraries.
We'll use JSONPlaceholder for our examples.
The endpoint:
https://jsonplaceholder.typicode.com/users/1
returns JSON similar to:
{
"id": 1,
"name": "Leanne Graham",
"username": "Bret",
"email": "[email protected]"
}
For simplicity, our record can ignore fields we don't need.
public record User(
int id,
String name,
String email
) {
}
However, by default Jackson may complain about JSON fields that are not represented in our record.
We can configure it to ignore unknown fields:
import com.fasterxml.jackson.databind.DeserializationFeature;
ObjectMapper mapper = new ObjectMapper()
.configure(
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
false
);
Now let's make the request:
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(
"https://jsonplaceholder.typicode.com/users/1"
))
.GET()
.build();
HttpResponse<String> response = client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
User user = mapper.readValue(
response.body(),
User.class
);
System.out.println(user);
Complete example:
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class Main {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
ObjectMapper mapper = new ObjectMapper()
.configure(
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
false
);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(
"https://jsonplaceholder.typicode.com/users/1"
))
.GET()
.build();
HttpResponse<String> response = client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
User user = mapper.readValue(
response.body(),
User.class
);
System.out.println(user);
}
}
And our record:
public record User(
int id,
String name,
String email
) {
}
8. Check the Status Code Before Parsing JSON
There is one important problem with the previous example.
We immediately parse:
response.body()
But what if the server returns:
404 Not Found
or:
500 Internal Server Error
The response body may not contain the JSON structure we expect.
So check the status code first:
if (response.statusCode() >= 200 &&
response.statusCode() < 300) {
User user = mapper.readValue(
response.body(),
User.class
);
System.out.println(user);
} else {
System.err.println(
"HTTP error: " + response.statusCode()
);
}
A useful rule is:
Send request
↓
Check status
↓
Parse JSON
Not:
Send request
↓
Immediately parse everything
9. Create a Reusable GET Method
Let's start making this more useful.
Instead of repeating the same code, we can create:
public <T> T get(
String url,
Class<T> responseType
)
The Class<T> argument tells Jackson which Java type to create.
Example implementation:
public <T> T get(
String url,
Class<T> responseType
) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response = client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
if (response.statusCode() >= 200 &&
response.statusCode() < 300) {
return mapper.readValue(
response.body(),
responseType
);
}
throw new IOException(
"HTTP " + response.statusCode()
);
}
Now we can write:
User user = api.get(
"https://jsonplaceholder.typicode.com/users/1",
User.class
);
Instead of manually dealing with JSON every time.
10. Build the API Client Class
Let's put the pieces into a reusable class.
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class RestClient {
private final HttpClient client;
private final ObjectMapper mapper;
public RestClient() {
client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
mapper = new ObjectMapper()
.configure(
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
false
);
}
public <T> T get(
String url,
Class<T> responseType
) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(20))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response = client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
if (response.statusCode() >= 200 &&
response.statusCode() < 300) {
return mapper.readValue(
response.body(),
responseType
);
}
throw new IOException(
"HTTP " +
response.statusCode() +
": " +
response.body()
);
}
}
Usage:
RestClient client = new RestClient();
User user = client.get(
"https://jsonplaceholder.typicode.com/users/1",
User.class
);
System.out.println(user.name());
This is already much cleaner.
11. Sending a Java Object with POST
Now let's add POST support.
Suppose we want to send:
CreatePostRequest post = new CreatePostRequest(
"Learning Java",
"HttpClient and Jackson work nicely together",
1
);
Define the record:
public record CreatePostRequest(
String title,
String body,
int userId
) {
}
Jackson can serialize it:
String json = mapper.writeValueAsString(post);
Then we can send that JSON with HttpClient.
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json")
.POST(
HttpRequest.BodyPublishers.ofString(json)
)
.build();
12. Create a Generic POST Method
Let's make POST reusable too.
public <T, R> R post(
String url,
T requestBody,
Class<R> responseType
) throws IOException, InterruptedException {
String json = mapper.writeValueAsString(
requestBody
);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(20))
.header(
"Accept",
"application/json"
)
.header(
"Content-Type",
"application/json"
)
.POST(
HttpRequest.BodyPublishers.ofString(json)
)
.build();
HttpResponse<String> response = client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
if (response.statusCode() >= 200 &&
response.statusCode() < 300) {
return mapper.readValue(
response.body(),
responseType
);
}
throw new IOException(
"HTTP " +
response.statusCode() +
": " +
response.body()
);
}
Notice the generic types:
<T, R>
Here:
T = request body type
R = response body type
For example:
CreatePostRequest
↓
T
Post
↓
R
Usage:
CreatePostRequest request =
new CreatePostRequest(
"Learning Java",
"HttpClient + Jackson",
1
);
Post response = client.post(
"https://jsonplaceholder.typicode.com/posts",
request,
Post.class
);
13. Complete POST Example
Let's create the request type:
public record CreatePostRequest(
String title,
String body,
int userId
) {
}
And the response type:
public record Post(
int id,
String title,
String body,
int userId
) {
}
Then:
RestClient client = new RestClient();
CreatePostRequest request =
new CreatePostRequest(
"Java HTTP Client",
"Sending objects instead of manually building JSON",
1
);
Post post = client.post(
"https://jsonplaceholder.typicode.com/posts",
request,
Post.class
);
System.out.println(post);
Now our application does not manually deal with JSON strings at all.
We send:
CreatePostRequest
and receive:
Post
The client handles the JSON conversion.
14. The Flow Now Looks Much Better
Our application code:
CreatePostRequest request =
new CreatePostRequest(
"Hello",
"REST API",
1
);
Post post = client.post(
endpoint,
request,
Post.class
);
Internally:
CreatePostRequest
↓
Jackson
↓
JSON
↓
HttpClient
↓
REST API
↓
JSON
↓
Jackson
↓
Post
This separation is very useful.
Your application works with Java objects.
The API client deals with HTTP and JSON.
15. What About JSON Arrays?
Suppose this endpoint:
https://jsonplaceholder.typicode.com/users
returns:
[
{
"id": 1,
"name": "Leanne Graham"
},
{
"id": 2,
"name": "Ervin Howell"
}
]
We want:
List<User>
You might expect this to work:
client.get(
url,
List<User>.class
);
But Java does not allow:
List<User>.class
because of type erasure.
So we need another approach.
16. Using TypeReference
Jackson provides:
TypeReference<T>
We can deserialize a list like this:
List<User> users = mapper.readValue(
json,
new TypeReference<List<User>>() {}
);
You will need:
import com.fasterxml.jackson.core.type.TypeReference;
The strange-looking:
new TypeReference<List<User>>() {}
preserves the generic type information Jackson needs.
17. Add a TypeReference GET Method
We can overload our get() method:
public <T> T get(
String url,
TypeReference<T> responseType
) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(20))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response = client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
if (response.statusCode() >= 200 &&
response.statusCode() < 300) {
return mapper.readValue(
response.body(),
responseType
);
}
throw new IOException(
"HTTP " +
response.statusCode() +
": " +
response.body()
);
}
Now:
List<User> users = client.get(
"https://jsonplaceholder.typicode.com/users",
new TypeReference<List<User>>() {}
);
And then:
for (User user : users) {
System.out.println(user.name());
}
18. Why Class<T> Works for One Object but Not List<T>
This works:
User.class
because the runtime knows:
User
But this doesn't exist:
List<User>.class
At runtime, Java mostly sees:
List
instead of:
List<User>
This is due to type erasure.
Jackson's TypeReference gives it enough information to reconstruct:
List<User>
So a useful rule is:
Single object
→ Class<T>
Generic type
→ TypeReference<T>
Examples:
User.class
for:
User
and:
new TypeReference<List<User>>() {}
for:
List<User>
19. Supporting Bearer Tokens
Many real APIs require authentication.
For example:
Authorization: Bearer YOUR_TOKEN
We could add the token when creating our client.
public class RestClient {
private final HttpClient client;
private final ObjectMapper mapper;
private final String token;
public RestClient(String token) {
this.token = token;
client = HttpClient.newHttpClient();
mapper = new ObjectMapper();
}
}
Then add it to every request:
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header(
"Authorization",
"Bearer " + token
)
.header(
"Accept",
"application/json"
)
.GET()
.build();
But don't hardcode real secrets into your source code.
Prefer something like:
String token = System.getenv("API_TOKEN");
Then:
RestClient client =
new RestClient(token);
20. Avoid Repeating Headers
Once the client becomes larger, repeated request-building code starts becoming annoying.
For example, we keep writing:
.header("Accept", "application/json")
and maybe:
.header(
"Authorization",
"Bearer " + token
)
We can create a helper method:
private HttpRequest.Builder requestBuilder(
String url
) {
HttpRequest.Builder builder =
HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(
Duration.ofSeconds(20)
)
.header(
"Accept",
"application/json"
);
if (token != null &&
!token.isBlank()) {
builder.header(
"Authorization",
"Bearer " + token
);
}
return builder;
}
Then GET becomes:
HttpRequest request = requestBuilder(url)
.GET()
.build();
And POST becomes:
HttpRequest request = requestBuilder(url)
.header(
"Content-Type",
"application/json"
)
.POST(
HttpRequest.BodyPublishers.ofString(json)
)
.build();
Much cleaner.
21. Don't Create a New HttpClient Per Request
This is worth repeating.
Avoid this:
public User getUser() {
HttpClient client =
HttpClient.newHttpClient();
// request
}
every time a request is made.
Instead:
private final HttpClient client;
and create it once:
client = HttpClient.newBuilder()
.connectTimeout(
Duration.ofSeconds(10)
)
.build();
Then reuse it.
A shared HttpClient can reuse connections and manage resources more efficiently.
22. Reuse ObjectMapper Too
The same idea applies to Jackson.
Avoid:
new ObjectMapper()
inside every method.
Instead:
private final ObjectMapper mapper;
Then initialize it once:
mapper = new ObjectMapper()
.configure(
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
false
);
ObjectMapper is designed to be reused after configuration.
23. Handling Empty Responses
Not every API response contains JSON.
For example:
204 No Content
If you blindly do:
mapper.readValue(
response.body(),
responseType
);
you may get a parsing error because there is no body.
One approach is to check:
if (response.statusCode() == 204) {
return null;
}
Or create separate methods for operations that do not return data.
For example:
public void delete(String url)
instead of forcing every request to return an object.
24. Add DELETE Support
A simple DELETE method might look like this:
public void delete(
String url
) throws IOException, InterruptedException {
HttpRequest request = requestBuilder(url)
.DELETE()
.build();
HttpResponse<String> response = client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
if (response.statusCode() < 200 ||
response.statusCode() >= 300) {
throw new IOException(
"HTTP " +
response.statusCode() +
": " +
response.body()
);
}
}
Usage:
client.delete(
"https://example.com/users/10"
);
25. Add PUT Support
We can use the same serialization pattern as POST:
public <T, R> R put(
String url,
T requestBody,
Class<R> responseType
) throws IOException, InterruptedException {
String json =
mapper.writeValueAsString(
requestBody
);
HttpRequest request =
requestBuilder(url)
.header(
"Content-Type",
"application/json"
)
.PUT(
HttpRequest.BodyPublishers
.ofString(json)
)
.build();
HttpResponse<String> response =
client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
if (response.statusCode() >= 200 &&
response.statusCode() < 300) {
return mapper.readValue(
response.body(),
responseType
);
}
throw new IOException(
"HTTP " +
response.statusCode() +
": " +
response.body()
);
}
26. Add PATCH Support
HttpRequest.Builder doesn't have a dedicated .PATCH() method.
Use:
.method()
Example:
HttpRequest request = requestBuilder(url)
.header(
"Content-Type",
"application/json"
)
.method(
"PATCH",
HttpRequest.BodyPublishers.ofString(json)
)
.build();
We can create:
public <T, R> R patch(
String url,
T requestBody,
Class<R> responseType
)
using the same pattern as POST and PUT.
27. We Are Starting to Repeat Ourselves
Look at GET:
build request
send request
check status
deserialize
POST:
serialize
build request
send request
check status
deserialize
PUT:
serialize
build request
send request
check status
deserialize
PATCH:
serialize
build request
send request
check status
deserialize
This is a sign that we can refactor.
28. Create a Shared Response Handler
We can move the repeated response processing into one method:
private <T> T handleResponse(
HttpResponse<String> response,
Class<T> responseType
) throws IOException {
int status = response.statusCode();
if (status < 200 || status >= 300) {
throw new IOException(
"HTTP " +
status +
": " +
response.body()
);
}
return mapper.readValue(
response.body(),
responseType
);
}
Now methods become smaller:
public <T> T get(
String url,
Class<T> responseType
) throws IOException, InterruptedException {
HttpRequest request =
requestBuilder(url)
.GET()
.build();
HttpResponse<String> response =
client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
return handleResponse(
response,
responseType
);
}
Much cleaner.
29. Create a Custom API Exception
Throwing a generic:
IOException
for an HTTP 404 or 500 works for a small example, but we can make errors clearer.
Create:
public class ApiException
extends RuntimeException {
private final int statusCode;
public ApiException(
int statusCode,
String message
) {
super(message);
this.statusCode = statusCode;
}
public int statusCode() {
return statusCode;
}
}
Then:
throw new ApiException(
response.statusCode(),
response.body()
);
Now callers can distinguish:
catch (ApiException e) {
if (e.statusCode() == 404) {
System.out.println(
"Resource not found"
);
}
}
That's more expressive than treating every HTTP status as a networking problem.
30. Network Errors and API Errors Are Different
A useful distinction is:
Network problem
↓
IOException
Server responded with 404 / 500
↓
ApiException
For example:
try {
User user = client.get(
url,
User.class
);
} catch (ApiException e) {
System.err.println(
"API error: " +
e.statusCode()
);
} catch (IOException e) {
System.err.println(
"Network error: " +
e.getMessage()
);
}
This makes error handling much clearer.
31. A More Complete RestClient
Let's combine the ideas into a cleaner reusable client.
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class RestClient {
private final HttpClient client;
private final ObjectMapper mapper;
private final String token;
public RestClient() {
this(null);
}
public RestClient(String token) {
this.token = token;
this.client = HttpClient.newBuilder()
.connectTimeout(
Duration.ofSeconds(10)
)
.build();
this.mapper = new ObjectMapper()
.configure(
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
false
);
}
public <T> T get(
String url,
Class<T> responseType
) throws IOException, InterruptedException {
HttpRequest request =
requestBuilder(url)
.GET()
.build();
HttpResponse<String> response =
send(request);
return read(
response,
responseType
);
}
public <T> T get(
String url,
TypeReference<T> responseType
) throws IOException, InterruptedException {
HttpRequest request =
requestBuilder(url)
.GET()
.build();
HttpResponse<String> response =
send(request);
validate(response);
return mapper.readValue(
response.body(),
responseType
);
}
public <T, R> R post(
String url,
T requestBody,
Class<R> responseType
) throws IOException, InterruptedException {
String json =
mapper.writeValueAsString(
requestBody
);
HttpRequest request =
requestBuilder(url)
.header(
"Content-Type",
"application/json"
)
.POST(
HttpRequest.BodyPublishers
.ofString(json)
)
.build();
HttpResponse<String> response =
send(request);
return read(
response,
responseType
);
}
public void delete(
String url
) throws IOException, InterruptedException {
HttpRequest request =
requestBuilder(url)
.DELETE()
.build();
HttpResponse<String> response =
send(request);
validate(response);
}
private HttpRequest.Builder requestBuilder(
String url
) {
HttpRequest.Builder builder =
HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(
Duration.ofSeconds(20)
)
.header(
"Accept",
"application/json"
);
if (token != null &&
!token.isBlank()) {
builder.header(
"Authorization",
"Bearer " + token
);
}
return builder;
}
private HttpResponse<String> send(
HttpRequest request
) throws IOException, InterruptedException {
try {
return client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
} catch (InterruptedException e) {
Thread.currentThread()
.interrupt();
throw e;
}
}
private void validate(
HttpResponse<String> response
) {
int status =
response.statusCode();
if (status < 200 ||
status >= 300) {
throw new ApiException(
status,
response.body()
);
}
}
private <T> T read(
HttpResponse<String> response,
Class<T> responseType
) throws IOException {
validate(response);
if (response.statusCode() == 204 ||
response.body() == null ||
response.body().isBlank()) {
return null;
}
return mapper.readValue(
response.body(),
responseType
);
}
}
And the custom exception:
public class ApiException
extends RuntimeException {
private final int statusCode;
public ApiException(
int statusCode,
String message
) {
super(message);
this.statusCode =
statusCode;
}
public int statusCode() {
return statusCode;
}
}
32. Using the Client
GET one object
RestClient client =
new RestClient();
User user = client.get(
"https://jsonplaceholder.typicode.com/users/1",
User.class
);
System.out.println(user.name());
GET a list
List<User> users = client.get(
"https://jsonplaceholder.typicode.com/users",
new TypeReference<List<User>>() {}
);
Then:
users.forEach(
user ->
System.out.println(
user.name()
)
);
POST an object
CreatePostRequest request =
new CreatePostRequest(
"Java REST Client",
"Using HttpClient and Jackson",
1
);
Post post = client.post(
"https://jsonplaceholder.typicode.com/posts",
request,
Post.class
);
System.out.println(post);
DELETE
client.delete(
"https://jsonplaceholder.typicode.com/posts/1"
);
33. Using an Authenticated Client
If an API requires a bearer token:
String token =
System.getenv("API_TOKEN");
RestClient client =
new RestClient(token);
Every request created by the client will include:
Authorization: Bearer ...
This avoids repeating authentication code everywhere.
34. What About Date and Time Types?
Suppose your API returns:
{
"id": 1,
"createdAt": "2026-09-06T18:30:00"
}
And your model uses:
LocalDateTime
For example:
public record Event(
int id,
LocalDateTime createdAt
) {
}
You should add Jackson's Java Time module.
Maven:
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>2.20.0</version>
</dependency>
Then:
ObjectMapper mapper =
new ObjectMapper();
mapper.findAndRegisterModules();
Or explicitly:
mapper.registerModule(
new JavaTimeModule()
);
For modern Java applications, registering the Java Time module is usually a good idea.
35. Naming Differences Between JSON and Java
Suppose the API returns:
{
"user_name": "Alice"
}
But your Java record uses:
userName
You can map the property explicitly:
import com.fasterxml.jackson.annotation.JsonProperty;
public record User(
@JsonProperty("user_name")
String userName
) {
}
Now Jackson knows:
user_name
↓
userName
36. Ignore JSON Fields You Don't Need
Suppose the server returns:
{
"id": 1,
"name": "Alice",
"internal_code": "ABC",
"created_by": "system",
"something_else": true
}
But your application only needs:
public record User(
int id,
String name
) {
}
You can configure Jackson globally:
mapper.configure(
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
false
);
Or annotate a particular type:
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true)
public record User(
int id,
String name
) {
}
This is very useful because APIs often return more data than your application needs.
37. Don't Put API Logic Everywhere
Without a client layer, application code often becomes:
HttpClient client = ...
HttpRequest request = ...
HttpResponse<String> response = ...
ObjectMapper mapper = ...
User user = ...
inside controllers, services, UI code, scheduled jobs, and other places.
That quickly becomes messy.
Instead:
Application
↓
UserApi
↓
RestClient
↓
HttpClient + Jackson
For example:
public class UserApi {
private final RestClient client;
public UserApi(
RestClient client
) {
this.client = client;
}
public User getUser(
int id
) throws Exception {
return client.get(
"https://example.com/users/" + id,
User.class
);
}
}
Then application code becomes:
User user =
userApi.getUser(10);
This is much easier to read.
38. Create API-Specific Classes
A generic RestClient is useful for infrastructure.
But your application should preferably expose methods related to the domain.
Instead of:
restClient.get(
"https://example.com/users/10",
User.class
);
everywhere, create:
public class UserApi {
private final RestClient client;
private final String baseUrl;
public UserApi(
RestClient client,
String baseUrl
) {
this.client = client;
this.baseUrl = baseUrl;
}
public User findById(
int id
) throws Exception {
return client.get(
baseUrl + "/users/" + id,
User.class
);
}
}
Usage:
User user =
userApi.findById(10);
Now the rest of your application doesn't need to know:
HTTP
URLs
JSON
Jackson
headers
It just works with:
User
39. A Better Architecture
A clean application might look like this:
Application / Service
↓
UserApi
↓
RestClient
↓
HttpClient
+
ObjectMapper
↓
External REST API
Each layer has one responsibility.
HttpClient
Handles HTTP communication.
ObjectMapper
Handles JSON.
RestClient
Handles reusable HTTP + JSON mechanics.
UserApi
Knows the external API's endpoints.
Application
Works with domain objects.
This separation becomes increasingly valuable as the project grows.
40. Synchronous vs Asynchronous APIs
Everything we've built so far uses:
client.send()
which is synchronous.
But Java also supports:
client.sendAsync()
which returns:
CompletableFuture<HttpResponse<String>>
We could create:
public <T> CompletableFuture<T> getAsync(
String url,
Class<T> responseType
)
For example:
public <T> CompletableFuture<T> getAsync(
String url,
Class<T> responseType
) {
HttpRequest request =
requestBuilder(url)
.GET()
.build();
return client.sendAsync(
request,
HttpResponse.BodyHandlers.ofString()
).thenApply(response -> {
validate(response);
try {
return mapper.readValue(
response.body(),
responseType
);
} catch (IOException e) {
throw new RuntimeException(e);
}
});
}
Usage:
CompletableFuture<User> future =
client.getAsync(
url,
User.class
);
future.thenAccept(
user ->
System.out.println(
user.name()
)
);
For many applications, synchronous methods are simpler.
But async methods can be useful when multiple requests can run concurrently.
41. Quick Cheat Sheet
Java object to JSON
String json =
mapper.writeValueAsString(object);
JSON to Java object
User user =
mapper.readValue(
json,
User.class
);
JSON array to List<User>
List<User> users =
mapper.readValue(
json,
new TypeReference<List<User>>() {}
);
GET request
HttpRequest request =
HttpRequest.newBuilder()
.uri(URI.create(url))
.header(
"Accept",
"application/json"
)
.GET()
.build();
POST JSON
String json =
mapper.writeValueAsString(object);
HttpRequest request =
HttpRequest.newBuilder()
.uri(URI.create(url))
.header(
"Content-Type",
"application/json"
)
.POST(
HttpRequest.BodyPublishers
.ofString(json)
)
.build();
Parse response
HttpResponse<String> response =
client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
User user =
mapper.readValue(
response.body(),
User.class
);
Bearer token
.header(
"Authorization",
"Bearer " + token
)
Ignore unknown JSON fields
mapper.configure(
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
false
);
42. The Mental Model
The most important thing to understand is the division of responsibilities.
HttpClient
↓
handles HTTP
ObjectMapper
↓
handles JSON
For a GET:
GET /users/1
↓
HttpClient
↓
JSON response
↓
ObjectMapper
↓
User
For a POST:
CreateUserRequest
↓
ObjectMapper
↓
JSON
↓
HttpClient
↓
POST /users
↓
JSON response
↓
ObjectMapper
↓
User
Once you see the flow this way, building REST clients becomes much easier.
Final Thoughts
Java's built-in HttpClient and Jackson make a very capable combination.
HttpClient gives us:
- HTTP/1.1 and HTTP/2
- GET
- POST
- PUT
- DELETE
- PATCH
- Headers
- Authentication
- Timeouts
- Synchronous requests
- Asynchronous requests
Jackson gives us:
- JSON serialization
- JSON deserialization
- Records support
- Generic type handling
- Custom property mappings
- Date/time support
- Flexible configuration
Together, they allow us to write application code like:
User user =
userApi.findById(10);
instead of spreading this everywhere:
HttpClient
HttpRequest
HttpResponse
ObjectMapper
JSON
status codes
headers
That's the real goal of a good REST API client:
keep HTTP and JSON details at the boundary of your application, while the rest of your code works with normal Java objects.
A good progression is:
HttpClient basics
↓
HttpClient + Jackson
↓
Reusable RestClient
↓
API-specific client classes
↓
Authentication
↓
Retries / rate limits / pagination
↓
Production-ready API integration
The next useful step is building a production-ready Java REST client with retries, rate limiting, pagination, authentication, and custom error responses.
About the Author
Deividas Strole is a Full-Stack Developer based in California, specializing in Java, Spring Boot, JavaScript, React, SQL, and AI-powered applications. He writes about software engineering, modern full-stack development, and digital marketing.
Connect with me:
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.