Quarkus

Quarkus is Java rethought for containers and serverless. Where a Spring Boot application starts in 2-5 seconds and uses 300MB of heap at rest, a Quarkus application compiled to a native binary starts in under 50ms and uses 20-30MB. That difference is irrelevant on a long-running server — it’s decisive in a serverless environment where you’re billed per millisecond and cold starts affect user experience.

The programming model is familiar: CDI for dependency injection, JAX-RS for REST, JPA for data access. Quarkus handles the optimization, and you pay almost nothing in developer experience to get it.


🟢 Junior

Project setup

The Quarkus CLI or code.quarkus.io generates a project. You pick extensions instead of dependencies — extensions are pre-configured integrations:

quarkus create app com.example:my-app \
  --extension='rest-jackson,hibernate-orm-panache,jdbc-postgresql,smallrye-health'

cd my-app
quarkus dev  # live reload dev mode

quarkus dev runs in development mode with live reload — changing a Java file or application.properties reloads instantly without restarting the JVM.

REST with Quarkus REST (JAX-RS)

@Path("/api/users")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class UserResource {

    @Inject
    UserService userService;

    @GET
    public List<UserDto> list() {
        return userService.listAll();
    }

    @GET
    @Path("/{id}")
    public Response get(@PathParam("id") Long id) {
        return userService.findById(id)
            .map(dto -> Response.ok(dto).build())
            .orElse(Response.status(404).build());
    }

    @POST
    public Response create(@Valid CreateUserRequest req) {
        UserDto created = userService.create(req);
        return Response.status(201).entity(created).build();
    }
}

JAX-RS maps HTTP methods to Java methods with annotations. @Inject is CDI dependency injection — Quarkus resolves UserService at build time, not runtime, which is what enables native compilation.

Configuration

Quarkus uses application.properties (or application.yaml with the YAML extension):

quarkus.datasource.db-kind=postgresql
quarkus.datasource.username=${DB_USER}
quarkus.datasource.password=${DB_PASS}
quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/mydb

quarkus.hibernate-orm.database.generation=validate

# Dev profile (overrides default)
%dev.quarkus.hibernate-orm.database.generation=drop-and-create
%dev.quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/mydb_dev

The %dev. prefix applies only in dev mode, %test. only in tests, %prod. in production. Profile-based configuration means one application.properties file for all environments, with environment-specific overrides.

Map custom configuration to a class with @ConfigMapping:

@ConfigMapping(prefix = "app")
public interface AppConfig {
    String apiKey();
    Duration sessionTimeout();
    List<String> allowedOrigins();
}

🟡 Medior

Panache ORM

Panache is Quarkus’s opinionated layer on top of Hibernate. It eliminates most boilerplate via two styles: Active Record (methods on the entity) and Repository.

Active Record style — the entity is also the repository:

@Entity
public class User extends PanacheEntity {
    public String name;
    public String email;
    public boolean active;
    public LocalDateTime createdAt;

    public static Optional<User> findByEmail(String email) {
        return find("email", email).firstResultOptional();
    }

    public static List<User> findActive() {
        return list("active = true ORDER BY createdAt DESC");
    }

    public static long countActive() {
        return count("active", true);
    }
}

PanacheEntity provides a generated id field and static methods like findById, listAll, count, deleteById, persist. Your custom queries add to them. This is concise but couples your query logic to your entity — a trade-off.

Repository style — separates queries from the entity:

@ApplicationScoped
public class UserRepository implements PanacheRepository<User> {

    public Optional<User> findByEmail(String email) {
        return find("email", email).firstResultOptional();
    }

    public List<User> findActiveUsers(int page, int size) {
        return find("active = true ORDER BY createdAt DESC")
            .page(page, size)
            .list();
    }

    public Map<String, Long> countByDepartment() {
        return stream("SELECT department, COUNT(u) FROM User u GROUP BY department")
            .collect(Collectors.toMap(
                arr -> (String) arr[0],
                arr -> (Long) arr[1]
            ));
    }
}

Prefer Repository style when you want to keep entities as plain data objects and test the repository layer in isolation.

Reactive programming with Mutiny

Quarkus’s reactive API is built on Mutiny — a library designed to be more readable than RxJava or Reactor. The two types: Uni<T> (zero or one item) and Multi<T> (zero or more items).

@ApplicationScoped
public class UserService {

    @Inject
    UserRepository userRepo;

    public Uni<UserDto> findById(Long id) {
        return userRepo.findByIdOptional(id)
            .map(opt -> opt.map(UserDto::from)
                .orElseThrow(() -> new NotFoundException("User " + id)));
    }

    public Uni<UserDto> create(CreateUserRequest req) {
        return Uni.createFrom().item(req)
            .flatMap(r -> userRepo.findByEmail(r.getEmail())
                .map(existing -> {
                    if (existing.isPresent()) {
                        throw new ConflictException("Email taken");
                    }
                    User user = new User(r);
                    return user;
                }))
            .call(user -> userRepo.persist(user))
            .map(UserDto::from);
    }

    public Multi<UserDto> stream() {
        return userRepo.streamAll()
            .map(UserDto::from);
    }
}

The reactive model is optional — Quarkus supports blocking code too (annotate with @Blocking to run on a worker thread pool). Use reactive for I/O-heavy code and keep business logic synchronous for readability.

Health and metrics

The smallrye-health extension provides MicroProfile Health endpoints:

@Liveness
@ApplicationScoped
public class AppLivenessCheck implements HealthCheck {

    @Override
    public HealthCheckResponse call() {
        return HealthCheckResponse.named("app-liveness")
            .status(true)
            .build();
    }
}

@Readiness
@ApplicationScoped
public class DatabaseReadinessCheck implements HealthCheck {

    @Inject
    DataSource dataSource;

    @Override
    public HealthCheckResponse call() {
        try (Connection c = dataSource.getConnection()) {
            c.isValid(1);
            return HealthCheckResponse.named("database").up().build();
        } catch (SQLException e) {
            return HealthCheckResponse.named("database")
                .down()
                .withData("error", e.getMessage())
                .build();
        }
    }
}

GET /q/health/live — liveness (should the container be restarted?). GET /q/health/ready — readiness (should traffic be sent here?). GET /q/metrics — Prometheus-format metrics (with quarkus-micrometer-registry-prometheus).


🔴 Senior

Native compilation with GraalVM

Building a native binary eliminates JVM startup time and dramatically reduces memory usage:

quarkus build --native

# Or with Docker (no local GraalVM needed)
quarkus build --native --no-tests \
  -Dquarkus.native.container-build=true \
  -Dquarkus.native.builder-image=quay.io/quarkus/ubi-quarkus-mandrel-builder-image:23.1-java21

The native binary starts in ~30-50ms and uses ~30MB at rest. The trade-off: build time increases from 3 seconds to 3-5 minutes, and dynamic Java features (reflection, dynamic proxies, runtime class loading) need to be declared explicitly.

Most Quarkus extensions are already native-compatible. Problems arise when you use third-party libraries that rely heavily on reflection without registering their classes. Register them manually:

@RegisterForReflection(targets = {MyDto.class, AnotherDto.class})
public class ReflectionConfig {}

Or via reflection-config.json in src/main/resources/META-INF/native-image/.

Testing

Quarkus tests use @QuarkusTest which runs the full application in test mode:

@QuarkusTest
class UserResourceTest {

    @TestHTTPEndpoint(UserResource.class)
    @TestHTTPResource
    URL userEndpoint;

    @Test
    void testGetUser() {
        given()
            .when()
            .get("/api/users/1")
            .then()
            .statusCode(200)
            .body("email", is("alice@example.com"));
    }

    @Test
    void testCreateUser() {
        given()
            .contentType(ContentType.JSON)
            .body("""{ "name": "Bob", "email": "bob@example.com" }""")
            .when()
            .post("/api/users")
            .then()
            .statusCode(201)
            .body("id", notNullValue());
    }
}

For native image testing, @QuarkusIntegrationTest runs against the compiled binary. These are slow (a fresh native binary starts every test run in CI) — run them in a separate CI stage.

Quarkus vs Spring Boot

Neither is universally better. The choice depends on what matters most:

Choose Quarkus when:

  • You’re deploying to Kubernetes/serverless where startup time and memory matter
  • You’re starting fresh and want reactive-first design
  • Container density (more instances per node) is a cost concern

Choose Spring Boot when:

  • Your team already knows Spring deeply
  • You need the breadth of the Spring ecosystem (Spring Batch, Spring Integration, etc.)
  • You have existing Spring code to integrate with

The performance story: Quarkus native compiles are genuinely faster to start and smaller at rest. JVM Quarkus vs JVM Spring Boot at peak throughput is much closer — the JVM’s JIT compilation levels the field over time.

Senior Gotchas

GraalVM Native Image doesn’t support all Java features. Dynamic class loading, arbitrary reflection, JVM agent-based instrumentation, and some serialization patterns won’t work in native mode without explicit registration. Test natively early if native is a requirement — discovering incompatibilities late is painful.

CDI scopes matter for injection. Injecting a @RequestScoped bean into an @ApplicationScoped bean requires a proxy — Quarkus handles this automatically, but injecting the wrong scope in the wrong direction will throw at startup.

Panache Active Record style doesn’t work well with testing. Static methods on an entity are hard to mock. If testability matters, use Repository style from the start.

%dev profile drop-and-create will drop your data. Make sure %prod is set to validate or none. The profile prefix on every property is easy to miss when reviewing configs.

Reactive and blocking code on the same thread pool causes starvation. If you block inside a reactive pipeline without @Blocking, you starve the event loop. Quarkus detects and logs this as a warning. Always annotate blocking operations explicitly.