What a Framework Adds

See which hand-written pieces of this API a framework such as Spring Boot replaces, how it does it, and when plain JDK code is still the right call.

Syntax@GetMapping("/{id}") Task get(@PathVariable long id) { ... } // is a route table entry + path parsing + JSON writing, generated from annotations

The previous lessons built a working REST API from the JDK alone: routing, JSON, validation, problem responses and paging. Doing it once by hand shows that a framework is not magic. It is the same code, written once, tested by many people and driven by annotations and reflection instead of copy and paste.

What you wrote, and what replaces it

By handSpring Boot
Route table, path regexes, 405 handling@GetMapping, @PathVariable
Reading and writing JSON strings@RequestBody and return values, serialised by Jackson
Field checks collected into a mapJakarta Bean Validation: @Valid, @NotBlank, @Size
Exception to status mapping@RestControllerAdvice, ProblemDetail
new-ing repositories and services in mainDependency injection into constructors
HttpServer.create, executorsAn embedded server (Tomcat by default) with tuned thread pools
Reading environment variablesExternalised configuration and profiles

On top of that come things that are a lot of work to write well yourself: authentication and authorisation, health and metrics endpoints, tracing, database transactions, and test support that starts the application in a test.

The same task endpoint in Spring Boot looks like this:

@RestController
@RequestMapping("/tasks")
class TaskController {
    private final TaskRepository repository;

    TaskController(TaskRepository repository) {   // injected by the container
        this.repository = repository;
    }

    @GetMapping("/{id}")
    Task get(@PathVariable long id) {             // path parsing and conversion
        return repository.findById(id)
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
    }

    @PostMapping
    ResponseEntity<Task> create(@Valid @RequestBody NewTask body) {   // JSON + validation
        Task task = repository.save(body.toTask());
        return ResponseEntity.created(URI.create("/tasks/" + task.id())).body(task);
    }
}

How it works underneath

At startup the framework scans your classes, reads annotations through reflection, builds a route table and creates objects in dependency order. Per request it matches the route, converts path and body into method arguments, calls your method and serialises the result. The examples below build tiny versions of each of those steps so the behaviour stops being a black box when you debug it.

The costs

More startup time and memory, a large dependency tree you must keep patched, and behaviour that happens away from your code, which makes errors harder to trace when you do not know the mechanism. These are usually worth paying for a product API with a team behind it.

When the JDK alone is enough

A fake server in a test, a small internal tool, a health or metrics sidecar, a command-line program that briefly serves a callback URL. For those, com.sun.net.httpserver plus a JSON library keeps the deployable small and the behaviour obvious.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Explain to a team why a @Transactional method called from inside its own class did not start a transaction.
  • Decide that a small internal webhook receiver stays on the JDK server instead of pulling in a full framework.
  • Debug a Spring endpoint returning 404 by knowing that routes come from scanned annotations and path templates.

More examples

A filter chain, the idea behind servlet filters and interceptors

Security, logging, CORS and request ids are all filters around your handler in Spring and in servlet containers. Order matters: put logging outside auth so rejected requests are logged too.

Example · java
record Request(String method, String path, Map<String, String> headers) {}
record Response(int status, String body) {}

interface Handler {
    Response handle(Request request);
}

interface Filter {
    Response filter(Request request, Handler next);
}

// Wrap the endpoint so the first filter in the list runs first.
Handler chain(List<Filter> filters, Handler endpoint) {
    Handler handler = endpoint;
    for (Filter filter : filters.reversed()) {
        Handler next = handler;
        handler = request -> filter.filter(request, next);
    }
    return handler;
}

void main() {
    Filter logging = (req, next) -> {
        IO.println("-> " + req.method() + " " + req.path());
        Response res = next.handle(req);
        IO.println("<- " + res.status());
        return res;
    };
    Filter auth = (req, next) -> "Bearer test-token".equals(req.headers().get("Authorization"))
        ? next.handle(req)
        : new Response(401, "missing or invalid token");
    Handler report = req -> new Response(200, "monthly report");

    Handler app = chain(List.of(logging, auth), report);
    IO.println(app.handle(new Request("GET", "/reports", Map.of())));
    IO.println(app.handle(new Request("GET", "/reports", Map.of("Authorization", "Bearer test-token"))));
}

// Output:
// -> GET /reports
// <- 401
// Response[status=401, body=missing or invalid token]
// -> GET /reports
// <- 200
// Response[status=200, body=monthly report]

Constructor injection with a tiny container

This is what a Spring application context does at startup, with far more checks: it reads constructor parameter types and builds the graph from the bottom up. Swapping the binding to a JDBC repository changes one line and no class that uses it.

Example · java
interface OrderRepository {
    List<String> findAll();
}

record InMemoryOrderRepository() implements OrderRepository {
    public List<String> findAll() { return List.of("order-1", "order-2"); }
}

record OrderService(OrderRepository repository) {
    int count() { return repository.findAll().size(); }
}

record OrderController(OrderService service) {
    String summary() { return "orders: " + service.count(); }
}

class Container {
    private final Map<Class<?>, Class<?>> bindings = new HashMap<>();
    private final Map<Class<?>, Object> singletons = new HashMap<>();

    <T> void bind(Class<T> type, Class<? extends T> implementation) {
        bindings.put(type, implementation);
    }

    // Build what the constructor needs first, then the object itself; keep one instance per type.
    <T> T get(Class<T> type) throws ReflectiveOperationException {
        Object existing = singletons.get(type);
        if (existing != null) return type.cast(existing);
        Class<?> implementation = bindings.getOrDefault(type, type);
        Constructor<?> constructor = implementation.getDeclaredConstructors()[0];
        Class<?>[] needs = constructor.getParameterTypes();
        Object[] args = new Object[needs.length];
        for (int i = 0; i < needs.length; i++) args[i] = get(needs[i]);
        IO.println("creating " + implementation.getSimpleName());
        T instance = type.cast(constructor.newInstance(args));
        singletons.put(type, instance);
        return instance;
    }
}

void main() throws ReflectiveOperationException {
    Container container = new Container();
    container.bind(OrderRepository.class, InMemoryOrderRepository.class);
    OrderController controller = container.get(OrderController.class);
    IO.println(controller.summary());
    IO.println("same service: " + (controller.service() == container.get(OrderService.class)));
}

// Output:
// creating InMemoryOrderRepository
// creating OrderService
// creating OrderController
// orders: 2
// same service: true

Validation annotations read through reflection

Jakarta Bean Validation works the same way, reading constraint annotations at runtime and running a validator for each. The rules live next to the fields they describe, and every endpoint that accepts the record gets the same checks.

Example · java
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.RECORD_COMPONENT)
@interface NotBlank {}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.RECORD_COMPONENT)
@interface Max {
    int value();
}

record NewTask(@NotBlank String title, @Max(5) int priority) {}

List<String> validate(Record body) throws ReflectiveOperationException {
    List<String> errors = new ArrayList<>();
    for (RecordComponent c : body.getClass().getRecordComponents()) {
        Object value = c.getAccessor().invoke(body);
        if (c.isAnnotationPresent(NotBlank.class) && (value == null || value.toString().isBlank())) {
            errors.add(c.getName() + ": must not be blank");
        }
        Max max = c.getAnnotation(Max.class);
        if (max != null && (Integer) value > max.value()) {
            errors.add(c.getName() + ": must be at most " + max.value());
        }
    }
    return errors;
}

void main() throws ReflectiveOperationException {
    IO.println(validate(new NewTask("Ship release", 3)));
    IO.println(validate(new NewTask(" ", 9)));
}

// Output:
// []
// [title: must not be blank, priority: must be at most 5]

Discussion

  • Be the first to comment on this lesson.