Dev.to WebDev 🛠 Dev 👁 0 📖 19 min read

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:

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.