In his blog post „Programming is a Pop Culture“, Baldur Bjarnason convincingly argues that our industry is a fashion industry, with trends coming and going, and a lack of historical awareness. Right now, according to Bjarnason, the pendulum is in full swing towards an aesthetics of static and strong typing, as he illustrates with quite a few evident examples.

In this blog post series, we are going to look at three typical aspects of the web stack: HTTP routing, HTML templating, and SQL queries. We’re going to compare what’s on offer in Java with what a Rust web stack provides in terms of build-time verification — Rust being a language known for having a stronger type system than most other mainstream languages. For each of the three aspects just mentioned, we will discuss the pros and cons of build-time verification compared to an approach that has no build-time guarantees, where any validation at all will happen at runtime. What do we gain from all these build-time checks, and is it worth the price?

In this first blog post in the series, we will take a look at HTTP routing.

Spring

Most popular Java web frameworks use an annotations-based approach to HTTP routing. For example, Spring MVC and Spring Webflux let you add annotations like @RequestMapping and @GetMapping to a controller class and its methods in order to specify which request path and HTTP method a particular controller method should respond to. In addition, any path variables in that mapping are mapped to parameters of the controller method, each of which needs a @PathVariable annotation:

package com.example.demo.web;  

import org.springframework.stereotype.Controller;  
import org.springframework.web.bind.annotation.*;  

@RequestMapping(path = "/hello")  
@Controller  
public class HelloController {  

    @GetMapping(value = "/{name}", produces = "text/html")  
    @ResponseBody  
    public String hello(@PathVariable("name") String name) {  
        return "<h1>Hello " + name"!</h1>";  
    }  
}

There are no build-time checks at all here: Spring MVC and Spring Webflux do not check at build-time (nor during application start) whether the path variables in the @GetMapping and the @PathVariable annotations of the controller method parameters match, or whether any @PathVariable annotation is missing. If they don’t match, the result will be an Internal Server Error (500) response, and an exception whose message is not that helpful. If one of the @PathVariable annotations is missing, the respective unannotated controller method parameter will simply have the value null.

What about reverse routing? Spring MVC provides a reflection-based API for generating URIs by referring to the respective controller method:

package com.example.demo.web;  

import org.springframework.stereotype.Controller;  
import org.springframework.web.bind.annotation.*;  
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;  

import static org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder.on;  

@RequestMapping(path = "/hello")  
@Controller  
public class IndexController {  

    @ResponseBody  
    public String index() {  
        final var greetDanielUri = MvcUriComponentsBuilder  
                .fromMethodCall(on(HelloController.class).hello("Daniel"))  
                .build()  
                .toUriString();  
        return "<a href=\"" + greetDanielUri + "\">Greetings</a>";  
    }  
}

The API is type-safe, albeit a bit clunky, especially when your controller method has parameters that do not map to path variables or query parameters, for example a Model parameter or a @RequestBody annotated parameter. For these, you need to pass null when using the MvcUriComponentsBuilder.

Clearly, in the Spring universe, you are expected to check the correctness of your HTTP routing using controller tests, and reverse routing has not been at the forefront of the framework design.

Quarkus

One framework that has gained popularity in recent years in the Java community is Quarkus. One of its design goals was to have very fast startup times. This is achieved by doing most of the things at build time that happen at runtime, during application startup, in frameworks like Spring.

Nevertheless, Quarkus does not behave much differently when it comes to HTTP routing. A resource in Quarkus uses the standardised Jakarta REST annotations, which looks very similar to what you do in Spring MVC or Webflux:

package com.example.demo.web;  

import jakarta.ws.rs.GET;  
import jakarta.ws.rs.Path;  
import jakarta.ws.rs.Produces;  
import jakarta.ws.rs.core.MediaType;  

@Path("/hello")  
public class HelloResource {  

    @GET  
    @Produces(MediaType.TEXT_HTML)  
    @Path("/{name}")  
    public String hello(String name) {  
        return "<h1>Hello " + name "!</h1>";  
    }  
}

Nevertheless, there are no build-time checks of tour @Path annotation. Quarkus does fail at build-time, though not at compile-time, if you have a syntax error in one of your @Path annotations. It does not, however, fail if the path parameters in your annotation and the names of your method parameters don’t match. In that case, like in Spring, the respective method parameter is simply null.

Reverse routing is supported via the Jakarta REST UriBuilder class. It works similarly to Spring MVC’s MvcUriComponentsBuilder, only with a less type-safe API (not shown here): The method name needs to be provided as a String, and the arguments to the method are a varargs of type Object. Also, it’s much more difficult to actually build correct URIs when there are @Path annotations on both your resource class and the methods in your resource class.

Just as with Spring, Quarkus does not have any support for build-time checks of your HTTP routing, and type-safe reverse routing is a concept that seems to be absolutely foreign (or unimportant) to the Quarkus designers.

Play

We have seen that the Java mainstream for web development has not been affected by the ongoing trend towards compile-time verifications of correctness. Even so, the Play framework already demonstrated years ago that it’s possible to use a more type-safe approach to HTTP routing and reverse routing.

In Play, you do not annotate your controller methods with request mapping annotations. So our HelloController looks like this:

package com.example.demo.web;  

import play.mvc.Result;  
import play.mvc.Results;  
import play.mvc.Controller;  

public class HelloController extends Controller {  

    public Result hello(String name) {  
        return Results.ok("<h1>Hello " + name "!</h1>");  
    }  
}

Instead of using annotations on controller methods, all routes are defined and mapped to a controller method in a centralised routes file:

GET     /hello/:name    com.example.demo.web.HelloController.hello(name)  
GET     /               com.example.demo.web.IndexController.index()

During build-time, a Routes Scala source file is generated from the routes configuration file. If there is any mismatch between query parameters or path variables and the parameters expected by the controller method, there will be an error at build-time.

Play also generates a reverse router at build-time, which can be used to create links to other routes in a type-safe manner:

package com.example.demo.web;  

import play.mvc.Results;  
import play.mvc.Result;  
import play.mvc.Controller;  

public class IndexController extends Controller {  

    public Result index() {  
        final var greetDanielPath = routes.HelloController.hello("Daniel").path();  
        return Results.ok("<a href=\"" + greetDanielPath + "\">Greetings</a>");  
    }  
}

If our controller expected a parameter of type int or boolean, for example, the generated reverse router would expect those as well.

The Play framework stands out from most of the other Java web frameworks in that it makes use of code generation to ensure that HTTP routes and the respective controller methods really match, and in order to provide a type-safe reverse routing facility. This is probably due to Play’s origin as a Scala web framework (since Play 2.0). In the Scala community, compile-time correctness checks have always been a lot more important than in the Java community.

Beyond Java: Type-safe routing with Rocket

Compared to Java, the Rust programming language has a much stronger type system, and a macro system that makes it relatively easy to implement all kinds of compile-time checks or generate strongly-typed code. All this potential is not lying idle. Rather, it has attracted people who care a lot about compile-time checks of correctness.

When it comes to HTTP routing, a good example is the web framework Rocket. In Rocket, routes are specified using attributes. For someone coming from Java and unfamiliar with Rust, you can think of these as something a little bit similar to Java’s annotations. Here is what our example looks like in Rocket:

use rocket::response::content;

#[macro_use]

extern crate rocket;

#[get("/hello/<name>")]
fn hello(name: &str) -> content::RawHtml<String> {
    content::RawHtml(format!("<h1>Hello, {}!</h1>", name))
}

#[get("/")] 
fn index() -> content::RawHtml<String> {
    content::RawHtml(format!(
        "<a href=\"{}\">Greet Daniel</a>",
        uri!(hello("Daniel")).to_string()
    ))
}

#[rocket::main]
async fn main() -> Result<(), rocket::Error> {
    let _rocket = rocket::build()
        .mount("/", routes![index, hello])
        .launch()
        .await?;
    Ok(())
}

If we have a mismatch between the path variables in our route attribute and the parameters of our hello function, our program will not compile and we will get a meaningful error message.

In the index function, we make use of the uri!macro, which transforms the function call to the hello function into something generating the corresponding URI. In Rocket, URI generation can be fully typed, and the URI generation code is generated at compile-time.

One other interesting aspect is how Rocket handles the integration of custom types into its HTTP routing. Let’s say you want to let clients allow to specifiy a mood query parameter. In your server code, you don’t want to represent this as a string, but rather use an enum. You need some facility to convert from the value in the query string to a value of your custom type.

In Rocket, this is done with a trait. Traits in Rust allow for ad-hoc polymorphism. For query parameters and form fields, Rocket defines the FromFormField trait. For simple enums, an implementation of this trait can be automatically derived. For other custom types, we can implement the trait manually.

Here is what we need to do to support a strongly-typed mood query parameter in Rocket:

#[get("/greeting/<name>?<mood>")]
fn greeting(name: &str, mood: Option<Mood>) -> content::RawHtml<String> {
    let greeting = match mood.unwrap_or_default() {
        Mood::Grumpy => String::from("Get lost!"),
        Mood::Neutral => format!("Hello, {}!", name),
        Mood::Enthusiastic => format!("Hey, {}, so great to see you!", name),
    };
    content::RawHtml(format!("<h1>{}</h1>", greeting))
}

#[derive(FromFormField)]
enum Mood {
    Grumpy,
    Neutral,
    Enthusiastic,
}

impl Default for Mood {
    fn default() -> Self { Mood::Neutral }
}

If we try to use a type for a query parameter for which there is no FromFormField implementation, the Rust compiler will complain with a helpful error message. Java has no equivalent of Rust’s traits, so in Spring, for example, you need to register custom Converters if you want to support custom types as query parameters. No compile-time check is possible that such a converter is indeed available.

The verdict

The Play framework has led a niche existence in the Java community, and this is not likely to change. When it comes to HTTP routing, the mainstream of Java development seems to be hardly affected by the general trend towards compile-time correctness checks in our industry.

As Baldur Bjarnason points out, our pop-culture industry often expects us to go all in on the current fashion. But as responsible-minded developers, we should carefully look at what is to be gained, and what the price is.

When it comes to HTTP routing and reverse routing, there are a few benefits to build-time verification and code generation for reverse routing: You get a lot of correctness checks for free that you don’t have to implement as unit tests. This means that you write less code, which is always good. Test code needs to be maintained as well, and is prone to bugs just like production code.

In addition, reflection-based approaches are slower than ones that rely on generated code. This is true both for request routing as well as for reverse routing.

I have seen Spring projects where a JSON response containing a lot of hypermedia links was incredibly slow. Handcrafting the link generation to eliminate the use of reflection of the reverse routing feature, solved the problem. If you only generate a link here and there, though, this will not be a problem.

Similarly, for request routing, whether the performance overhead of reflection is a problem in practice is a question only you can answer. Do you need to squeeze out every bit of performance to minimise response times?

What’s the price of build-time checks and code generation for HTTP request routing and reverse routing? Well, Play generates Scala source files which need to be compiled by the infamously slow Scala compiler. But that is an implementation detail of Play and doesn’t invalidate the approach as such. In general, it’s a trade-off between developer experience and convenience in terms of fast feedback loops on the one side, and better runtime performance and more confidence in correctness on the other hand.

Rocket shows that compile-time-verified HTTP routing doesn’t necessitate a centralised routes file, as used by Play. So if you prefer decentralised routes, this not an argument against compile-time verified HTTP routing per se. In Java, it would also be possible to use the familiar annotation-based approach with compile-time annotation processing.

Play, on the other hand, shows that what’s available in terms of type-safety and compile-time checks for HTTP routing in Rust can largely be achieved in Java as well. For the most part, Rocket does not provide any stronger compile-time guarantees than what’s provided by Play. One exception is the use of custom types in query parameters, form parameters, or path variables. Unlike all the Java web frameworks, Rocket can verify at compile-time that a conversion to your custom type is actually implemented.

In the upcoming second part of this blog series, we are going to examine the topic of HTML templating.