Ten Reasons for Spring Developers to Explore Quarkus
A hands-on look at Quarkus' developer experience advantages, focusing on coding constructs, testing, and configuration and not just speed.
Okay, fellow Spring developers, let's talk. I've spent a significant chunk of my career deep in the trenches with the Spring Framework. From building large monoliths back in the XML configuration days to crafting small microservices with Spring Boot, I've seen its evolution and appreciate the power and ecosystem it brings. Spring is familiar, robust, and has a massive community – it’s the comfortable chair in our developer living room.
But even the most comfortable chair doesn't fit every room perfectly. As we increasingly build for the cloud, focus on containers, and fight with optimizing resource usage (both for cost and performance), other frameworks warrant a serious look. I’ve spent the last few years heavily involved with Quarkus. While it often gets highlighted for its blazing fast startup and low memory footprint (which are fantastic, don't get me wrong), today I want to focus on something closer to our daily grind: the developer experience.
Forget runtime metrics for a moment. Let's talk about the "developer joy" aspects. The things that make coding, testing, and iterating smoother, faster, and maybe even a bit more fun. Here are ten reasons why, as a Spring developer, you should give Quarkus a serious look, focusing purely on the development workflow and programming constructs. Here’s my take on why Quarkus is worth your time, specifically looking at how it changes the feel of development:
1. Live Coding That Just Works
We've all been there with Spring Boot: change a line of code, maybe add a dependency, and wait… wait for the application to restart. Spring DevTools helps, offering class reloading, but it has its limitations. Sometimes it requires a full restart anyway, especially for configuration changes, dependency updates, or more significant refactoring. This constant stop-start cycle breaks flow and adds up significantly over a day.
Quarkus takes a different approach with its live coding feature. Because Quarkus does so much work at build time, it can often handle changes incredibly quickly during development. You run your application using quarkus dev
(or via your IDE), make a code change (Java source, resource file, configuration), hit refresh in your browser (or re-send an API request), and boom – the change is reflected almost instantly.
Imagine a simple JAX-RS endpoint:
// src/main/java/org/acme/GreetingResource.java
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 RESTEasy Reactive";
}
}
Run mvn quarkus:dev
. Access /hello
. Now, change the return string to "Hello Quarkus Live Coding!"
. Save the file. Refresh your browser. The new message appears immediately. Add a System.out.println("Endpoint hit!");
inside the method, save, hit the endpoint again – the log appears in the console where quarkus dev
is running. Add a new dependency to your pom.xml
that brings in a new feature (like quarkus-jsonb
), save the pom.xml
, and Quarkus will often detect the change and seamlessly restart the dev mode process, incorporating the new capability without manual intervention. This tight feedback loop is a significant productivity boost.
2. Dev Services: Automatic Test/Dev Infrastructure
Setting up local development environments can be a pain. You need a database, maybe a Kafka broker, a Redis instance, etc. Traditionally, you might install these locally, run them via Docker Compose, or rely on shared remote instances. This adds setup complexity and potential configuration drift.
Quarkus Dev Services tackles this head-on. When you run Quarkus in dev mode (quarkus dev
) or during tests (@QuarkusTest
), if it detects you need an external service (like PostgreSQL, Kafka, Redis, Mockito, etc.) based on your dependencies and you haven't configured an explicit connection, Quarkus automatically starts a containerized instance of that service (using Testcontainers behind the scenes). It wires your application to use it, and shuts it down when you're done.
Add the PostgreSQL JDBC driver extension: mvn quarkus:add-extension -Dextensions="quarkus-jdbc-postgresql"
. Don't configure any quarkus.datasource.url
, username
, or password
in your application.properties
. Just run mvn quarkus:dev
. Quarkus will detect the need for a PostgreSQL database, pull the image (if needed), start a container, configure your application to point to it, and log the details. You can immediately start using JPA or JDBC without manually setting up a database. The same applies when running @QuarkusTest
– your integration tests get a fresh, ephemeral database instance automatically. This drastically simplifies local setup and testing.
3. Unified and Simplified Configuration
Spring Boot's configuration system is powerful, with profiles (application-dev.properties
, application-prod.properties
), property sources, and complex precedence rules. It works, but sometimes tracking down where a specific property value comes from can involve some digging.
Quarkus uses a single application.properties
file (located in src/main/resources
) by default. Profile-specific configurations are handled using a straightforward prefix syntax: %profileName.property.key=value
. For instance, %dev.quarkus.datasource.url=jdbc:postgresql://localhost:5432/devdb
sets the datasource URL only for the dev
profile. Quarkus performs validation of configuration properties at build time, catching typos or incorrect values early, rather than at runtime.
# src/main/resources/application.properties
# Default message
greeting.message=Hello
# Database URL for production profile
%prod.quarkus.datasource.url=jdbc:postgresql://prod-db.example.com:5432/mydatabase
%prod.quarkus.datasource.username=produser
%prod.quarkus.datasource.password=prodsecret
# Database URL for dev profile (often auto-configured by Dev Services if left out)
%dev.quarkus.datasource.url=jdbc:postgresql://localhost:5432/devdb
%dev.quarkus.datasource.username=devuser
%dev.quarkus.datasource.password=devsecret
# Property specific to test profile
%test.my.special.property=testing123
Inject this configuration using MicroProfile Config annotations:
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import org.eclipse.microprofile.config.inject.ConfigProperty;
@Path("/config")
public class ConfigResource {
@Inject
@ConfigProperty(name = "greeting.message")
String message;
@GET
public String getConfig() {
return "The message is: " + message;
}
}
This feels direct and having profile variations in one place can improve clarity for moderately complex scenarios.
4. Panache: A Different Take on Data Access
Spring Data JPA provides a powerful repository pattern. You define an interface extending JpaRepository
, and Spring Boot auto-implements basic CRUD operations and allows custom query methods based on naming conventions or @Query
annotations.
Quarkus offers Panache, which comes in two flavors: the Active Record pattern and the Repository pattern. With the Active Record pattern (often preferred for simpler cases), your JPA entity extends PanacheEntity
(or PanacheEntityBase
if you have a custom ID). This directly adds static methods for common operations to your entity class itself.
// src/main/java/org/acme/Person.java
// Active Record
package org.acme;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Entity;
@Entity
public class Person extends PanacheEntity { // id is automatically provided
public String name;
public int age;
// Find by name convenience method
public static Person findByName(String name){
return find("name", name).firstResult();
}
// Find people older than a certain age
public static List<Person> findOlderThan(int age) {
return list("age > ?1", age);
}
}
// Usage in a service or resource:
@ApplicationScoped
public class PersonService {
@Transactional
public Person createPerson(String name, int age) {
Person person = new Person();
person.name = name;
person.age = age;
person.persist(); // Persist the entity directly
return person;
}
public Person findById(Long id) {
return Person.findById(id); // Static method on the entity
}
public List<Person> getAdults() {
return Person.findOlderThan(18); // Use custom static query method
}
}
For those who prefer separating data logic, the Panache Repository pattern (PanacheRepository<YourEntity>
) feels closer to Spring Data, but still offers Panache's simplified query language and methods. For many common CRUD and query scenarios, Panache can feel more direct and require less boilerplate than defining separate repository interfaces.
5. RESTEasy Reactive: Unified Blocking and Reactive Endpoints
Spring gives us two distinct web stacks: Spring MVC (servlet-based, primarily blocking) and Spring WebFlux (reactive, non-blocking). Choosing between them often happens early and influences how you write controllers and services.
Quarkus, with its RESTEasy Reactive extension (the default for REST), aims to unify this. You write your JAX-RS resource methods, and Quarkus intelligently handles the execution based on the method signature. If your method returns a standard object (like String
, List<MyObject>
, MyObject
), Quarkus typically runs it on a worker thread (blocking allowed). If your method returns a reactive type from Mutiny (Quarkus's reactive library, e.g., Uni<String>
, Multi<MyObject>
), Quarkus runs it on an I/O thread (non-blocking expected).
import io.smallrye.mutiny.Uni;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.time.Duration;
@Path("/reactive-or-not")
public class UnifiedResource {
// This will run on a worker thread
@GET
@Path("/blocking")
@Produces(MediaType.TEXT_PLAIN)
public String getBlockingData() {
try {
Thread.sleep(100); // Simulating blocking work
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
return "Blocking data fetched";
}
// This will run on an I/O thread
@GET
@Path("/reactive")
@Produces(MediaType.TEXT_PLAIN)
public Uni<String> getReactiveData() {
return Uni.createFrom().item("Reactive data fetched")
.onItem().delayIt().by(Duration.ofMillis(100)); // Non-blocking delay
}
}
This flexibility allows you to mix and match blocking and non-blocking code within the same application, or even the same resource class, without switching frameworks or managing different thread pools explicitly in many common cases.
6. The Developer UI
While Spring Boot Actuator provides valuable endpoints for monitoring and interacting with a running application, accessing them often requires curl
, Postman, or integrating with external dashboards.
Quarkus includes a built-in Developer UI, accessible at /q/dev
when running in dev mode. This web-based tool provides a central place to:
View and update configuration properties live.
See all registered CDI beans and their scopes.
Explore available REST endpoints.
Manage Dev Services (e.g., view Kafka topics, interact with databases via a console).
Access extension-specific panels (e.g., Hibernate ORM statistics, cache status).
Run tests directly from the UI.
It acts as a helpful companion during development, providing visibility and quick access to common tasks without leaving the browser.
7. Standard-Based Dependency Injection (CDI)
Spring's Dependency Injection (DI) using @Autowired
, @Component
, @Service
, etc., is powerful and widely used. It's core to the Spring programming model.
Quarkus is built on Jakarta EE standards, primarily using Contexts and Dependency Injection (CDI) via its optimized implementation called Arc. For developers familiar with Jakarta EE or MicroProfile, this feels natural. Annotations like @ApplicationScoped
, @RequestScoped
, @Inject
are standard CDI.
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped // Standard CDI scope annotation
public class GreetingService {
public String generateGreeting(String name) {
return "Hello, " + name + "!";
}
}
// In a JAX-RS Resource:
@Path("/greet")
public class GreetingResource {
@Inject // Standard CDI injection point
GreetingService service;
@jakarta.ws.rs.GET
@jakarta.ws.rs.Path("/{name}")
public String greet(@jakarta.ws.rs.PathParam("name") String name) {
return service.generateGreeting(name);
}
}
While Spring's DI is excellent, standard CDI provides a vendor-neutral way of defining beans and injection, which can be appealing. Arc also performs significant work at build time to optimize DI wiring, contributing to faster startup. The feel is very similar to Spring's annotation-driven DI in practice but uses standard annotations.
8. Faster and Integrated Testing
Spring Boot's testing support (@SpringBootTest
, test slices like @WebMvcTest
, @DataJpaTest
) is comprehensive but can sometimes be slow due to context startup times, even with context caching.
Quarkus tests, typically annotated with @QuarkusTest
, benefit significantly from the build-time optimizations. Test startup is generally very fast. Furthermore, @QuarkusTest
seamlessly integrates with Dev Services. If your test requires a database or message broker, Quarkus can automatically provide one just for the test run, ensuring isolated and reliable integration tests without complex setup.
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
@QuarkusTest // Starts Quarkus context for the test
public class GreetingServiceTest {
@Inject // Inject beans just like in the application
GreetingService service;
@Test
public void testGreeting() {
String greeting = service.generateGreeting("Quarkus Tester");
assertEquals("Hello, Quarkus Tester!", greeting);
}
}
Running this test feels quick. If GreetingService
interacted with a database managed by Panache, and you had the JDBC driver extension, @QuarkusTest
would automatically start a database container (if needed) via Dev Services for the test execution. This combination of speed and integrated services makes writing and running tests, including integration tests, a smoother experience.
9. The Extension Model: Build-Time Magic
Spring Boot Starters are essentially dependency descriptors that pull in necessary libraries at runtime. They simplify dependency management but most configuration and integration logic happens when the application starts.
Quarkus Extensions go a step further. While they also bring in dependencies, extensions contain build steps that run during compilation. These steps analyze your code, configure integrations, generate bytecode, and set up framework components ahead of time. This build-time processing is key to Quarkus's fast startup and low memory use, but it also benefits the developer experience. It means potential configuration errors are caught earlier, and complex integrations (like Kafka, OpenAPI, health checks) often "just work" with minimal explicit configuration because the extension wires them up during the build.
Add the health check extension: mvn quarkus:add-extension -Dextensions="quarkus-smallrye-health"
. Immediately, without writing any code, Quarkus adds basic health check endpoints (/q/health
, /q/health/live
, /q/health/ready
). If you also have the database extension (quarkus-jdbc-postgresql
), the health extension automatically includes a check for database connectivity in the readiness probe. This proactive integration happens because the extensions coordinate during the build.
10. Command Mode Applications
Spring Boot allows creating command-line applications using CommandLineRunner
or ApplicationRunner
. These beans execute their run
method after the application context is loaded.
Quarkus offers a dedicated "Command Mode." You create a class implementing QuarkusApplication
or simply annotate a class with @QuarkusMain
. This allows you to run specific logic using the Quarkus dependency injection and configuration system, but without starting the full server (like the HTTP listener). This is ideal for utility applications, database migration scripts, batch jobs, or any task that needs application services but not a running server.
import io.quarkus.runtime.QuarkusApplication;
import io.quarkus.runtime.annotations.QuarkusMain;
import jakarta.inject.Inject;
@QuarkusMain // Marks this as the main entry point for command mode
public class MyCommand implements QuarkusApplication {
@Inject // Can inject other CDI beans
GreetingService service;
@Override
public int run(String... args) throws Exception {
System.out.println("Running MyCommand...");
if (args.length > 0) {
System.out.println(service.generateGreeting(args[0]));
} else {
System.out.println(service.generateGreeting("Default User"));
}
System.out.println("Command finished.");
return 0; // Exit code
}
}
Build the application (mvn package
). Then you can run it from the command line like a standard Java app, passing arguments: java -jar target/quarkus-app/quarkus-run.jar World
. It executes the run
method and exits.
Time to try on your own!
Look, Spring isn't going anywhere, and for good reason. It's a mature, comprehensive framework that powers countless critical systems. But the Java landscape is evolving. Quarkus presents a compelling alternative, particularly for cloud-native microservices and serverless functions, not just because of its runtime characteristics, but because of the thoughtful focus on the developer workflow.
The live coding, automated dev services, streamlined configuration, integrated testing, and the build-time optimizations offered by the extension model all contribute to a development cycle that often feels faster and more fluid.
As fellow enterprise developers, we owe it to ourselves to stay informed and explore tools that can make our jobs easier and more productive. You don't need to ditch Spring overnight, but spending an afternoon playing with Quarkus (quarkus.io/get-started/) might just surprise you. Give these developer experience features a try – you might find they resonate more than you expect.