Versioning Kontrak JSON: Kompatibilitas, Deprecation, dan Migrasi Data
Intinya: Menambah field tidak otomatis aman. Membahas batas kompatibilitas, field versi, jendela deprecation, migrasi dual-write, contract test, dan metrik canary.

Intinya: jalankan evolusi versi sebagai expand, backfill, lalu contract, dan simpan versi di dalam dokumen; pastikan konsumen toleran terhadap field dan nilai enum tak dikenal sebelum mengirim apa pun, dan lewati deprecation melalui freeze, announce, observe, dan remove dalam urutan itu.
Bagian sulit dari versioning bukan mengubah kode; melainkan bahwa Anda tidak bisa tahu siapa yang masih membaca field lama. Kompatibilitas bukan janji satu arah — ia adalah penyelarasan berkelanjutan antara penerbit dan konsumen yang tidak dikenal.
1. Batas kompatibilitas: penambahan tidak otomatis aman
Buku teks menyebut field opsional baru "kompatibel mundur", tetapi itu hanya berlaku ketika konsumen toleran terhadap field tak dikenal. Menambah field tetap merusak klien dalam setidaknya tiga situasi:
- konsumen memvalidasi dengan schema yang menetapkan
additionalProperties: false; - konsumen menelusuri object dalam urutan tetap, atau membaca nilai berdasarkan posisi alih-alih berdasarkan key;
- field baru memakai nama lama tetapi mengubah maknanya, misalnya memperlebar
statusdari "status order" menjadi "status fulfillment".
Penambahan yang aman butuh tiga prasyarat: field opsional, maknanya mandiri, dan konsumen mengabaikan key tak dikenal. Pastikan ketiganya sebelum Anda bahkan membahas nomor versi.
{
"schemaVersion": 2,
"orderId": "ord-8842",
"status": "paid",
"refundDeadline": "2026-11-30T00:00:00+08:00",
"channel": "wechat"
}
Di sini refundDeadline dan channel adalah tambahan v2 yang opsional, sementara orderId dan status mempertahankan semantik aslinya. schemaVersion disimpan bersama dokumen alih-alih hanya hidup di header HTTP.
2. Perubahan yang memutus kompatibilitas dan tidak boleh dikirim
Perlakukan perubahan berikut sebagai garis merah yang membutuhkan versi baru atau transisi dual-write. Memperbarui dokumentasi tidak akan menyelamatkan Anda:
| Perubahan | Mengapa memutus kompatibilitas | Alternatif yang bisa diterima |
|---|---|---|
| Menghapus field | Konsumen langsung membaca undefined |
Deprecate, amati, lalu hapus setelah jendela berakhir |
| Mengubah tipe field | "1" menjadi 1; klien bertipe kuat langsung gagal dan yang bertipe longgar sering gagal validasi |
Tambah nama field baru dan dual-write selama transisi |
| Menambah field wajib | Pemanggil lama tidak mengirimnya, sehingga validasi selalu gagal | Sediakan default, atau jadikan wajib hanya di versi baru |
| Mengubah himpunan nilai enum | Pernyataan switch konsumen tidak punya cabang fallback |
Ajari klien menerima nilai tak dikenal dulu, lalu perluas enum |
| Mengubah makna field | Tipe tetap valid sementara perilaku berubah — kasus tersulit untuk di-debug | Perlakukan sebagai field baru dan bekukan semantik lama |
| Mengubah kedalaman nesting | Setiap jalur akses harus berubah | Pertahankan bentuk lama dan tambahkan field datar atau parameter expand |
Mengubah makna adalah baris paling berbahaya: validasi schema tetap hijau, pemantauan tidak melaporkan error, dan hanya metrik bisnis yang melenceng. Perubahan makna apa pun sebaiknya dikirim sebagai field baru.
3. Di mana versi berada: dokumen, URL, media type
Tiga penempatan umum melayani tujuan berbeda:
| Lokasi | Contoh | Cocok untuk | Keterbatasan |
|---|---|---|---|
| Field dokumen | "schemaVersion": 2 |
Basis data, message queue, backup offline | Lapisan transport tidak bisa merutekan berdasarkan itu |
| Path URL | /v2/orders/8842 |
API publik | Mengikat alamat resource ke versi dan membuat migrasi mahal |
| Media type | application/vnd.acme.order.v2+json |
Layanan internal yang butuh negosiasi konten | Pemanggil harus menetapkan Accept dengan benar |
Pembagian kerja yang praktis: gunakan negosiasi media-type di wire, dan selalu simpan field versi di dalam dokumen. Pesan lama di queue tidak akan naik versi karena Anda mengubah URL, begitu pula file backup. Hanya schemaVersion di dalam dokumen yang memberi fondasi untuk replay, backfill, dan audit.
GET /orders/ord-8842 HTTP/1.1
Host: api.bbdrug.com
Accept: application/vnd.acme.order.v2+json
Jika Anda memang mem-version URL, setidaknya biarkan path lama tetap mengembalikan bentuk lama sepanjang seluruh jendela deprecation alih-alih diam-diam mengarahkannya ke implementasi baru. Untuk keputusan kontrak API yang lebih luas, lihat Poin penting mendesain kontrak data JSON API.
4. Membuka jendela deprecation
Deprecation bukan satu pengumuman; ia adalah jendela dengan awal, periode pengamatan, dan akhir. Irama yang bisa diterapkan:
- Freeze: berhenti menulis field lama, tetapi tetap membacanya.
- Announce: tandai tanggal penghapusan di dokumentasi, changelog, dan header respons sekaligus.
- Observe: hitung pembacaan field lama per pemanggil sampai ia tetap nol selama dua periode berturut-turut.
- Remove: hanya setelah jendela ditutup, pembacaan nol, dan tes regresi lolos, hapus field dan kode kompatibilitasnya.
Header respons Sunset (RFC 8594) memberi klien tanggal penghapusan yang bisa dibaca mesin. Deprecation masih berupa draf IETF: bisa dipakai, tetapi jangan mengasumsikan setiap klien memahaminya. Pasangkan dengan header Link yang menunjuk ke panduan migrasi:
Deprecation: true
Sunset: Wed, 31 Mar 2027 23:59:59 GMT
Link: <https://bbdrug.com/docs/deprecations/order-status>; rel="deprecation"; type="text/html"
Panjang jendela adalah konvensi tim. Lantai yang umum adalah satu siklus rilis atau 90 hari, mana yang lebih lama. API publik sebaiknya mendapat lebih banyak, layanan internal bisa lebih sedikit, tetapi jangan pernah kurang dari waktu yang dibutuhkan setiap pemanggil untuk melakukan upgrade.
5. Menemukan konsumen sebelum deprecate
Sebelum deprecate, Anda harus bisa menjawab "siapa yang masih membaca ini?" Jika tidak, jendelanya adalah penipuan diri. Urutkan bukti berdasarkan keandalan:
- Log gateway: agregasi akses field lama berdasarkan
appId,User-Agent, dan versi klien. - Distributed trace: tandai field lama di lapisan serialisasi agar trace langsung menunjukkan nama layanan pemanggil.
- Registry konsumen queue: daftar consumer group lebih andal daripada pencarian kode.
- Pencarian repositori:
rghanya mencakup kode Anda sendiri; pemanggil eksternal tetap membutuhkan log.
rg --type ts --type js 'legacyStatus|statusCode' src/ --stats
Temuan pencarian membuktikan "seseorang di repositori ini membacanya", bukan "tidak ada orang lain yang membacanya". Ekspor log gateway dari hari ke hari dan pastikan pembacaan field lama tetap nol sebelum menghapus apa pun.
6. Migrasi data: expand, backfill, contract
Parallel Change Martin Fowler menjelaskan tiga langkah: expand (field lama dan baru hidup berdampingan dan kedua versi bisa dibaca), backfill (pindahkan record historis ke bentuk baru), dan contract (hapus field lama dan cabang kompatibilitas). Jangan membalik urutannya, atau Anda tidak akan punya jalur rollback.
// Kerangka migrasi: cursor berdasarkan primary key, penulisan kondisional untuk idempotensi
async function migrateBatch(collection, cursor, batchSize = 500) {
const records = await collection
.find({ _id: { $gt: cursor } })
.limit(batchSize)
.toArray();
if (records.length === 0) {
return { nextCursor: cursor, done: true };
}
const operations = records
.filter((record) => record.schemaVersion < 2)
.map((record) => ({
updateOne: {
filter: { _id: record._id, schemaVersion: record.schemaVersion },
update: {
$set: {
schemaVersion: 2,
status: record.legacyStatus ?? record.status,
refundDeadline: record.refundDeadline ?? null,
},
},
},
}));
if (operations.length > 0) {
await collection.bulkWrite(operations);
}
return { nextCursor: records.at(-1)._id, done: false };
}
Detail yang mudah terlewat:
- Idempotensi: sertakan nomor versi lama di
filter, agar menjalankan ulang tidak pernah menulis ulang record yang sudah dimigrasi. - Batching dan checkpoint: maju berdasarkan cursor
_id, simpan checkpoint, dan lanjutkan dari sana setelah kegagalan. - Rehearsal: jalankan skrip terhadap snapshot produksi dan bandingkan jumlah record serta checksum sebelum dan sesudah.
- Rollback: pertahankan field lama sampai field baru lolos validasi; ganti jalur baca dengan flag dan jangan pernah rollback hanya dengan mengedit
schemaVersion. - Validasi: jalankan batasan bisnis setelah migrasi (jumlah uang non-negatif, transisi state legal), bukan sekadar pengecekan parse JSON.
Data konfigurasi mengikuti proses yang sama; lihat Mengelola JSON untuk konfigurasi, fixture, dan data lokal untuk cara mengatur file-file itu.
7. Contract test: taruh kompatibilitas di CI
Kompatibilitas tidak bisa bergantung pada ingatan code review; ia butuh tes. Dua lapisan sudah cukup:
- Shape test: validasi respons dengan JSON Schema, tetapi jangan menetapkan
additionalProperties: falsepada schema yang dipakai konsumen. - Consumer-subset test: ekstrak field yang benar-benar diandalkan konsumen lama ke sub-schema dan assert bahwa respons baru masih memenuhinya.
// Contract test: respons baru harus tetap memenuhi subset konsumen lama
import Ajv from 'ajv';
import assert from 'node:assert/strict';
import { test } from 'node:test';
const ajv = new Ajv({ strict: false });
const validateLegacySubset = ajv.compile(legacyConsumerSubsetSchema);
test('a v2 response still satisfies the v1 consumer subset', () => {
const response = buildOrderResponse({ schemaVersion: 2 });
const { schemaVersion, ...legacyFields } = response;
const isValid = validateLegacySubset(legacyFields);
assert.equal(isValid, true, JSON.stringify(validateLegacySubset.errors));
});
Sub-schema hanya mendeklarasikan field dan tipe yang dibaca konsumen lama dan membiarkan setiap field baru lewat. Itu menangkap field yang dihapus dan perubahan tipe tanpa alarm palsu pada penambahan. Simpan data kasus di direktori fixture, terurut dan bisa di-diff; untuk konflik merge di file-file itu, lihat Konflik Git file JSON: union menulis JSON rusak, merge berdasarkan key sebagai gantinya.
8. Peluncuran canary: skala berdasarkan dimensi, berhenti pada metrik
Masalah kompatibilitas jarang muncul di unit test; mereka muncul di canary. Perluas eksposur dari sempit ke luas:
| Dimensi | Praktik | Apa yang divalidasi |
|---|---|---|
| Allowlist tenant | Aktifkan jalur baca baru untuk pelanggan internal atau bersahabat dulu | Perbedaan bentuk data dunia nyata |
| Versi klien | Aktifkan hanya untuk klien yang sudah di-upgrade ke bentuk baru | Klien lama dan baru berjalan berdampingan |
| Persentase trafik | 5% → 25% → 100%, mengamati satu siklus bisnis penuh per langkah | Panggilan long-tail dan perilaku cache hit |
Pantau setidaknya tiga metrik: tingkat error API, tingkat kegagalan validasi kontrak, dan rasio baca antara field lama dan baru. Definisikan kondisi berhenti otomatis, seperti 5xx di atas 1% atau lonjakan mendadak pembacaan field lama, lalu rollback flag. Pertahankan dual write selama canary agar rollback hanya membutuhkan penggantian jalur baca, bukan perbaikan data.
9. Checklist kompatibilitas sebelum rilis
- [ ] Field baru opsional, mandiri secara semantik, dan tidak memakai ulang nama lama.
- [ ] Konsumen menangani field tak dikenal dan nilai enum tak dikenal dengan fallback.
- [ ] Field versi disimpan bersama dokumen dan bisa dinegosiasikan di wire.
- [ ] Jendela deprecation punya pengumuman, header
Sunset, dan pengamatan. - [ ] Pembacaan field lama bisa dilacak ke pemanggil tertentu melalui log atau trace.
- [ ] Skrip migrasi idempoten, terbatch, bisa dilanjutkan, dan punya jalur rollback.
- [ ] Contract test mencakup subset konsumen lama dan berjalan di CI.
- [ ] Canary punya dimensi eksposur eksplisit dan metrik berhenti.
FAQ
Apakah menambah field opsional selalu kompatibel mundur?
Tidak. Ia kompatibel hanya ketika konsumen mengabaikan field tak dikenal dan menghindari validasi schema ketat. Konsumen yang memakai additionalProperties: false atau membaca berdasarkan posisi tetap akan gagal. Verifikasi toleransi mereka dengan contract test sebelum merilis.
Apakah schemaVersion sebaiknya berada di URL atau di response body?
Wire bisa menegosiasikan versi melalui media type atau path URL, tetapi record yang disimpan harus membawa schemaVersion di dalam dokumen. Queue, backup offline, dan audit tidak pernah melewati URL, jadi hanya versi di dalam dokumen yang bisa mendukung replay dan backfill.
Bisakah field deprecated langsung dihapus?
Tidak. Berhenti menulisnya, umumkan tanggal penghapusan, dan hitung pembacaan per pemanggil. Hapus hanya setelah jendela ditutup dan pembacaan tetap nol. Sebelum menghapus, jalankan regresi penuh untuk memastikan tidak ada cron job atau laporan tersembunyi yang masih membacanya.
Bagaimana jika skrip migrasi gagal di tengah jalan?
Rancang sebagai job idempoten: update kondisional, cursor primary key, dan checkpoint. Lanjutkan dari checkpoint setelah kegagalan, dan menjalankan ulang tidak boleh menulis dua kali. Untuk rollback, pertahankan field lama dan snapshot; jangan hanya mengembalikan schemaVersion.
Bagaimana urutan migrasi data sebaiknya?
Ikuti Parallel Change: expand dulu agar field lama dan baru hidup berdampingan dan kedua versi bisa dibaca, backfill record historis ke bentuk baru, lalu contract dengan menghapus field lama dan cabang kompatibilitas. Jangan membalik urutannya, atau Anda kehilangan jalur rollback, dan buat skrip migrasi idempoten, terbatch, dan bisa dilanjutkan.