skip to content
Scott's Ramblings
Jisca Lucia, Unsplash

From Systems to Services II: Java & Quarkus

/ 14 min read

Another day, another postIt note pushed dramatically from one side of the productivity whiteboard to the other. Although your industry has become perversely obsessed with the performance art of the agile excuse - sentences beginning with “I know this task was only 2 story points, but …” - this one was actually straightforward. You’ve spent a bunch of time building with Java & Quarkus, and this proof-of-concept ticket was an easy one to knock out. Nonetheless, in the dual spirit of the language-and-framework battle royale and providing a reasonable example for the more junior developers in your team - who don’t all have the same experience - a concrete walkthrough of how your target architecture could work. You put your thoughts down in notes.

But first, why Quarkus?

… your junior, less-enlighted coworkers ask. “Well”, you begin, sighing deeply to underline the extensive experience you are about to allude to:

  • It benefits from Java’s broad, performant ecosystem, but is unencumbered by its long history.
  • It’s not Spring, you don’t need an EJB container, and you don’t need to give Oracle any more money if you don’t want. Larry’s doing fine.
  • Container first, but can run on serverless too. It does clever things like compile-time dependency injection, and has first class support for async programming. It is fast, and cares about having a tiny startup time and memory footprint.
  • It has a broad palette of extensions, that let you with a single quarkus extension add quickly bolt external services onto your app in an opinionated fashion. Want a PostgreSQL database? Here! Also - a container running it will be automagically wired into your developer’s local environments when they launch the service.
  • Can be compiled to Graal out-of-the-box, a fancy tool for building native binaries out of Java applications, which gives us a lever to make things “native fast” without much effort

You paste this into the team slack, and await the inevitable stream of :thumbsup: and :cooldoge: emojis to follow. You wait some more. Perhaps long-winded bulleted lists are not the way to win the hearts and minds of the people? I guess you’ll need to actually show them what to get excited about.

Bootstrapping

You’ve not started a new Quarkus project for a while, so you jump over to the getting started page to make sure your environment you’ve got all the shiny new things:

Java
# Install Quarkus
curl -Ls https://sh.jbang.dev | bash -s - trust add https://repo1.maven.org/maven2/io/quarkus/quarkus-cli/
curl -Ls https://sh.jbang.dev | bash -s - app install --fresh --force quarkus@quarkusio
# Create our app
quarkus create app passes-java
# Looking for the newly published extensions in registry.quarkus.io
# -----------
#
# applying codestarts...
# 📚 java
# 🔨 maven
# 📦 quarkus
# 📝 config-properties
# 🔧 tooling-dockerfiles
# 🔧 tooling-maven-wrapper
# 🚀 rest-codestart
#
# -----------
# [SUCCESS] ✅ quarkus project has been successfully generated in:
# --> /Users/scott.gerring/Documents/code/systems-to-services/passes-java
# -----------
# Navigate into this directory and get started: quarkus dev

At this point, we actually have a running, hot-reloading developer environment! We can simply run quarkus dev to get started:

Terminal window
Listening for transport dt_socket at address: 5005
__ ____ __ _____ ___ __ ____ ______
--/ __ \/ / / / _ | / _ \/ //_/ / / / __/
-/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/
2024-11-26 10:32:21,234 INFO [io.quarkus] (Quarkus Main Thread) passes-java 1.0.0-SNAPSHOT on JVM (powered by Quarkus 3.16.4) started in 0.934s. Listening on: http://localhost:8080
2024-11-26 10:32:21,236 INFO [io.quarkus] (Quarkus Main Thread) Profile dev activated. Live Coding activated.
2024-11-26 10:32:21,236 INFO [io.quarkus] (Quarkus Main Thread) Installed features: [cdi, rest, smallrye-context-propagation, vertx]
--
Tests paused
Press [e] to edit command line args (currently ''), [r] to resume testing, [o] Toggle test output, [:] for the terminal, [h] for more options>

Connecting to http://localhost:8080, you even get a jazzy Dev UI that looks like it’ll come in handy when there’s a bit more going on in your apps. ORMs! Messaging! Beans!

quarkus-home.png

From the Home tab, you can see that you’ve got a /hello endpoint. The code looks like this:

package org.acme;
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 GreetingResource {
@GET
@Produces(MediaType.TEXT_PLAIN)
public String hello() {
return "Hello from Quarkus REST";
}
}

Super straightforward, right? And we’re even using the standard jakarta.ws annotations to define our handlers; there’s nothing quarkus-specific here.

Adding external services

You know you’re going to need integration with PostgreSQL, Kafka, OpenTelemetry, and that OpenStreetMaps upstream API. You also know that you want to everything to be async/reactive where possible. Diving through the onboarding guide, you find what you need

In each cases, a simple quarkus extension add {...} from the CLI updates your app. Remembering panache - a simplified ORM wrapper - and your undying focus on developer experience, you quickly bolt this on too, to provide a simplified ORM layer.

Next, having let the framework frame out the shape of your service, you get into writing some actual code

Actual coding!

The Database bit

You start by defining your data model, so that you have something to bind back to your REST API. As you’ve picked Panache, this is super straightforward, and very similar to the ORM mapping attributes you’d see in most other languages:

@Entity
@Table(name = "passes")
public class Pass extends PanacheEntityBase {
// We extend PanacheEntityBase, so we can customize ID, so we can use a linear sequence generator.
// This makes testing easier, as our IDs don't jump around!
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "passes_seq")
@SequenceGenerator(name = "passes_seq", sequenceName = "passes_SEQ", allocationSize = 1)
public Long id;
@Column(name = "name")
public String name;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "country_id", nullable = false)
public Country country;
// .... and so on ....
}

Probably actually your granddad's ORM mapping; some things are timeless.

It occurs to you that you probably want some sample data, so you take advantage of quarkus’ import.sql feature to drop some sample data in; not very “production ready” - you’d need to use something like flyway for that - but perfect for knocking out a PoC.

INSERT INTO country (shortcode, name) VALUES ('CH', 'Switzerland');
INSERT INTO passes (id, name, country_id, ascent, latitude, longitude) VALUES
(nextval('passes_seq'), 'Albis Pass', 'CH', 793, 47.276, 8.522),
/* und so weiter */
)

Seeding test data in an ordinary SQL-ish fashion

Starting your app app, you are pleased to remember that Quarkus has gone and spun up a local PostgreSQL container for you, and plumbed it into your development environment! Time to add an API and start to push some data in and out.

The API bit

Next up, you go to knock up the HTTP API you designed earlier. This is very much on-rails stuff, you think, and don’t expect much friction. The GetAll is straighforward enough, although you have to re-check some examples to remember mutiny, the reactive programming library used by Quarkus, works:

@Path("/api/v1/passes")
public class PassResource {
@GET
@Produces(MediaType.APPLICATION_JSON)
public Uni<List<PassDTO>> getAll() {
return Pass.<Pass>findAll().list() // panache, making data access easy
.map(passes -> passes.stream()
.map(pass -> new PassDTO(pass.id, pass.name, pass.country.getName(),
pass.ascent, pass.latitude, pass.longitude))
// Functional programming in Java can never be beautiful,
// you sadly remind yourself as you type the necessary
// incantation
.collect(Collectors.toList()));
}

Implementing a GetAll using Mutiny and Panache

Once you’ve remember the shape of the necessary incantations, you quickly finish off the other endpoints, and drop a stub in place for the image endpoint for good measure. Jumping back into your Quarkus dev console, you can see your new endpoints:

quarkus-endpoints

You knock out some quick curls to make sure it works, then encode them into unit tests for good measure, and again relishing the quarkus magic that provides you an actual running database without any extra ado for your test environment:

PassResourceTest.java
@QuarkusTest
public class PassResourceTest {
@BeforeEach
public void prepareDb() {
// Setup some test data
}
@Test
public void testUpdatePass() {
given()
.contentType("application/json")
.body("{\"name\":\"Updated Pass\",\"ascent\":1600,\"country\":\"Switzerland\"}")
.when().put("/api/v1/passes/2")
.then()
.statusCode(200)
.body("name", is("Updated Pass"))
.body("country", is("Switzerland"))
.body("ascent", is(1600));
}
// ...
}

The Kafka bit

If Franz Kafka had known about message brokers, The Metamorphosis would have been about a dev waking up one morning to find their streams mysteriously unpartitioned, and an ops team demanding the submission of arcane Jiras to resolve it, you think to yourself, as you peruse the Kafka documentation once again.

Fortunately in this case you don’t actually have to run this thing in production, which means that paritioning, zookeeper, and rentention policies are all a tomorrow problem. Quarkus dev services to the rescue once more! Freed of the need to do any of the hard parts, setting up Kafka is actually kinda easy, you think to yourself:

PassResource.java
// An async channel to write our events out
@Channel("passes")
MutinyEmitter<PassEvent> passEventEmitter;
// Emits an event (Created / Updated / Deleted) for a given pass
private Uni<Void> emitEvent(PassEvent.EventType et, long id, String name, String countryName, int ascent) {
PassEvent event = new PassEvent(id, name, countryName, ascent, et);
return passEventEmitter.send(event);
}
// Encouraged by a sense of _radical transparency_, you decide
// to highlight the gnarliest-looking change to your REST API to
// support emitting events - the PUT method.
//
// "Nothing some inline comments won't fix!" you mutter to yourself,
// as you set out to diminish the horror.
@PUT
@Path("/{id}")
@Produces(MediaType.APPLICATION_JSON)
public Uni<Response> updatePass(@PathParam("id") Long id, PassDTO passDTO) {
return Pass.<Pass>findById(id)
.onItem().ifNotNull().transformToUni(pass ->
Country.find("name", passDTO.country).firstResult() // Does the country exist?
.onItem().ifNotNull().transformToUni(country -> { // If it does, chuck it on the pass
pass.name = passDTO.name;
pass.ascent = passDTO.ascent;
pass.country = (Country) country;
return Panache.withTransaction(() -> pass.persist() // try to write the pass
.onItem().transformToUni(updated -> // Map the saved model back out to our DTO
emitEvent(PassEvent.EventType.Updated, pass.id, pass.name, pass.country.getName(), pass.ascent)
.replaceWith(Response.ok(
new PassDTO(pass.id, pass.name, pass.country.getName(), pass.ascent, pass.latitude, pass.longitude))
.build())));
})
// If we can't find the country or the pass, return sensible errors
.onItem().ifNull().failWith(() -> new WebApplicationException("Country not found", Response.Status.BAD_REQUEST)))
.onItem().ifNull().failWith(() -> new WebApplicationException("Pass not found", Response.Status.NOT_FOUND));
}

So … that was not as aesthetically pleasing as you remember. But also - you told yourself you’d do everything async, and you like writing in a functional style, so you have to consider the possibility you are your own worst enemy here. Besides, your blog supports horizontal scrollbars.

Regardless - you plough on, adding events to the other mutating handlers - and are pleased to discover, once again, that everything has been automagically plumbed up, and you can even see your messages flying through on the dev console!

quarkus-kafka

The Parallel HTTP Client bit

Next, you remember you promised to chuck in an API to render aerial images of the passes. Something about “concurrency” and “upstream HTTP requests”. You spent a bunch of time doing geospatial in the past, and a vague sense of dread creeps down your spine. Coordinate systems? WGS84? Nah - that’s all noise; let’s just munge some OpenStreetMaps tiles together and try and make sure they probably mostly cover the coordinates given. After literal hours of messing about in a an idiotic coordinate system, you produce something that roughly works. Aware that your colleagues already think you might be taking this thing a little too seriously, you choose to only highlight the bits that are relevant for the PoC - making HTTP calls, and dealing with concurrency - whilst obscuring the rest of the sausage-making infrastructure.

First, we need an interface to represent the OSM API that we can inject into our code:

OsmTileClient.java
@Path("/")
@RegisterRestClient
interface OsmTileClient {
@GET
@Path("{z}/{x}/{y}.png")
@ClientHeaderParam(name = "User-Agent", value = "passes-java-demo")
Uni<byte[]> fetchTile(@PathParam("z") int zoom,
@PathParam("x") int x,
@PathParam("y") int y);
}

Then, we need to use it:

@ApplicationScoped
public class OsmImageGenerator {
// ....
// Grab our REST client
@RestClient
OsmTileClient osmTileClient;
/**
* Generates an aerial image of a given area based on latitude,
* longitude, radius (km), and image size.
*
* @return A `Uni<byte[]>` containing the generated image as a PNG byte array.
*/
@WithSpan // We'll come back to this later!
public Uni<byte[]> generateImage(double lat, double lon, double radiusKm, int sizePixels) {
LOG.info("Starting image generation...");
LatLong center = new LatLong(lat, lon);
return Uni.createFrom().item(() -> computeBoundingBox(center, radiusKm, sizePixels))
.flatMap(tileBox -> fetchTilesAsync(tileBox)
.flatMap(tiles -> assembleImage(tileBox, tiles, sizePixels)));
}
/**
Fetches all OSM times referenced in the given tileBox async in parallel
**/
private Uni<Map<TileCoordinate, BufferedImage>> fetchTilesAsync(ConstrainedTileBox tileBox) {
Set<TileCoordinate> coordinates = generateTileCoordinates(tileBox.getTileBox());
return Multi.createFrom().items(coordinates.stream())
.onItem().transformToUniAndMerge(tile -> osmTileClient.fetchTile(tile.z, (int) tile.x, (int) tile.y)
.onItem().transform(tileBytes -> {
try {
return Map.entry(tile, ImageIO.read(new ByteArrayInputStream(tileBytes)));
} catch (IOException e) {
throw new RuntimeException("Failed to decode tile image", e);
}
}))
.collect().asMap(Map.Entry::getKey, Map.Entry::getValue);
}
/**
Lots of other nasty stuff down here, you think, pressing the delete key
before submitting the file to your Quarkus writeup.
**/

The Observability Bit

Once again, you think to yourself, Quarkus has made this all a little too easy - a quick quarkus extension add opentelemetry, a couple of properties dropped into the configuration, and Bob is your metaphorical uncle.

Reflecting back on that one time you worked for an observability company, you remember the three signals - logs / traces / metrics. Aware that you often want to customize these things, and the ability to do so is something worth testing out for this stack, you add a custom span to your telemetry, to capture the image generation:

@WithSpan
public Uni<byte[]> generateImage(double lat, double lon, double radiusKm, int sizePixels) {
LOG.info("Starting image generation...");

Adding a custom span to capture imageGeneration

And a metric to capture request rates against the different endpoints:

// Our counter
LongCounter apiCallCounter;
// Inject the meter provider into the pass
public PassResource(Meter meter) {
apiCallCounter = meter.counterBuilder("get-pass-calls")
.setDescription("Calls made to fetch from the pass APIs")
.setUnit("invocations")
.build();
}
// Then, bump the counter for each request type, e.g.
@GET
@Produces(MediaType.APPLICATION_JSON)
public Uni<List<PassDTO>> getAll() {
apiCallCounter.add(1, Attributes.of(AttributeKey.stringKey("api"), "getPasses"));
// ...

Counting request rates per API resource

These days everything supports OTLP, so you point your app at Datadog’s agent. This is going to make it easy to compare performance beween stacks, you think to yourself!

But is it fast?

You have been on the internet for long enough to know two things to be true:

  • When it comes to battles of the language, everyone freaks out about throughput - how many requests-per-second (RPS) you can push through your technology-of-choice
  • Comparing throughput is extremely frought

So, you figure, its important to show some attention to performance, but not freak out about it. In addition to your request rate and process metric monitoring in Datadog, you set up a quick supporting environment for the different pieces you need - a local PostgreSQL install - uncontainerized, an instance of Kafka, and a basic Locust test script for both the JSON-y API and the image API. To keep things simple you’ll run this all on your rather overpowered Macbook and keep an eye on the usage of the supporting services to make sure that they aren’t causing performance bottlenecks.

from locust import HttpUser, TaskSet, task
class UserBehavior(TaskSet):
@task(1)
def example_task(self):
self.client.get("/api/v1/passes/10")
# self.client.get("/api/v1/passes/10/image")
class WebsiteUser(HttpUser):
tasks = [UserBehavior]

A simple locustfile to load test the API

And with that, we have load test results for the JVM:

JVM Load Test

And, GraalVM:

GraalVM Load Test

In both cases, the latency-over-time looks like this - very “steady state”-ish:

Latency over time

So - we have a baseline! About 20k requests per second, and somewhere under 1 GiB of memory usage. Interestingly enough, a Graal native image is slightly slower than the JVM - both in terms of request latency and throughput - but uses dramatically less RAM.

To put that in context, that native-image build took 90s, in comparison to 10s for the JVM build. You remind yourself that comparing performance numbers is a slippery slope, but feel like looking at these numbers, you’d probably only bother with native-image for this workload if memory consumption was a driving constraint.

The “Colleagues” Pass Judgement

With great anticipation at the unveiling of the first of your three-part masterpiece proof-of-concept series, you drop a link to your PoC into the team chat. Once again, the engagement you had visualised does not appear. Someone posts a snarky meme implying Java users are boomers. Someone else observes that those request rates are “actually kinda good?!”. As you let the praise wash over you, you crack open a new terminal and type go mod init. The terror sets in.