Quarkus #
Quarkus adalah framework Java/Kotlin yang dirancang untuk era cloud-native dan Kubernetes — startup sangat cepat, footprint memori sangat kecil, dan mendukung kompilasi ke native binary dengan GraalVM. Quarkus menggunakan standar Java EE/Jakarta EE (CDI, JAX-RS, JPA) sehingga developer yang familiar dengan ekosistem tersebut tidak perlu belajar API baru. Yang membedakan Quarkus dari Spring Boot adalah pendekatannya: sebanyak mungkin pekerjaan dilakukan saat build time (bukan runtime), menghasilkan aplikasi yang jauh lebih ringan. Quarkus mendukung Kotlin dengan baik — semua extension Quarkus bisa digunakan dari Kotlin. Artikel ini membahas setup proyek, membangun REST API, dependency injection, akses database dengan Panache, konfigurasi, testing, dan cara membuat native image.
Quarkus vs Spring Boot vs Ktor #
flowchart TD
A{Prioritas Utama?} --> B{Startup time\ndan memory footprint?}
B -- Sangat penting --> C{Tim familiar\ndengan Jakarta EE?}
C -- Ya --> D["Quarkus\nNative image, CDI, JAX-RS"]
C -- Tidak --> E["Ktor\nKotlin-first, coroutine native"]
B -- Tidak penting --> F{Ekosistem\ndan library?}
F -- Butuh sangat kaya --> G["Spring Boot\nEkosistem terluas"]
F -- Cukup modern --> D| Aspek | Quarkus | Spring Boot | Ktor |
|---|---|---|---|
| Startup time | ~0.05s (native) / ~0.5s (JVM) | ~2-5s | ~0.3s |
| Memori (idle) | ~10MB (native) / ~70MB (JVM) | ~200MB+ | ~40MB |
| Native image | ✓ GraalVM native | ✓ Spring AOT (lebih baru) | ✗ JVM only |
| Bahasa desain | Java + Kotlin | Java + Kotlin | Kotlin-first |
| DI Framework | CDI (standar) | Spring Container | Koin/manual |
| ORM | Hibernate Panache | Spring Data | Exposed/Ktorm |
| Reaktif | Mutiny, Vert.x | Project Reactor | Coroutine |
| Kubernetes | Sangat optimal | Baik | Baik |
Membuat Proyek Quarkus #
Cara termudah membuat proyek Quarkus dengan Kotlin adalah melalui Quarkus CLI atau Maven:
# Menggunakan Quarkus CLI
quarkus create app com.myapp:api-produk \
--extension="resteasy-reactive-jackson,hibernate-orm-panache-kotlin,jdbc-postgresql,smallrye-openapi,kotlin" \
--kotlin
# Menggunakan 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"
# Mode development (hot reload)
./mvnw quarkus:dev
# atau
quarkus dev
Struktur Proyek #
api-produk/
├── src/
│ ├── main/
│ │ ├── kotlin/com/myapp/
│ │ │ ├── model/ ← entity dan DTO
│ │ │ ├── resource/ ← REST endpoint (controller)
│ │ │ ├── service/ ← logika bisnis
│ │ │ └── repository/ ← akses data
│ │ └── resources/
│ │ ├── application.properties ← konfigurasi
│ │ └── META-INF/resources/ ← file statis
│ └── test/kotlin/com/myapp/
├── pom.xml
└── Dockerfile.native
pom.xml — Dependensi Utama
#
<properties>
<kotlin.version>2.0.0</kotlin.version>
<quarkus.platform.version>3.10.0</quarkus.platform.version>
</properties>
<dependencies>
<!-- REST API reaktif -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-reactive-jackson</artifactId>
</dependency>
<!-- Hibernate dengan Panache untuk Kotlin -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-orm-panache-kotlin</artifactId>
</dependency>
<!-- Driver database -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-postgresql</artifactId>
</dependency>
<!-- Validasi -->
<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>
Konfigurasi — 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 di dalam container
quarkus.native.builder-image=quay.io/quarkus/ubi-quarkus-mandrel-builder-image:jdk-21
Entity dengan Hibernate Panache #
Panache adalah layer ORM di atas Hibernate yang mengurangi boilerplate secara signifikan:
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 sudah tersedia dari 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> {
// Query methods statis — PanacheCompanion sudah include findAll, findById, dll
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()
}
}
// DTO untuk request dan response
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
)
// Extension untuk konversi
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 Resource (Controller) #
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 = "Manajemen data produk")
@ApplicationScoped
class ProdukResource {
@GET
@Operation(summary = "Ambil semua produk aktif")
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 = "Ambil produk berdasarkan ID")
fun ambilById(@PathParam("id") id: Long): ProdukDto {
return Produk.findById(id)?.toDto()
?: throw NotFoundException("Produk $id tidak ditemukan")
}
@POST
@Transactional
@Operation(summary = "Tambah produk baru")
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 = "Perbarui produk")
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
// Tidak perlu save eksplisit — Hibernate mendeteksi perubahan otomatis
return produk.toDto()
}
@DELETE
@Path("/{id}")
@Transactional
@Operation(summary = "Hapus produk (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 dengan CDI #
Quarkus menggunakan CDI (Contexts and Dependency Injection) — standar Jakarta EE untuk 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 = satu instance per aplikasi (singleton)
// @RequestScoped = satu instance per HTTP request
// @Dependent = dibuat baru setiap kali di-inject
@ApplicationScoped
class LayananProduk {
@Inject
lateinit var logger: Logger // Logger bisa di-inject langsung
@Inject
lateinit var layananEmail: LayananEmail
@Transactional
fun tambahProdukDanKirimNotifikasi(dto: ProdukDto): ProdukDto {
val produk = dto.toEntity()
produk.persist()
logger.info("Produk baru ditambahkan: ${produk.id} — ${produk.nama}")
// Kirim email notifikasi secara asinkron
layananEmail.kirimNotifikasiProdukBaru(produk.nama)
return produk.toDto()
}
}
@ApplicationScoped
class LayananEmail {
@Inject
lateinit var logger: Logger
fun kirimNotifikasiProdukBaru(namaProduk: String) {
logger.info("Mengirim email notifikasi untuk produk: $namaProduk")
// Implementasi pengiriman email
}
}
Producer — Menyediakan Bean Kustom #
import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.inject.Produces
import jakarta.enterprise.inject.Typed
import kotlinx.serialization.json.Json
// Membuat bean yang tidak bisa ditandai @ApplicationScoped langsung
@ApplicationScoped
class JsonProducer {
@Produces
@ApplicationScoped
fun json(): Json = Json {
ignoreUnknownKeys = true
prettyPrint = false
}
}
Konfigurasi dengan MicroProfile Config #
import org.eclipse.microprofile.config.inject.ConfigProperty
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
@ApplicationScoped
class KonfigurasiAplikasi {
// Inject dari application.properties atau environment variable
@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 maks: ${maksUploadMb}MB)"
}
Testing dengan 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 semua produk mengembalikan list`() {
given()
.`when`().get("/api/v1/produk")
.then()
.statusCode(200)
.contentType(ContentType.JSON)
.body(notNullValue())
}
@Test
fun `POST produk baru berhasil`() {
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 produk dengan ID tidak valid mengembalikan 404`() {
given()
.`when`().get("/api/v1/produk/99999")
.then()
.statusCode(404)
}
@Test
fun `POST produk tanpa nama mengembalikan 400`() {
val payload = """{"harga": 100000.0, "stok": 1}"""
given()
.contentType(ContentType.JSON)
.body(payload)
.`when`().post("/api/v1/produk")
.then()
.statusCode(400)
}
}
Test dengan Mock #
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 `tambah produk memanggil email service`() {
val dto = ProdukDto(nama = "Test", harga = 100.0, stok = 1)
layananProduk.tambahProdukDanKirimNotifikasi(dto)
Mockito.verify(layananEmail).kirimNotifikasiProdukBaru("Test")
}
}
Native Image dengan GraalVM #
Quarkus bisa dikompilasi ke native binary yang tidak membutuhkan JVM:
# Build native image (membutuhkan GraalVM atau container)
./mvnw package -Pnative
# Atau build di dalam container (tidak perlu install GraalVM di lokal)
./mvnw package -Pnative -Dquarkus.native.container-build=true
# Jalankan binary native
./target/api-produk-1.0.0-SNAPSHOT-runner
# Startup dalam milidetik, memori jauh lebih kecil!
# 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"]
Perbandingan Startup dan Memori #
Mode JVM:
Startup: ~0.5 detik
Memori awal: ~70MB
Mode Native:
Startup: ~0.05 detik (10x lebih cepat!)
Memori awal: ~10MB (7x lebih hemat!)
Spring Boot (sebagai referensi):
Startup: ~2-5 detik
Memori awal: ~200MB+
Native image punya batasan: refleksi dan dynamic proxy membutuhkan konfigurasi tambahan. Quarkus menangani ini secara otomatis untuk extension resmi, tapi library pihak ketiga mungkin perlu konfigurasi manual di reflect-config.json.Dev Services — Database Otomatis untuk Development #
Quarkus menyediakan Dev Services — saat di mode dev (quarkus:dev), Quarkus otomatis menjalankan container Docker untuk database dan service lain yang dibutuhkan:
# Tidak perlu konfigurasi database untuk development!
# Quarkus otomatis menjalankan PostgreSQL via Docker Testcontainers
# dan mengkonfigurasi datasource secara otomatis
# Hanya perlu konfigurasi untuk production:
%prod.quarkus.datasource.jdbc.url=jdbc:postgresql://db:5432/myapp
%prod.quarkus.datasource.username=${DB_USER}
%prod.quarkus.datasource.password=${DB_PASSWORD}
# Profile development menggunakan Dev Services secara otomatis
Ringkasan #
- Quarkus untuk cloud-native dan Kubernetes — startup sangat cepat dan footprint memori kecil menjadikan Quarkus ideal untuk microservice yang di-deploy di Kubernetes di mana pod sering dibuat dan dihancurkan.
- Native image untuk efisiensi ekstrem — dengan GraalVM, Quarkus bisa di-compile ke binary native yang startup dalam milidetik dan menggunakan 10MB memori. Ideal untuk serverless (AWS Lambda, Cloud Functions).
- Panache mengurangi boilerplate JPA —
PanacheEntitydanPanacheCompanionmenghilangkan kebutuhan repository interface yang verbose. Query sederhana langsung di companion object.- CDI sebagai standar DI — gunakan
@ApplicationScoped,@Inject, dan@Producesuntuk dependency injection. Ini adalah standar Jakarta EE, bukan proprietary framework.- Dev Services mempercepat development — database, Kafka, Redis semuanya otomatis berjalan via Docker saat mode dev. Tidak perlu setup manual untuk development lokal.
- MicroProfile Config untuk konfigurasi —
@ConfigPropertydengan default value dan support environment variable. Gunakan profile%prod,%dev,%testuntuk konfigurasi per environment.- QuarkusTest + REST Assured untuk testing — testing integrasi yang terintegrasi sangat baik.
@QuarkusTestmemulai aplikasi nyata, dan REST Assured membuat pengujian HTTP endpoint sangat mudah.@Transactionaldi resource atau service — pastikan operasi database selalu dalam transaksi. Panache menggunakan lazy loading yang butuh konteks transaksi aktif.