API slop sering tidak terlihat saat code review karena terlihat “masuk akal” secara sintaks, lulus unit test yang dangkal, dan baru pecah di produksi ketika request nyata mulai masuk. Dalam studi kasus ini, layanan backend mendadak mengembalikan 500 Internal Server Error setelah tim menerima kontribusi kode hasil AI/intern yang mengubah struktur payload, tetapi tidak menyelaraskan DTO, validator, dan query layer.

Masalah utamanya bukan sekadar bug parsing, melainkan schema drift: kontrak data bergeser di satu lapisan, sementara lapisan lain masih mengasumsikan bentuk lama. Akibatnya, log awal terlihat menyesatkan, exception muncul jauh dari titik penyebab, dan tim cenderung salah fokus ke database atau infrastruktur. Artikel ini membahas gejala produksi, cara mengisolasi akar masalah, contoh payload before/after, pseudo-code, checklist reproduksi, perbaikan, contract testing, observability, dan guardrail review agar pola bug seperti ini tidak terulang.

Apa yang Terjadi di Produksi

Kasus bermula dari endpoint pembaruan profil pelanggan. Sebelumnya, backend menerima payload datar untuk field alamat. Kontribusi baru mencoba “merapikan” struktur request menjadi nested object agar lebih konsisten dengan frontend lain. Perubahan tampak kecil dan niatnya baik, tetapi kontrak API tidak diperbarui secara eksplisit.

Gejala di produksi biasanya terlihat seperti ini:

  • Lonjakan HTTP 500 pada satu endpoint tertentu.
  • Request dari klien lama gagal, tetapi request dari tool internal atau skenario manual tertentu tampak normal.
  • Log aplikasi menunjukkan error generik seperti null reference, key not found, serialization error, atau query binding failure.
  • Dashboard database terlihat ada error query, sehingga tim sempat mengira masalah ada di migrasi, indeks, atau koneksi.

Inti masalahnya: payload yang masuk masih valid secara JSON, tetapi sudah tidak sesuai dengan asumsi antar lapisan backend.

Studi Kasus: Schema Drift antara Payload, DTO, Validator, dan Query Layer

Payload sebelum perubahan

{
  "userId": "u_123",
  "street": "Jl. Melati 10",
  "city": "Bandung",
  "postalCode": "40123"
}

Payload setelah kontribusi AI/intern

{
  "userId": "u_123",
  "address": {
    "street": "Jl. Melati 10",
    "city": "Bandung",
    "postalCode": "40123"
  }
}

Perubahan ini sendiri belum tentu salah. Yang menjadi masalah adalah hanya sebagian lapisan yang ikut berubah.

Lapisan yang drift

  • Controller sudah menerima address.street.
  • DTO masih mendefinisikan field datar: street, city, postalCode.
  • Validator sebagian diperbarui, tetapi masih mengizinkan field lama atau tidak mewajibkan object address.
  • Query layer / repository masih membaca properti DTO lama.

Hasilnya, request lolos sampai tahap tertentu, lalu gagal ketika repository membangun query update dari properti yang ternyata null atau tidak ada.

Contoh pseudo-code yang bermasalah

// controller
function updateProfile(request) {
  dto = new UpdateProfileDTO({
    userId: request.body.userId,
    address: request.body.address
  })

  validator.validate(dto)
  service.updateProfile(dto)
}

// DTO lama: belum sinkron
class UpdateProfileDTO {
  constructor(data) {
    this.userId = data.userId
    this.street = data.street
    this.city = data.city
    this.postalCode = data.postalCode
    // field address diabaikan
  }
}

// repository: masih asumsi schema lama
function updateProfileInDb(dto) {
  db.query(
    'UPDATE profiles SET street = ?, city = ?, postal_code = ? WHERE user_id = ?',
    [dto.street.trim(), dto.city.trim(), dto.postalCode.trim(), dto.userId]
  )
}

Jika dto.street bernilai undefined atau null, error yang keluar bisa berupa:

  • Cannot read properties of undefined
  • query parameter invalid
  • constraint violation karena field wajib menjadi null
  • mapping error di ORM

Masalah seperti ini sering disebut API slop: perubahan terlihat produktif di permukaan, tetapi tidak menjaga kontrak end-to-end. Dalam konteks praktis seperti diskusi “intern code” atau “API slop”, persoalannya bukan siapa yang menulis kodenya, melainkan tidak adanya verifikasi kontrak dan review lintas lapisan.

Kenapa Log Awal Sering Menyesatkan

Pada banyak sistem, exception pertama yang terlihat bukan akar masalah sebenarnya. Jika observability kurang lengkap, tim hanya melihat gejala di bagian paling akhir.

Pola log yang menyesatkan

ERROR updateProfile failed
message="Cannot read properties of undefined (reading 'trim')"
requestId="req-7f2a"
path="/api/profile/update"

Log di atas menunjukkan titik pecah di repository atau mapper, padahal sumbernya adalah request schema yang berubah. Tanpa logging payload shape atau metadata validasi, tim mudah salah diagnosis.

Contoh lain adalah log query:

ERROR db update failed
message="invalid input syntax for type ..."
requestId="req-7f2a"

Ini membuat tim curiga ke database, padahal database hanya menerima data rusak dari lapisan sebelumnya.

Kenapa bisa lolos dari validator

Ada beberapa pola umum:

  • Validator hanya memeriksa keberadaan userId, bukan struktur lengkap payload.
  • Validator mengizinkan unknown fields dan tidak menolak bentuk baru/lama secara tegas.
  • DTO melakukan transformasi diam-diam yang membuang field nested.
  • Layer service mengasumsikan objek sudah valid tanpa defensive check tambahan.

Langkah Isolasi Bug yang Efektif

Saat menghadapi 500 yang diduga berasal dari schema drift, tujuan pertama bukan langsung memperbaiki, melainkan mempersempit titik drift. Berikut langkah yang praktis.

1. Korelasikan request yang gagal

Ambil beberapa request ID dari error 500 dan bandingkan dengan request yang sukses. Cari perbedaan shape payload, bukan hanya nilainya.

  • Apakah field pindah dari top-level ke nested object?
  • Apakah nama field berubah, misalnya postalCode menjadi zipCode?
  • Apakah ada perubahan tipe, misalnya string menjadi number atau array menjadi object?

2. Log bentuk data di setiap boundary

Tambahkan logging sementara di boundary penting:

  • request masuk ke controller
  • hasil parsing/DTO mapping
  • hasil validator
  • data sebelum query/repository

Yang penting bukan dump seluruh payload sembarangan, melainkan bentuk field yang relevan dan aman dari sisi data sensitif.

log.info('updateProfile.request_shape', {
  requestId,
  hasAddressObject: !!body.address,
  hasStreetTopLevel: body.street !== undefined,
  keys: Object.keys(body)
})

log.info('updateProfile.dto_shape', {
  requestId,
  hasStreet: dto.street !== undefined,
  hasCity: dto.city !== undefined,
  hasPostalCode: dto.postalCode !== undefined
})

Dengan log seperti ini, schema drift cepat terlihat tanpa harus membocorkan PII.

3. Reproduksi dengan payload before/after

Jangan hanya mengandalkan satu contoh request. Uji payload lama dan baru terhadap build yang sama.

curl -X POST http://localhost:3000/api/profile/update \
  -H 'Content-Type: application/json' \
  -d '{
    "userId": "u_123",
    "street": "Jl. Melati 10",
    "city": "Bandung",
    "postalCode": "40123"
  }'

curl -X POST http://localhost:3000/api/profile/update \
  -H 'Content-Type: application/json' \
  -d '{
    "userId": "u_123",
    "address": {
      "street": "Jl. Melati 10",
      "city": "Bandung",
      "postalCode": "40123"
    }
  }'

Jika salah satu lolos dan yang lain pecah, Anda hampir pasti berhadapan dengan perubahan kontrak yang tidak konsisten.

4. Periksa definisi kontrak di satu tempat, lalu cocokkan ke semua layer

Bandingkan:

  • spesifikasi API atau OpenAPI jika ada
  • validator schema
  • DTO / request object
  • mapper ke model domain
  • query builder atau repository
  • test integrasi endpoint

Tujuannya adalah menemukan titik pertama di mana schema berubah tetapi lapisan berikutnya belum ikut diperbarui.

5. Pastikan error yang salah harus berubah menjadi 4xx

Kalau request klien tidak sesuai kontrak, respons yang benar biasanya 400 Bad Request atau 422 Unprocessable Entity, bukan 500. Jika server melempar 500, itu tanda validation boundary terlalu lemah.

Checklist Reproduksi Debugging

  1. Ambil request ID dari error 500 terbaru.
  2. Temukan sample payload dari access log, tracing, atau replay tool.
  3. Bandingkan payload sukses vs gagal.
  4. Jalankan endpoint lokal dengan payload before/after.
  5. Tambahkan logging shape di controller, DTO, validator, service, repository.
  6. Verifikasi apakah field berubah nama, posisi, atau tipe.
  7. Periksa apakah validator benar-benar menolak payload invalid.
  8. Periksa apakah mapper/DTO membuang field baru secara diam-diam.
  9. Periksa query layer apakah masih memakai field lama.
  10. Tulis regression test sebelum melakukan fix.

Perbaikan yang Tepat: Sinkronkan Kontrak, Jangan Patch Gejala

Godaan paling umum adalah menambah if di repository atau melakukan fallback diam-diam. Itu bisa meredakan insiden, tetapi sering meninggalkan ambiguitas kontrak. Perbaikan yang lebih sehat adalah menetapkan satu bentuk input yang sah, memvalidasinya di boundary, lalu menurunkannya secara konsisten ke seluruh layer.

Pendekatan 1: Tetapkan schema baru dan tolak schema lama

Pendekatan ini cocok jika perubahan memang disengaja dan klien sudah siap migrasi.

// validator pseudo-code
schema = {
  type: 'object',
  required: ['userId', 'address'],
  additionalProperties: false,
  properties: {
    userId: { type: 'string' },
    address: {
      type: 'object',
      required: ['street', 'city', 'postalCode'],
      additionalProperties: false,
      properties: {
        street: { type: 'string', minLength: 1 },
        city: { type: 'string', minLength: 1 },
        postalCode: { type: 'string', minLength: 1 }
      }
    }
  }
}
class UpdateProfileDTO {
  constructor(data) {
    this.userId = data.userId
    this.street = data.address.street
    this.city = data.address.city
    this.postalCode = data.address.postalCode
  }
}

Keuntungan:

  • Kontrak jelas.
  • Lapisan downstream menerima bentuk yang stabil.
  • Request invalid ditolak lebih awal dengan 4xx.

Kekurangan:

  • Bisa mematahkan kompatibilitas klien lama.
  • Perlu komunikasi perubahan dan mungkin versioning.

Pendekatan 2: Dukung masa transisi secara eksplisit

Jika klien lama belum bisa langsung migrasi, backend dapat menerima schema lama dan baru untuk periode tertentu, tetapi transformasinya harus eksplisit dan terdokumentasi.

function normalizeRequest(body) {
  if (body.address) {
    return {
      userId: body.userId,
      street: body.address.street,
      city: body.address.city,
      postalCode: body.address.postalCode
    }
  }

  return {
    userId: body.userId,
    street: body.street,
    city: body.city,
    postalCode: body.postalCode
  }
}

Gunakan pendekatan ini hanya jika benar-benar diperlukan, dan beri batas waktu deprecasi. Jika tidak, API akan menyimpan kompleksitas transisi terlalu lama.

Jangan lakukan ini

  • Menangkap exception lalu tetap mengembalikan 200 dengan data parsial.
  • Membiarkan validator longgar agar “lebih fleksibel”.
  • Menambal repository dengan banyak fallback tanpa memperbaiki boundary contract.
  • Mengandalkan ORM error untuk memberi tahu bahwa payload salah.

Test Regresi yang Seharusnya Ada

Bug schema drift jarang tertangkap oleh unit test yang hanya memeriksa satu fungsi. Yang dibutuhkan adalah kombinasi test boundary dan test integrasi.

1. Test validasi request

it('menolak payload tanpa address pada schema baru', async () => {
  const payload = {
    userId: 'u_123',
    street: 'Jl. Melati 10',
    city: 'Bandung',
    postalCode: '40123'
  }

  const res = await callApi('/api/profile/update', payload)
  expect(res.status).toBe(422)
})

2. Test integrasi endpoint ke persistence

it('menyimpan alamat saat payload sesuai kontrak', async () => {
  const payload = {
    userId: 'u_123',
    address: {
      street: 'Jl. Melati 10',
      city: 'Bandung',
      postalCode: '40123'
    }
  }

  const res = await callApi('/api/profile/update', payload)
  expect(res.status).toBe(200)

  const profile = await loadProfile('u_123')
  expect(profile.street).toBe('Jl. Melati 10')
})

3. Test negative case untuk memastikan tidak kembali 500

it('request invalid tidak menghasilkan 500', async () => {
  const payload = {
    userId: 'u_123',
    address: null
  }

  const res = await callApi('/api/profile/update', payload)
  expect([400, 422]).toContain(res.status)
})

Poin pentingnya: test bukan hanya memeriksa jalur sukses, tetapi juga memastikan request yang melanggar kontrak gagal secara benar dan terprediksi.

Contract Testing untuk Mencegah Schema Drift

Kalau tim Anda sering menerima kontribusi lintas orang, lintas servis, atau lintas generator AI, contract testing adalah guardrail penting. Tujuannya memastikan provider dan consumer sepakat pada shape data yang sama.

Apa yang perlu diuji

  • field wajib dan opsional
  • nama field
  • nesting object
  • tipe data
  • contoh respons error saat request invalid

Pilihan pendekatan

  • Schema validation di CI: cocok jika Anda punya spesifikasi request/response yang jelas, misalnya OpenAPI atau JSON Schema.
  • Consumer-driven contract test: cocok jika banyak klien bergantung pada endpoint yang sama dan Anda ingin memastikan perubahan provider tidak mematahkan mereka.
  • Snapshot payload shape: berguna sebagai lapisan tambahan, tetapi jangan dijadikan satu-satunya kontrak.

Trade-off utamanya adalah tambahan biaya pemeliharaan. Namun biaya ini biasanya jauh lebih kecil dibanding insiden produksi akibat perubahan kontrak yang lolos diam-diam.

Observability yang Membantu, Bukan Sekadar Banyak Log

Untuk kasus 500 akibat API slop, observability yang efektif berfokus pada konteks request dan boundary failure.

Minimal telemetry yang sebaiknya ada

  • Request ID yang konsisten dari gateway sampai database log.
  • Structured logging untuk field penting seperti endpoint, validator result, error class, dan payload shape.
  • Error categorization yang membedakan validation error, mapping error, dan database error.
  • Metrics per endpoint untuk 2xx, 4xx, 5xx.
  • Tracing jika arsitektur cukup kompleks atau terdiri dari beberapa servis.

Praktik logging yang aman

  • Log bentuk data, bukan isi sensitifnya.
  • Masking field seperti email, nomor telepon, token, dan alamat lengkap jika tidak perlu.
  • Jangan menulis seluruh payload mentah ke log produksi tanpa alasan kuat.

Tujuan observability adalah mempercepat jawaban atas pertanyaan: request seperti apa yang masuk, validasi di mana gagal, dan data seperti apa yang diteruskan ke layer berikutnya?

Guardrail Review agar Bug Serupa Tidak Lolos Lagi

Bug ini sering lolos bukan karena sulit, tetapi karena perubahan kontrak tampak kecil dan review hanya fokus pada syntax atau style. Berikut guardrail yang lebih efektif.

Checklist review untuk perubahan API

  • Apakah bentuk request/response berubah?
  • Apakah spesifikasi kontrak ikut diperbarui?
  • Apakah validator, DTO, mapper, service, dan repository sudah sinkron?
  • Apakah ada test untuk payload lama, payload baru, dan negative case?
  • Jika perubahan breaking, apakah ada versioning atau rencana deprecasi?
  • Apakah observability cukup untuk mendeteksi mismatch di produksi?

Guardrail di pipeline

  • Fail CI jika schema berubah tanpa update spesifikasi.
  • Jalankan contract test terhadap endpoint yang diubah.
  • Wajibkan integration test untuk perubahan request mapping.
  • Tambahkan rule review bahwa perubahan payload harus disetujui oleh pemilik API.

Guardrail untuk kontribusi hasil AI

Kode hasil AI tidak perlu diperlakukan istimewa, tetapi memang perlu review yang memeriksa asumsi, bukan hanya kerapian output. Beberapa pertanyaan yang wajib ditanyakan:

  • Bagian mana dari kontrak yang berubah?
  • Apakah perubahan hanya di controller atau sampai persistence?
  • Apakah validator menolak bentuk yang salah?
  • Apakah code generator atau AI memperkenalkan field baru yang tidak ada di spesifikasi?

Masalah utama dalam “API slop” adalah perubahan yang terdengar benar, tetapi tidak diverifikasi secara end-to-end.

Penutup

Debug backend: API slop dari AI picu 500 karena schema drift pada dasarnya adalah masalah kontrak yang tidak dijaga. Gejalanya muncul sebagai 500, log awal sering menyesatkan ke query atau database, tetapi akar masalahnya ada pada pergeseran schema antara payload, DTO, validator, dan query layer.

Strategi yang paling efektif adalah:

  1. bandingkan payload sukses dan gagal,
  2. log shape data di setiap boundary,
  3. temukan titik drift pertama,
  4. perbaiki kontrak di boundary,
  5. tambahkan regression test dan contract test,
  6. pasang guardrail review dan observability yang tepat.

Jika request invalid ditolak lebih awal dengan 4xx, shape data dinormalisasi secara eksplisit, dan kontrak diuji di CI, bug seperti ini biasanya berhenti menjadi insiden produksi dan turun menjadi kegagalan build yang jauh lebih murah untuk diperbaiki.