Skema database tidak pernah statis. Saat fitur bertambah, kita perlu menambah kolom, tabel, atau indeks. Mengubah skema secara manual di setiap environment (development, staging, produksi) cepat menjadi tidak terkendali. Database migration memecahkan masalah ini dengan menyimpan perubahan skema sebagai skrip versi yang diterapkan berurutan dan otomatis. Pada chapter ini kita akan mempelajari Flyway dan Liquibase — dua alat migration paling populer — serta membangun migration runner sederhana untuk memahami cara kerjanya.
Mengapa Migration Penting #
Tanpa migration, perubahan skema tersebar di banyak tempat: catatan di Slack, skrip SQL di folder lokal, dan ingatan developer. Akibatnya skema di dev, staging, dan produksi sering tidak sinkron, menyebabkan bug yang sulit didiagnosis. Migration memecahkan hal ini dengan:
- Versioning: setiap perubahan punya nomor versi yang terurut.
- Reproducibility: skema yang sama di semua environment.
- Audit trail: catatan siapa, kapan, dan apa yang berubah.
- Rollback plan: skrip undo untuk membatalkan perubahan bila perlu.
Flyway #
Flyway adalah alat migration paling sederhana. Skrip SQL disimpan di src/main/resources/db/migration/ dengan konvensi penamaan V<nomor>__<deskripsi>.sql. Saat aplikasi start, Flyway memeriksa tabel schema_version dan menerapkan skrip yang belum dijalankan.
dependencies {
implementation("org.flywaydb:flyway-core:10.15.0")
runtimeOnly("org.flywaydb:flyway-database-postgresql:10.15.0")
}CREATE TABLE pengguna (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
email VARCHAR(100) NOT NULL UNIQUE,
nama VARCHAR(100) NOT NULL
);CREATE TABLE pesanan (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
pengguna_id BIGINT NOT NULL,
total INTEGER NOT NULL,
FOREIGN KEY (pengguna_id) REFERENCES pengguna(id)
);
CREATE INDEX idx_pesanan_pengguna ON pesanan(pengguna_id);Konfigurasi Flyway di Spring Boot otomatis: cukup tambah dependency, dan saat aplikasi start Flyway menjalankan migration yang pending.
Liquibase #
Liquibase lebih kaya fitur: mendukung format XML/YAML/JSON selain SQL, mendukung rollback otomatis untuk beberapa operasi, dan punya diff tool untuk membandingkan skema. Liquibase menyimpan state di tabel databasechangelog.
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="1" author="budi">
<createTable tableName="pengguna">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true"/>
</column>
<column name="email" type="VARCHAR(100)">
<constraints unique="true" nullable="false"/>
</column>
</createTable>
</changeSet>
</databaseChangeLog>Liquibase cocok untuk tim yang perlu mendukung banyak RDBMS (XML/DSL diterjemahkan ke SQL spesifik per database), sementara Flyway cocok untuk tim yang menginginkan SQL murni dan kesederhanaan.
Prinsip Migration #
Beberapa prinsip yang dipakai baik di Flyway maupun Liquibase:
- Forward-only: migration hanya menambah, tidak mengedit migration lama. Bila perlu kembali, tulis migration baru yang membatalkan perubahan.
- Atomic per migration: setiap migration berjalan dalam satu transaksi; berhasil semua atau gagal semua.
- Idempotent bila mungkin: hindari
CREATE TABLEtanpaIF NOT EXISTSagar migration tetap aman bila dijalankan ulang. - Data migration terpisah dari DDL: bila migrasi data besar, pisahkan ke migration tersendiri agar tidak menggangu DDL yang cepat.
Kode Lengkap: Migration Runner Sederhana #
Kode berikut mengimplementasikan migration runner minimalis yang meniru Flyway: melacak versi di tabel schema_version, menerapkan migration pending dalam transaksi terpisah, dan menolak untuk mengaplikasikan ulang yang sudah ada.
// database_migrations.kt
// Mendemonstrasikan konsep database migration ala Flyway/Liquibase: skrip
// SQL versi yang diterapkan berurutan. Migration runner sederhana ini
// melacak versi di tabel `schema_version` dan menerapkan skrip yang belum
// dijalankan. Memakai SQLite via JDBC.
import java.sql.Connection
import java.sql.DriverManager
// Definisi migration: versi + deskripsi + SQL
data class Migration(
val version: String,
val description: String,
val sql: String
)
// Repo skrip (di produksi: file V1__init.sql, V2__add_index.sql, dst.)
val migrations: List<Migration> = listOf(
Migration(
version = "1",
description = "init schema",
sql = """
CREATE TABLE pengguna (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nama TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);
""".trimIndent()
),
Migration(
version = "2",
description = "add pesanan table",
sql = """
CREATE TABLE pesanan (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pengguna_id INTEGER NOT NULL,
total INTEGER NOT NULL,
FOREIGN KEY (pengguna_id) REFERENCES pengguna(id)
);
CREATE INDEX idx_pesanan_pengguna ON pesanan(pengguna_id);
""".trimIndent()
),
Migration(
version = "3",
description = "add created_at column",
sql = """
ALTER TABLE pesanan ADD COLUMN created_at TEXT;
""".trimIndent()
)
)
class MigrationRunner(private val conn: Connection) {
fun ensureVersionTable() {
conn.createStatement().use { it.execute(
"""CREATE TABLE IF NOT EXISTS schema_version (
version TEXT PRIMARY KEY,
description TEXT NOT NULL,
applied_at TEXT NOT NULL
)"""
) }
}
fun appliedVersions(): Set<String> {
val result = mutableSetOf<String>()
conn.createStatement().use { stmt ->
stmt.executeQuery("SELECT version FROM schema_version ORDER BY version").use { rs ->
while (rs.next()) result.add(rs.getString("version"))
}
}
return result
}
fun apply(migrations: List<Migration>) {
ensureVersionTable()
val applied = appliedVersions()
val pending = migrations.filter { it.version !in applied }.sortedBy { it.version }
if (pending.isEmpty()) {
println("Tidak ada migration pending. Skema sudah versi ${applied.maxOrNull() ?: "0"}")
return
}
for (m in pending) {
// Tiap migration dalam satu transaksi terpisah
conn.autoCommit = false
try {
// Eksekusi SQL (bisa multi-statement)
conn.createStatement().use { stmt ->
// Pisahkan multi-statement oleh ';'
m.sql.split(";").map { it.trim() }.filter { it.isNotEmpty() }.forEach { s ->
stmt.execute(s)
}
}
// Catat ke schema_version
conn.prepareStatement(
"INSERT INTO schema_version(version, description, applied_at) VALUES(?, ?, ?)"
).use { ps ->
ps.setString(1, m.version)
ps.setString(2, m.description)
ps.setString(3, "2026-01-15T08:00:00Z")
ps.executeUpdate()
}
conn.commit()
println("Applied V${m.version}__${m.description.replace(' ', '_')}: OK")
} catch (e: Exception) {
conn.rollback()
println("Failed V${m.version}: ${e.message}")
throw e
} finally {
conn.autoCommit = true
}
}
println("Skema sekarang versi ${appliedVersions().maxOrNull()}")
}
fun currentVersion(): String? = appliedVersions().maxOrNull()
}
fun main() {
Class.forName("org.sqlite.JDBC")
val url = "jdbc:sqlite:file::memory:?cache=shared"
val keeper: Connection = DriverManager.getConnection(url)
DriverManager.getConnection(url).use { conn ->
println("=== Migration scripts (versioned) ===")
migrations.forEach { m ->
val slug = m.description.replace(' ', '_')
println("V${m.version}__$slug.sql")
println("---")
println(m.sql)
println()
}
val runner = MigrationRunner(conn)
println("=== First run (semua migration pending) ===")
runner.apply(migrations)
println("\n=== Second run (tidak ada perubahan) ===")
runner.apply(migrations)
println("\n=== Tambah migration baru (V4) ===")
val updatedMigrations = migrations + Migration(
version = "4",
description = "add status column",
sql = "ALTER TABLE pesanan ADD COLUMN status TEXT DEFAULT 'NEW'"
)
runner.apply(updatedMigrations)
println("\n=== Verifikasi skema akhir ===")
conn.createStatement().use { stmt ->
stmt.executeQuery("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").use { rs ->
println("Tabel:")
while (rs.next()) println(" - ${rs.getString("name")}")
}
}
conn.createStatement().use { stmt ->
stmt.executeQuery("SELECT version, description FROM schema_version ORDER BY version").use { rs ->
println("Versi applied:")
while (rs.next()) println(" V${rs.getString("version")}: ${rs.getString("description")}")
}
}
println("\n=== Prinsip migration ===")
println("1. Skrip SQL versi (V1, V2, ...) disimpan di classpath:db/migration/")
println("2. Saat startup aplikasi, runner cek schema_version dan apply migration pending")
println("3. Migration bersifat forward-only; rollback pakai migration terpisah (Undo)")
println("4. Tiap migration wajib transaksi (commit per migration)")
println("5. Skema selalu konsisten lintas environment dev/staging/prod")
}
keeper.close()
}Output yang Dihasilkan:
=== Migration scripts (versioned) ===
V1__init_schema.sql
---
CREATE TABLE pengguna (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nama TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);
V2__add_pesanan_table.sql
---
CREATE TABLE pesanan (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pengguna_id INTEGER NOT NULL,
total INTEGER NOT NULL,
FOREIGN KEY (pengguna_id) REFERENCES pengguna(id)
);
CREATE INDEX idx_pesanan_pengguna ON pesanan(pengguna_id);
V3__add_created_at_column.sql
---
ALTER TABLE pesanan ADD COLUMN created_at TEXT;
=== First run (semua migration pending) ===
Applied V1__init_schema: OK
Applied V2__add_pesanan_table: OK
Applied V3__add_created_at_column: OK
Skema sekarang versi 3
=== Second run (tidak ada perubahan) ===
Tidak ada migration pending. Skema sudah versi 3
=== Tambah migration baru (V4) ===
Applied V4__add_status_column: OK
Skema sekarang versi 4
=== Verifikasi skema akhir ===
Tabel:
- pengguna
- pesanan
- schema_version
- sqlite_sequence
Versi applied:
V1: init schema
V2: add pesanan table
V3: add created_at column
V4: add status column
=== Prinsip migration ===
1. Skrip SQL versi (V1, V2, ...) disimpan di classpath:db/migration/
2. Saat startup aplikasi, runner cek schema_version dan apply migration pending
3. Migration bersifat forward-only; rollback pakai migration terpisah (Undo)
4. Tiap migration wajib transaksi (commit per migration)
5. Skema selalu konsisten lintas environment dev/staging/prodIntegrasi Spring Boot #
Spring Boot mendukung Flyway dan Liquibase secara native. Cukup tambah dependency dan tentukan properti:
spring:
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
datasource:
url: jdbc:postgresql://db.example.com:5432/pesanan
username: appuser
password: ${DB_PASSWORD}Saat aplikasi start, Spring Boot menjalankan migration otomatis sebelum Hibernate EntityManagerFactory dibuat — sehingga entity selalu mengacu skema terbaru.
Referensi Lanjutan #
- Flyway — dokumentasi resmi Flyway untuk versioning skema database
Selanjutnya #
Setelah skema dikelola via migration, kita sering perlu mengisi data awal — peran default, akun admin, atau data referensial. Chapter Database Seeding akan membahas cara mengisi data awal secara idempoten.