Quarkus #

Quarkus is a Java/Kotlin framework designed for the cloud-native and Kubernetes era — extremely fast startup, very small memory footprint, and support for compiling to native binaries with GraalVM. Quarkus uses Java EE/Jakarta EE standards (CDI, JAX-RS, JPA) so developers familiar with that ecosystem don’t need to learn new APIs. What sets Quarkus apart from Spring Boot is its approach: as much work as possible is done at build time (not runtime), producing far lighter applications. Quarkus supports Kotlin well — all Quarkus extensions can be used from Kotlin. This article covers project setup, building REST APIs, dependency injection, database access with Panache, configuration, testing, and how to build native images.

Quarkus vs Spring Boot vs Ktor #

flowchart TD
    A{Main Priority?} --> B{Startup time\nand memory footprint?}
    B -- Very important --> C{Team familiar\nwith Jakarta EE?}
    C -- Yes --> D["Quarkus\nNative image, CDI, JAX-RS"]
    C -- No --> E["Ktor\nKotlin-first, native coroutines"]
    B -- Not important --> F{Ecosystem\nand libraries?}
    F -- Need very rich --> G["Spring Boot\nLargest ecosystem"]
    F -- Modern enough --> D
AspectQuarkusSpring BootKtor
Startup time~0.05s (native) / ~0.5s (JVM)~2-5s~0.3s
Memory (idle)~10MB (native) / ~70MB (JVM)~200MB+~40MB
Native image✓ GraalVM native✓ Spring AOT (newer)✗ JVM only
Design languageJava + KotlinJava + KotlinKotlin-first
DI frameworkCDI (standard)Spring ContainerKoin/manual
ORMHibernate PanacheSpring DataExposed/Ktorm
ReactiveMutiny, Vert.xProject ReactorCoroutines
KubernetesVery optimalGoodGood

Creating a Quarkus Project #

The easiest way to create a Quarkus project with Kotlin is via the Quarkus CLI or Maven:

# Using the Quarkus CLI
quarkus create app com.myapp:api-produk \
    --extension="resteasy-reactive-jackson,hibernate-orm-panache-kotlin,jdbc-postgresql,smallrye-openapi,kotlin" \
    --kotlin

# Using Maven
mvn io.quarkus.platform:quarkus-maven-plugin:3.10.0:create \
    -DprojectGroupId=com.myapp \
    -DprojectArtifactId=api-produk \
    -Dextensions="resteasy-reactive-jackson,hibernate-orm-panache-kotlin,jdbc-postgresql,kotlin" \
    -DpackageName="com.myapp"

# Development mode (hot reload)
./mvnw quarkus:dev
# or
quarkus dev

Project Structure #

api-produk/
├── src/
│   ├── main/
│   │   ├── kotlin/com/myapp/
│   │   │   ├── model/         ← entities and DTOs
│   │   │   ├── resource/      ← REST endpoints (controllers)
│   │   │   ├── service/       ← business logic
│   │   │   └── repository/    ← data access
│   │   └── resources/
│   │       ├── application.properties  ← configuration
│   │       └── META-INF/resources/     ← static files
│   └── test/kotlin/com/myapp/
├── pom.xml
└── Dockerfile.native

pom.xml — Main Dependencies #

<properties>
    <kotlin.version>2.0.0</kotlin.version>
    <quarkus.platform.version>3.10.0</quarkus.platform.version>
</properties>

<dependencies>
    <!-- Reactive REST API -->
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-resteasy-reactive-jackson</artifactId>
    </dependency>

    <!-- Hibernate with Panache for Kotlin -->
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-hibernate-orm-panache-kotlin</artifactId>
    </dependency>

    <!-- Database driver -->
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-jdbc-postgresql</artifactId>
    </dependency>

    <!-- Validation -->
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-hibernate-validator</artifactId>
    </dependency>

    <!-- OpenAPI/Swagger UI -->
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-smallrye-openapi</artifactId>
    </dependency>

    <!-- Testing -->
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-junit5</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>io.rest-assured</groupId>
        <artifactId>rest-assured</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Configuration — application.properties #

# Server
quarkus.http.port=8080
quarkus.http.cors=true
quarkus.http.cors.origins=http://localhost:3000

# Database
quarkus.datasource.db-kind=postgresql
quarkus.datasource.username=${DB_USER:postgres}
quarkus.datasource.password=${DB_PASSWORD:postgres}
quarkus.datasource.jdbc.url=jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:myapp}
quarkus.datasource.jdbc.max-size=16

# Hibernate
quarkus.hibernate-orm.database.generation=update  # dev: update, prod: none
quarkus.hibernate-orm.log.sql=false

# Logging
quarkus.log.level=INFO
quarkus.log.category."com.myapp".level=DEBUG

# OpenAPI
quarkus.swagger-ui.always-include=true
quarkus.smallrye-openapi.info-title=API Produk
quarkus.smallrye-openapi.info-version=1.0.0

# Native image
quarkus.native.container-build=true  # build inside a container
quarkus.native.builder-image=quay.io/quarkus/ubi-quarkus-mandrel-builder-image:jdk-21

Entities with Hibernate Panache #

Panache is an ORM layer on top of Hibernate that significantly reduces boilerplate:

package com.myapp.model

import io.quarkus.hibernate.orm.panache.kotlin.PanacheEntity
import io.quarkus.hibernate.orm.panache.kotlin.PanacheCompanion
import jakarta.persistence.*
import jakarta.validation.constraints.*
import java.time.LocalDateTime

@Entity
@Table(name = "produk")
class Produk : PanacheEntity() {
    // id is already available from PanacheEntity (Long, auto-generated)

    @Column(nullable = false)
    @field:NotBlank(message = "Nama tidak boleh kosong")
    @field:Size(max = 255)
    lateinit var nama: String

    @Column(columnDefinition = "TEXT")
    var deskripsi: String? = null

    @Column(nullable = false)
    @field:Positive(message = "Harga harus positif")
    var harga: Double = 0.0

    @Column(nullable = false)
    @field:PositiveOrZero(message = "Stok tidak boleh negatif")
    var stok: Int = 0

    var kategori: String? = null

    @Column(nullable = false)
    var aktif: Boolean = true

    @Column(name = "dibuat_pada", nullable = false)
    var dibuatPada: LocalDateTime = LocalDateTime.now()

    companion object : PanacheCompanion<Produk> {
        // Static query methods — PanacheCompanion already includes findAll, findById, etc.

        fun cariAktif(): List<Produk> =
            list("aktif", true)

        fun cariByKategori(kategori: String): List<Produk> =
            list("kategori = ?1 AND aktif = true", kategori)

        fun cariByHargaMaks(maks: Double): List<Produk> =
            list("harga <= ?1 AND aktif = true ORDER BY harga", maks)

        fun hitungByKategori(kategori: String): Long =
            count("kategori = ?1 AND aktif = true", kategori)

        fun cariDenganPaginasi(halaman: Int, ukuran: Int): List<Produk> =
            findAll().page(halaman, ukuran).list()
    }
}

// DTOs for requests and responses
data class ProdukDto(
    val id: Long? = null,
    @field:NotBlank val nama: String,
    val deskripsi: String? = null,
    @field:Positive val harga: Double,
    @field:PositiveOrZero val stok: Int = 0,
    val kategori: String? = null,
    val aktif: Boolean = true
)

// Conversion extensions
fun Produk.toDto() = ProdukDto(id, nama, deskripsi, harga, stok, kategori, aktif)
fun ProdukDto.toEntity() = Produk().also { p ->
    p.nama = nama
    p.deskripsi = deskripsi
    p.harga = harga
    p.stok = stok
    p.kategori = kategori
    p.aktif = aktif
}

REST Resources (Controllers) #

package com.myapp.resource

import com.myapp.model.*
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.transaction.Transactional
import jakarta.validation.Valid
import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
import jakarta.ws.rs.core.Response
import org.eclipse.microprofile.openapi.annotations.Operation
import org.eclipse.microprofile.openapi.annotations.tags.Tag

@Path("/api/v1/produk")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "Produk", description = "Product data management")
@ApplicationScoped
class ProdukResource {

    @GET
    @Operation(summary = "Get all active products")
    fun ambilSemua(
        @QueryParam("kategori") kategori: String?,
        @QueryParam("halaman") @DefaultValue("0") halaman: Int,
        @QueryParam("ukuran") @DefaultValue("20") ukuran: Int
    ): List<ProdukDto> {
        val produk = if (kategori != null) {
            Produk.cariByKategori(kategori)
        } else {
            Produk.cariDenganPaginasi(halaman, ukuran)
        }
        return produk.map { it.toDto() }
    }

    @GET
    @Path("/{id}")
    @Operation(summary = "Get a product by ID")
    fun ambilById(@PathParam("id") id: Long): ProdukDto {
        return Produk.findById(id)?.toDto()
            ?: throw NotFoundException("Produk $id tidak ditemukan")
    }

    @POST
    @Transactional
    @Operation(summary = "Add a new product")
    fun tambah(@Valid dto: ProdukDto): Response {
        val produk = dto.toEntity()
        produk.persist()
        return Response.status(Response.Status.CREATED)
            .entity(produk.toDto())
            .build()
    }

    @PUT
    @Path("/{id}")
    @Transactional
    @Operation(summary = "Update a product")
    fun perbarui(@PathParam("id") id: Long, @Valid dto: ProdukDto): ProdukDto {
        val produk = Produk.findById(id)
            ?: throw NotFoundException("Produk $id tidak ditemukan")

        produk.nama      = dto.nama
        produk.deskripsi = dto.deskripsi
        produk.harga     = dto.harga
        produk.stok      = dto.stok
        produk.kategori  = dto.kategori

        // No explicit save needed — Hibernate detects changes automatically
        return produk.toDto()
    }

    @DELETE
    @Path("/{id}")
    @Transactional
    @Operation(summary = "Delete a product (soft delete)")
    fun hapus(@PathParam("id") id: Long): Response {
        val produk = Produk.findById(id)
            ?: throw NotFoundException("Produk $id tidak ditemukan")

        produk.aktif = false  // soft delete
        return Response.noContent().build()
    }

    @GET
    @Path("/kategori/{kategori}/jumlah")
    fun hitungByKategori(@PathParam("kategori") kategori: String): Map<String, Long> {
        return mapOf("jumlah" to Produk.hitungByKategori(kategori))
    }
}

Dependency Injection with CDI #

Quarkus uses CDI (Contexts and Dependency Injection) — the Jakarta EE standard for DI:

package com.myapp.service

import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.transaction.Transactional
import com.myapp.model.Produk
import org.jboss.logging.Logger

// @ApplicationScoped = one instance per application (singleton)
// @RequestScoped = one instance per HTTP request
// @Dependent = created new each time it's injected

@ApplicationScoped
class LayananProduk {

    @Inject
    lateinit var logger: Logger  // Logger can be injected directly

    @Inject
    lateinit var layananEmail: LayananEmail

    @Transactional
    fun tambahProdukDanKirimNotifikasi(dto: ProdukDto): ProdukDto {
        val produk = dto.toEntity()
        produk.persist()

        logger.info("New product added: ${produk.id}${produk.nama}")

        // Send the notification email asynchronously
        layananEmail.kirimNotifikasiProdukBaru(produk.nama)

        return produk.toDto()
    }
}

@ApplicationScoped
class LayananEmail {
    @Inject
    lateinit var logger: Logger

    fun kirimNotifikasiProdukBaru(namaProduk: String) {
        logger.info("Sending notification email for product: $namaProduk")
        // Email sending implementation
    }
}

Producers — Providing Custom Beans #

import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.inject.Produces
import jakarta.enterprise.inject.Typed
import kotlinx.serialization.json.Json

// Create a bean that can't be annotated @ApplicationScoped directly
@ApplicationScoped
class JsonProducer {

    @Produces
    @ApplicationScoped
    fun json(): Json = Json {
        ignoreUnknownKeys = true
        prettyPrint = false
    }
}

Configuration with MicroProfile Config #

import org.eclipse.microprofile.config.inject.ConfigProperty
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject

@ApplicationScoped
class KonfigurasiAplikasi {

    // Injected from application.properties or environment variables
    @Inject
    @ConfigProperty(name = "app.nama", defaultValue = "MyApp")
    lateinit var namaAplikasi: String

    @Inject
    @ConfigProperty(name = "app.versi", defaultValue = "1.0.0")
    lateinit var versi: String

    @Inject
    @ConfigProperty(name = "app.maks-upload-mb", defaultValue = "10")
    var maksUploadMb: Int = 10

    // Optional value
    @Inject
    @ConfigProperty(name = "app.feature.pendaftaran")
    var pendaftaranAktif: java.util.Optional<Boolean> = java.util.Optional.empty()

    fun info() = "$namaAplikasi v$versi (upload max: ${maksUploadMb}MB)"
}

Testing with QuarkusTest #

package com.myapp.resource

import io.quarkus.test.junit.QuarkusTest
import io.restassured.RestAssured.given
import io.restassured.http.ContentType
import org.hamcrest.CoreMatchers.*
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestMethodOrder
import org.junit.jupiter.api.MethodOrderer

@QuarkusTest
@TestMethodOrder(MethodOrderer.OrderAnnotation::class)
class ProdukResourceTest {

    @Test
    fun `GET all products returns a list`() {
        given()
            .`when`().get("/api/v1/produk")
            .then()
            .statusCode(200)
            .contentType(ContentType.JSON)
            .body(notNullValue())
    }

    @Test
    fun `POST a new product succeeds`() {
        val payload = """
            {
                "nama": "Laptop Test",
                "harga": 15000000.0,
                "stok": 5,
                "kategori": "Elektronik"
            }
        """.trimIndent()

        given()
            .contentType(ContentType.JSON)
            .body(payload)
            .`when`().post("/api/v1/produk")
            .then()
            .statusCode(201)
            .body("nama", equalTo("Laptop Test"))
            .body("id", notNullValue())
    }

    @Test
    fun `GET product with an invalid ID returns 404`() {
        given()
            .`when`().get("/api/v1/produk/99999")
            .then()
            .statusCode(404)
    }

    @Test
    fun `POST product without a name returns 400`() {
        val payload = """{"harga": 100000.0, "stok": 1}"""

        given()
            .contentType(ContentType.JSON)
            .body(payload)
            .`when`().post("/api/v1/produk")
            .then()
            .statusCode(400)
    }
}

Testing with Mocks #

import io.quarkus.test.InjectMock
import io.quarkus.test.junit.QuarkusTest
import org.mockito.Mockito

@QuarkusTest
class LayananProdukTest {

    @InjectMock
    lateinit var layananEmail: LayananEmail

    @Inject
    lateinit var layananProduk: LayananProduk

    @Test
    fun `adding a product calls the email service`() {
        val dto = ProdukDto(nama = "Test", harga = 100.0, stok = 1)
        layananProduk.tambahProdukDanKirimNotifikasi(dto)

        Mockito.verify(layananEmail).kirimNotifikasiProdukBaru("Test")
    }
}

Native Images with GraalVM #

Quarkus can be compiled to a native binary that doesn’t need a JVM:

# Build a native image (needs GraalVM or a container)
./mvnw package -Pnative

# Or build inside a container (no need to install GraalVM locally)
./mvnw package -Pnative -Dquarkus.native.container-build=true

# Run the native binary
./target/api-produk-1.0.0-SNAPSHOT-runner
# Startup in milliseconds, far less memory!
# Dockerfile.native
FROM quay.io/quarkus/quarkus-micro-image:2.0
WORKDIR /work/
COPY --chown=1001:root target/*-runner /work/application
RUN chmod 775 /work
EXPOSE 8080
USER 1001
CMD ["./application", "-Dquarkus.http.host=0.0.0.0"]

Startup and Memory Comparison #

JVM mode:
  Startup: ~0.5 seconds
  Initial memory: ~70MB

Native mode:
  Startup: ~0.05 seconds (10x faster!)
  Initial memory: ~10MB (7x more efficient!)

Spring Boot (as a reference):
  Startup: ~2-5 seconds
  Initial memory: ~200MB+
Native images have limitations: reflection and dynamic proxies need extra configuration. Quarkus handles this automatically for official extensions, but third-party libraries may need manual configuration in reflect-config.json.

Dev Services — Automatic Databases for Development #

Quarkus provides Dev Services — in dev mode (quarkus:dev), Quarkus automatically runs Docker containers for databases and other needed services:

# No database configuration needed for development!
# Quarkus automatically runs PostgreSQL via Docker Testcontainers
# and configures the datasource automatically

# Only production needs configuration:
%prod.quarkus.datasource.jdbc.url=jdbc:postgresql://db:5432/myapp
%prod.quarkus.datasource.username=${DB_USER}
%prod.quarkus.datasource.password=${DB_PASSWORD}

# The development profile uses Dev Services automatically

Summary #

  • Quarkus for cloud-native and Kubernetes — very fast startup and a small memory footprint make Quarkus ideal for microservices deployed on Kubernetes where pods are often created and destroyed.
  • Native images for extreme efficiency — with GraalVM, Quarkus can be compiled to a native binary that starts in milliseconds and uses 10MB of memory. Ideal for serverless (AWS Lambda, Cloud Functions).
  • Panache reduces JPA boilerplatePanacheEntity and PanacheCompanion remove the need for verbose repository interfaces. Simple queries go directly in the companion object.
  • CDI as the DI standard — use @ApplicationScoped, @Inject, and @Produces for dependency injection. This is the Jakarta EE standard, not a proprietary framework.
  • Dev Services speeds up development — databases, Kafka, Redis all run automatically via Docker in dev mode. No manual setup needed for local development.
  • MicroProfile Config for configuration@ConfigProperty with default values and environment variable support. Use the %prod, %dev, %test profiles for per-environment configuration.
  • QuarkusTest + REST Assured for testing — very well integrated integration testing. @QuarkusTest starts a real application, and REST Assured makes testing HTTP endpoints very easy.
  • @Transactional on resources or services — make sure database operations are always in a transaction. Panache uses lazy loading which needs an active transaction context.

← Previous: Memcached   Next: Ktor →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact