CodeGym /Courses /Java Server /Short HttpHandler: steps and utilities

Short HttpHandler: steps and utilities

Java Server
Level 23, Lesson 4
Available

1. A short HttpHandler and reading top to bottom

By this point, we already have all the building blocks: route by method + path, reading path/query/headers/body, and sendJson(...) for the response. Now we only need to put them together so that the handler does not turn into a mess. When you start writing server-side code without a framework, it is very easy to fall into a trap: "Well, I am only making one endpoint... I will just do it quickly in handle()." And then a second endpoint appears, then a third, then body, errors, headers — and suddenly handle() becomes a "300-line novel" where JSON is read in the middle and routing appears at the end. That is exactly why we need a short, scenario-driven HttpHandler.

Think of HttpHandler as a clinic receptionist. They do not treat teeth (that is the service/business logic), they do not manufacture implants (the repository/storage), and they do not even write the "medical history" as a domain model. Their job is simpler: receive the person, understand "which doctor," collect minimal data (full name, complaint), pass it along, and return a clear result. In code, that means handle(...) should deal with transport: accept HttpExchange, choose a route, parse the input, call the required branch method, and send the response.

If you keep HttpHandler short, you get a very practical benefit: server behavior reads top to bottom like furniture assembly instructions (just without the surprise "this screw is extra"). If it is long, then any error turns into a quest: "Which part of this monolith did I forget to set Content-Type in?"

2. Step order: from HttpExchange to response

Now we will assemble the request processing "conveyor" so that a stable sequence of actions forms in your head. This is especially important in the pre-Spring world: here nobody will do routing, binding, validation, error mapping, and response writing for you. If you do not keep the step order in mind, you start reading the body before you understand the route, or you send the response twice (and then the server gets offended and silently drops the connection).

It is convenient to think of it this way: handle(...) is the top-level scenario, and the details live in small methods. Schematic view:

flowchart TD
    A["handle(exchange)"] --> B["log method + path"]
    B --> C["route(exchange)"]
    C --> D{route branch}
    D -->|GET /health| E[sendJson 200]
    D -->|POST ...| F["readBody -> readJson DTO"]
    F --> G[build response DTO]
    G --> H[sendJson status]
    C --> I["if route not found -> sendNotFound"]
    C --> J["if transport error -> sendBadRequest"]

For body-less GET branches, after route(...) you usually have enough path/query/headers helpers: get id from path, read filters from query, check the needed header, and move on. For body-bearing branches, the pipeline differs by only one piece: before readBody(...) there is an explicit gate for Content-Type, and then readBody(...), the empty-body check, and readJson(...) follow. It is the same handler conveyor, just with different branches reading different parts of the incoming request.

Pay attention to one thing: routing should not read the body "just in case." The body is read only in branches where it is expected. Otherwise, you do extra work and create more errors: for example, GET without a body suddenly starts failing with "Invalid JSON" because someone decided to always read the body.

From a practical standpoint, we want the handler to do four things predictably: log the incoming request, choose a route, parse input data, and send a response. Everything else should, if possible, live outside the handler, but today we are deliberately staying at the transport level and doing only a "preview" of operations, without the real reading list logic.

3. The ReadingListHandler skeleton: dependencies in the constructor

We need to choose where the handler lives and how it gets its dependencies. In our project, that is the com.example.readlater.readinglist.http package, because it is clearly the web/transport layer of the reading list feature. Inside the handler, we definitely need an ObjectMapper, because we read JSON and write JSON. It is important not to create an ObjectMapper inside every request: that is both slow and architecturally sloppy (plus, later you will not be able to configure JSON centrally).

Let us start with a minimal class skeleton. It still does not know about routes or helpers, but it already shows the right engineering habits: package, final class, Logger, dependency field, and constructor.

package com.example.readlater.readinglist.http;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

final class ReadingListHandler implements HttpHandler {
    // Logger — for diagnosing incoming requests and transport problems.
    private static final Logger log = LoggerFactory.getLogger(ReadingListHandler.class);

    // ObjectMapper is created once in the composition root and passed into the handler.
    private final ObjectMapper objectMapper;

    ReadingListHandler(ObjectMapper objectMapper) {
        // A simple check does not hurt: otherwise NPE will show up "somewhere later".
        this.objectMapper = objectMapper;
    }
}

The skeleton still does not know about routes or helpers, but the two main habits are already in place: dependencies come from the outside, and the handler does not start assembling half the application around itself.

Another point: I deliberately do not make the class public. In a small project, it is useful to start with package-private so that you do not turn every class into "part of the application public API." Inside the readinglist.http package, it is enough for us to see the handler and its helper methods.

4. The handle(...) method: scenario and error handling

Now let us add the top-level handle(...) to this same class. A working version immediately combines two things: incoming request logging and one place to catch transport errors. Otherwise, they spread very quickly across route branches.

@Override
public void handle(HttpExchange exchange) throws IOException {
    // Log the minimum: method + path (no body and no sensitive data).
    log.info("{} {}", exchange.getRequestMethod(), exchange.getRequestURI().getPath());

    try {
        // route(...) contains the specific route branches.
        route(exchange);
    } catch (IllegalArgumentException e) {
        // The client sent invalid data — this is not a "server error".
        log.warn("Bad request: {}", e.getMessage());

        // Return a stable JSON error format and the correct HTTP status.
        sendBadRequest(exchange, e.getMessage());
    }
}

Two engineering habits matter here. First, handle(...) stays short and does not know the route details. Second, handling IllegalArgumentException lives in one place, instead of being spread across handleHealth(...), handleCreatePreview(...), and other branches.

The format of ErrorResponse itself and the low-level JSON helpers are already in place. At this level, the handler simply uses them and does not turn the catch block into a homemade framework.

5. route(...): all routing in one place

route(...) is your "mini train schedule." It says: if GET /health arrives — go there; if POST ... arrives — go here; otherwise — "route not found." It is very tempting to scatter this across different methods ("let every endpoint check itself"), but then you lose the whole picture. When there is a bug in routing, you want to open one method and see with your own eyes what the server actually understands.

At our current level, a simple if/else is enough, with no "genius" abstractions. It is important to keep the order: more specific rules above, more general ones below. And even more important — use an early return after sending the response so that you do not try to send a second response.

import com.sun.net.httpserver.HttpExchange;

import java.io.IOException;

private void route(HttpExchange exchange) throws IOException {
    // Get the method and path exactly once so we do not repeat ourselves or make mistakes in conditions.
    String method = exchange.getRequestMethod();
    String path = exchange.getRequestURI().getPath();

    // The simplest body-less branch.
    if ("GET".equals(method) && "/health".equals(path)) {
        handleHealth(exchange);
        return; // Important: the response has been sent, do not continue.
    }

    // Temporary transport branch: lets us exercise the JSON pipeline without mixing it with business logic.
    if ("POST".equals(method) && "/api/v1/reading-list/preview".equals(path)) {
        handleCreatePreview(exchange);
        return;
    }

    // If nothing matched, return a proper JSON 404.
    sendNotFound(exchange);
}

Here you can immediately see two different branches of the same handler: one lives without a body, the other requires a body and JSON. But the route(...) method itself still does not read the body, parse JSON, or build a response — it only chooses who gets control.

6. Helper methods near the handler

When you write a second or third endpoint, you notice that you repeat the same code fragments: read the body, parse JSON, check Content-Type, send JSON, set Content-Type in the response, count byte length. If you leave this "in place," then in a week you will have five versions of sendJson(...), and guess which one will forget charset=UTF-8 someday.

At the scale of one handler, it is perfectly fine to keep such methods private near route(...). Separate utility classes will only be needed if this code starts repeating across several handlers. For today, a short set of helpers is enough.

Let us start with reading the body. The stream is one-time use, so we read it exactly once and decode it in UTF-8 right away:

import com.sun.net.httpserver.HttpExchange;

import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;

private String readBody(HttpExchange exchange) throws IOException {
    // Read the request body once: the stream is "single-use."
    try (InputStream is = exchange.getRequestBody()) {
        byte[] bytes = is.readAllBytes();
        return new String(bytes, StandardCharsets.UTF_8);
    }
}

For body-bearing branches, you also need an explicit gate for Content-Type:

import com.sun.net.httpserver.HttpExchange;

private void requireJsonContentType(HttpExchange exchange) {
    String contentType = exchange.getRequestHeaders().getFirst("Content-Type");

    if (contentType == null || !contentType.startsWith("application/json")) {
        throw new IllegalArgumentException("Expected Content-Type: application/json");
    }
}

The next helper is reading JSON into a DTO. We catch the IOException from Jackson and turn it into an IllegalArgumentException, so the top-level handle(...) returns 400 Bad Request instead of a stack trace to the client:

import java.io.IOException;

private <T> T readJson(String body, Class<T> type) {
    try {
        // Deserialize into the required DTO type.
        return objectMapper.readValue(body, type);
    } catch (IOException e) {
        // At the transport level, we need to return 400, not "blow up" with a stacktrace.
        throw new IllegalArgumentException("Invalid JSON");
    }
}

And finally — sending JSON. Here there are two "eternal" nuances: Content-Type must be set before sending headers, and length must be counted in bytes.

import com.sun.net.httpserver.HttpExchange;

import java.io.IOException;
import java.io.OutputStream;

private void sendJson(HttpExchange exchange, int status, Object body) throws IOException {
    // Serialize directly to bytes: this is how Content-Length is counted correctly.
    byte[] bytes = objectMapper.writeValueAsBytes(body);

    // Set headers before sendResponseHeaders(...), otherwise they may not end up in the response.
    exchange.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8");
    exchange.sendResponseHeaders(status, bytes.length);

    // It is important to close OutputStream: try-with-resources guarantees closing even if writing fails.
    try (OutputStream os = exchange.getResponseBody()) {
        os.write(bytes);
    }
}

On top of sendJson(...), we keep thin wrappers for standard error branches:

import com.example.readlater.common.error.ErrorResponse;

import java.io.IOException;
import java.util.List;

private void sendBadRequest(HttpExchange exchange, String message) throws IOException {
    sendJson(exchange, 400, new ErrorResponse("BAD_REQUEST", message, List.of()));
}

private void sendNotFound(HttpExchange exchange) throws IOException {
    sendJson(exchange, 404, new ErrorResponse("NOT_FOUND", "Route not found", List.of()));
}

As a result, handler branches read like scenarios rather than a set of low-level stream and byte manipulations.

7. The "preview" endpoint: a full loop without business logic

Now we will make a mini-branch that shows the full request processing loop, but will not touch the reading list logic. This is important: today we are building the transport layer, not CRUD. So the endpoint will be temporary: it will accept JSON, extract a couple of fields, and return them back as a response so that you can test the mechanics through Postman.

For this branch, you do not need a second almost identical DTO. It is enough to use CreateReadingItemRequest, which already describes the shape of the incoming JSON.

import com.example.readlater.readinglist.dto.CreateReadingItemRequest;

import java.io.IOException;
import java.util.Map;

private void handleCreatePreview(HttpExchange exchange) throws IOException {
    // 1) The body-bearing branch first checks the media type.
    requireJsonContentType(exchange);

    // 2) Read the body as a string (once).
    String body = readBody(exchange);

    // 3) Basic transport check: an empty body is a 400.
    if (body.isBlank()) {
        throw new IllegalArgumentException("Request body is required");
    }

    // 4) Parse JSON into the already familiar request DTO.
    CreateReadingItemRequest request = readJson(body, CreateReadingItemRequest.class);

    // 5) Return a short echo response: verify that the transport pipeline is assembled.
    sendJson(exchange, 200, Map.of(
            "title", request.title(),
            "author", request.author()
    ));
}

This preview branch does not pretend to be a real create endpoint. It has one job: run the full body-side pipeline through the handler and not mix it immediately with repository, service, and domain logic.

After that, the whole handler skeleton reads like this:

final class ReadingListHandler implements HttpHandler {
    private static final Logger log = LoggerFactory.getLogger(ReadingListHandler.class);
    private final ObjectMapper objectMapper;

    ReadingListHandler(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @Override
    public void handle(HttpExchange exchange) throws IOException {
        log.info("{} {}", exchange.getRequestMethod(), exchange.getRequestURI().getPath());

        try {
            route(exchange);
        } catch (IllegalArgumentException e) {
            log.warn("Bad request: {}", e.getMessage());
            sendBadRequest(exchange, e.getMessage());
        }
    }

    private void route(HttpExchange exchange) throws IOException {
        String method = exchange.getRequestMethod();
        String path = exchange.getRequestURI().getPath();

        if ("GET".equals(method) && "/health".equals(path)) {
            handleHealth(exchange);
            return;
        }

        if ("POST".equals(method) && "/api/v1/reading-list/preview".equals(path)) {
            handleCreatePreview(exchange);
            return;
        }

        sendNotFound(exchange);
    }

    // The private helper methods remain below:
    // requireJsonContentType(...), readBody(...), readJson(...),
    // sendJson(...), sendBadRequest(...), sendNotFound(...).
}

That is the end-of-day baseline: one short handler, in which body-less and body-bearing branches live side by side, and transport routine does not spread across the project.

8. Project structure: the readinglist.http boundary

Now it is useful to fix the fact that we are building the web layer, not "writing everything wherever it is convenient." If the handler starts containing business rules (for example, "externalId must be unique"), then we silently break the course architecture: transport starts controlling the domain, and after that everything spreads. So the handler should live in readinglist.http, DTOs in readinglist.dto, domain in readinglist.domain, and service in readinglist.service (but we will not touch it today).

Visually, it might look like this:

com.example.readlater
└── readinglist
    ├── dto
    │   └── CreateReadingItemRequest.java
    └── http
        └── ReadingListHandler.java

The ErrorResponse itself logically belongs in shared common.error, because one common error format is needed by more than one feature.

And one more small but important point: ReadingListHandler should be created in the composition root (in your server mode), not create its own ObjectMapper, Logger, and "half the application." Ideally, server mode does something like this: gets ObjectMapper from a shared place (common.json), creates the handler, and registers the context. This is not "beauty for beauty's sake"; it is a way to keep dependencies predictable.

While our handler contains helper methods inside itself, that is acceptable and even good for learning. But if you notice that there are too many of them, it is better to extract them into small helper classes, still staying inside the readinglist.http package. The important thing is not to cross the boundary and build a "framework" that is more complicated than the course task.

9. Common mistakes when assembling an HttpHandler

At the end of the day, it is very tempting to "finish" the handler until it is "well, sort of working," but this is exactly where beginners most often step on rakes that are hard to debug later. The mistakes are usually not in HTTP theory, but in details: order of actions, double response sending, utility duplication, incorrect body length. Let us talk through a few typical scenarios so you can recognize them by smell.

Mistake #1: handle(...) turns into a megamethod that does absolutely everything.
At first, this seems convenient: "Well, I can see all the code in one place." In practice, after a couple of endpoints, you get 150 lines of handle() with branches, where part of the code is duplicated and part depends on variables from another branch. A simple rule helps: handle(...) is read as a top-level scenario, and details go into route(...), handleCreatePreview(...), sendJson(...), and so on.

Mistake #2: creating ObjectMapper inside a route or inside every request.
Technically, that may work, but you pay for extra initialization and lose a single place for JSON configuration. As a result, one endpoint serializes dates one way, another does it differently, and a third suddenly fails on an unknown field. Keep ObjectMapper as a dependency of the handler, passed through the constructor, and create it once in the composition root.

Mistake #3: reading the request body "just in case" before routing.
You read the body at the start of handle(), and then it turns out to be GET /health, which does not need a body. It seems "not scary," but you create problems for yourself: extra work, potential blocking on reading, and most importantly, you lose the discipline of "route first, then parse." Read the body only in branches where it is expected.

Mistake #4: wrong response length in sendResponseHeaders(...).
A very common trap: send exchange.sendResponseHeaders(status, json.length()), where json is a string. For ASCII this sometimes "accidentally works," but for UTF-8 (especially with non-English text) the length in characters does not equal the length in bytes, and the client may receive a truncated response. The solution is simple: serialize to byte[] and use bytes.length.

Mistake #5: forgetting to set Content-Type or setting it "after sending headers."
Headers are not wishes, they are part of the contract. If Content-Type is not set, the client may try to interpret JSON as text/plain, or vice versa. And if you set the header after sendResponseHeaders(...), it may no longer make it into the response. So we set Content-Type before sending headers, always.

1
Task
Java Server, level 23, lesson 4
Locked
Short `GreetingHandler` with centralized error handling
Short `GreetingHandler` with centralized error handling
1
Task
Java Server, level 23, lesson 4
Locked
Short `EchoHandler` with query and body in one place
Short `EchoHandler` with query and body in one place
1
Survey/quiz
HTTP routing, level 23, lesson 4
Unavailable
HTTP routing
Routes and request handling
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION