Spec Driven Development Part 3: Spec untuk API & Kontrak Data
10 min read

Spec Driven Development Part 3: Spec untuk API & Kontrak Data

Part 2 membahas cara menulis spec untuk fitur — intent, constraint, acceptance criteria, dan non-goals yang ditujukan untuk satu potongan pekerjaan yang dikerjakan sekali. Tapi ada satu kategori spec yang punya karakteristik berbeda: spec untuk API dan skema data. Bedanya bukan di format, tapi di konsekuensi. Sebuah spec fitur yang ambigu paling buruk menghasilkan implementasi yang salah dan perlu direvisi. Sebuah kontrak API yang ambigu bisa membuat frontend dan backend membangun asumsi berbeda secara paralel, atau lebih buruk, membuat breaking change yang merusak klien eksternal yang bahkan tidak kamu tahu keberadaannya. Artikel ini membahas cara menulis spec untuk API dan data sebagai kontrak yang benar-benar executable — bisa divalidasi otomatis, bukan sekadar dokumentasi yang dibaca lalu diabaikan.

Kenapa Kontrak Data Berbeda dari Spec Fitur Biasa

Spec fitur biasa punya satu “konsumen” utama: agent atau developer yang mengimplementasikan fitur tersebut. Begitu fitur selesai dan terverifikasi, spec itu sudah menyelesaikan tugasnya — meskipun tetap berguna sebagai dokumentasi historis.

Kontrak API dan skema data berbeda karena punya banyak konsumen yang bekerja secara paralel dan independen:

  • Tim frontend yang membangun UI berdasarkan asumsi bentuk response
  • Tim backend lain yang mengonsumsi API ini sebagai bagian dari service mereka
  • Klien eksternal atau partner yang mengintegrasikan sistem mereka dengan API ini
  • AI agent yang menghasilkan kode di kedua sisi — kadang agent yang sama menulis server dan client, kadang agent berbeda yang tidak saling tahu asumsi satu sama lain

Karena banyak pihak bergantung pada kontrak yang sama, ambiguitas di sini jauh lebih mahal. Kalau spec fitur reset password dari Part 2 ambigu soal “berapa lama token kedaluwarsa”, dampaknya terbatas pada satu fitur. Kalau spec API ambigu soal “apakah field email ini selalu ada di response atau bisa null”, dampaknya menyebar ke setiap kode yang pernah mengonsumsi endpoint tersebut — dan begitu sudah dipakai banyak pihak, memperbaikinya menjadi breaking change yang harus dikoordinasikan, bukan sekadar revisi internal.

Inilah kenapa kontrak data butuh format spec yang lebih ketat dan, idealnya, bisa divalidasi oleh tooling — bukan hanya dibaca manusia.

Spec fitur yang ambigu menghasilkan revisi. Spec API yang ambigu menghasilkan integrasi yang gagal di production, kadang berbulan-bulan setelah kontraknya ditulis, ketika konsumen baru muncul dengan asumsi berbeda dari yang dimaksud penulis spec.

OpenAPI sebagai Spec yang Executable

Untuk REST API, OpenAPI (sebelumnya dikenal sebagai Swagger) adalah format yang paling matang untuk menulis kontrak yang executable. Bedanya dengan dokumentasi API biasa: OpenAPI ditulis dalam format terstruktur (YAML atau JSON) yang bisa diparsing oleh tooling, bukan prosa bebas yang hanya bisa dibaca manusia.

Ada tiga keuntungan praktis menjadikan OpenAPI sebagai spec, bukan hasil akhir:

Validasi otomatis. Request dan response bisa divalidasi terhadap schema secara otomatis di test maupun di runtime. Kalau implementasi menyimpang dari kontrak, validasi akan gagal — tidak perlu menunggu reviewer manusia menyadari ketidaksesuaian.

Generate stub dan client. Dari satu file OpenAPI, bisa digenerate server stub, client SDK, bahkan mock server untuk testing — semuanya konsisten karena berasal dari sumber yang sama.

Dokumentasi yang tidak pernah basi. Karena dokumentasi diturunkan dari spec yang sama dengan yang divalidasi, dokumentasi tidak bisa “lupa diupdate” seperti README yang ditulis manual terpisah dari kode.

Yang membuat OpenAPI relevan secara khusus untuk SDD: agent bisa membaca file OpenAPI dan langsung memahami bentuk request/response yang diharapkan, tanpa perlu menebak dari membaca kode existing yang mungkin sudah tidak konsisten.

JSON Schema untuk Validasi Data

OpenAPI sendiri menggunakan JSON Schema di balik layar untuk mendefinisikan bentuk request dan response body. Tapi JSON Schema juga berguna sebagai spec tersendiri — misalnya untuk struktur data yang disimpan di database, payload event/message queue, atau konfigurasi yang dibaca aplikasi.

Prinsip dasarnya: setiap field harus didefinisikan tipe, apakah required, dan constraint nilainya — bukan dibiarkan implisit.

{
  "type": "object",
  "required": ["id", "email", "status", "createdAt"],
  "properties": {
    "id": {
      "type": "string",
      "format": "uuid"
    },
    "email": {
      "type": "string",
      "format": "email",
      "maxLength": 255
    },
    "status": {
      "type": "string",
      "enum": ["pending", "active", "suspended", "deleted"]
    },
    "displayName": {
      "type": ["string", "null"],
      "maxLength": 100
    },
    "createdAt": {
      "type": "string",
      "format": "date-time"
    }
  },
  "additionalProperties": false
}

Perhatikan beberapa detail yang sengaja eksplisit di sini:

  • required menyatakan field mana yang wajib ada — agent tidak perlu menebak apakah email bisa hilang dari response
  • enum pada status mencegah agent (atau implementasi manapun) menambahkan nilai status baru yang tidak terdokumentasi
  • displayName secara eksplisit dinyatakan boleh null, berbeda dari field lain yang tidak boleh — perbedaan ini sering jadi sumber bug kalau hanya diasumsikan
  • additionalProperties: false melarang field tambahan yang tidak terdaftar, mencegah schema “mengembang” diam-diam seiring waktu
Schema yang membiarkan additionalProperties default (yang berarti true) terlihat fleksibel, tapi ini membuka pintu bagi field tak terduga menyelinap masuk tanpa validasi. Untuk kontrak yang harus stabil, eksplisit melarang field tambahan jauh lebih aman daripada mengasumsikan semua pihak akan disiplin.

Protobuf untuk Kontrak Antar Service

Untuk komunikasi antar service di dalam sistem sendiri — terutama yang sensitif terhadap performa atau butuh strong typing yang lebih ketat dari JSON — Protocol Buffers (Protobuf) sering jadi pilihan yang lebih cocok dibanding REST plus JSON.

Bedanya dengan OpenAPI/JSON Schema bukan soal mana yang “lebih baik” secara umum, tapi soal konteks pemakaian:

AspekREST + OpenAPI/JSONgRPC + Protobuf
Konsumen tipikalKlien eksternal, browser, partner APIKomunikasi internal antar service
Tipe dataFleksibel, validasi di runtimeStrict, divalidasi saat compile
PerformaLebih besar payload (JSON text-based)Lebih kecil dan cepat (binary)
Evolusi schemaManual, butuh disiplin versioningBuilt-in (field number, reserved keyword)
Tooling generate kodeTersedia luas, banyak bahasaNative, terintegrasi erat dengan gRPC

Protobuf punya keunggulan khusus untuk SDD: skema .proto itu sendiri adalah kontrak yang dipakai untuk generate kode di kedua sisi (client dan server), di banyak bahasa sekaligus. Tidak ada celah antara “spec” dan “implementasi” karena keduanya diturunkan dari file yang sama.

syntax = "proto3";

message ResetPasswordRequest {
  string email = 1;
}

message ResetPasswordResponse {
  bool accepted = 1;
  string message = 2;
}

message ConfirmResetRequest {
  string token = 1;
  string new_password = 2;
}

message ConfirmResetResponse {
  bool success = 1;
  string error_code = 2; // kosong jika success = true
}

service PasswordResetService {
  rpc RequestReset(ResetPasswordRequest) returns (ResetPasswordResponse);
  rpc ConfirmReset(ConfirmResetRequest) returns (ConfirmResetResponse);
}

Untuk tim yang belum butuh kompleksitas gRPC, REST dengan OpenAPI tetap pilihan yang masuk akal sebagai default. Pertimbangkan Protobuf ketika komunikasi terjadi murni antar service internal dengan volume tinggi, atau ketika strong typing lintas bahasa menjadi prioritas.

flowchart TD
    A{Siapa konsumen kontrak?} -- Klien eksternal/browser --> B[REST + OpenAPI]
    A -- Service internal --> C{Volume traffic tinggi atau perlu strong typing lintas bahasa?}
    C -- Ya --> D[gRPC + Protobuf]
    C -- Tidak --> B

Melanjutkan contoh fitur reset password dari Part 2, berikut bagaimana acceptance criteria yang sudah ditulis di sana diterjemahkan menjadi kontrak OpenAPI yang konkret.

openapi: 3.0.3
info:
  title: Password Reset API
  version: 1.0.0

paths:
  /api/v1/password-reset/request:
    post:
      summary: Mengajukan permintaan reset password
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
      responses:
        "200":
          description: >
            Permintaan diterima. Response sama persis baik email terdaftar
            maupun tidak, untuk mencegah email enumeration.            
          content:
            application/json:
              schema:
                type: object
                required: [accepted, message]
                properties:
                  accepted:
                    type: boolean
                    enum: [true]
                  message:
                    type: string
        "429":
          description: Rate limit terlampaui (maksimal 3 request per email per jam)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /api/v1/password-reset/confirm:
    post:
      summary: Mengonfirmasi reset password dengan token
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, newPassword]
              properties:
                token:
                  type: string
                  format: uuid
                newPassword:
                  type: string
                  minLength: 8
      responses:
        "200":
          description: Password berhasil diubah
          content:
            application/json:
              schema:
                type: object
                required: [success]
                properties:
                  success:
                    type: boolean
                    enum: [true]
        "410":
          description: Token sudah kedaluwarsa atau sudah pernah dipakai
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "422":
          description: Password baru tidak memenuhi kebijakan validasi
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

components:
  schemas:
    ErrorResponse:
      type: object
      required: [errorCode, message]
      properties:
        errorCode:
          type: string
        message:
          type: string

Setiap status code di sini bukan kebetulan — masing-masing terhubung langsung ke acceptance criteria yang sudah ditulis di Part 2. HTTP 410 untuk token kedaluwarsa, HTTP 429 untuk rate limit, HTTP 422 untuk validasi password — semua eksplisit, sehingga agent yang mengimplementasikan endpoint ini, dan agent atau developer lain yang mengonsumsinya, punya pemahaman yang sama persis tentang bagaimana setiap kasus harus ditangani.

Alur interaksi lengkap antara client, API, dan email service untuk kedua endpoint ini terlihat seperti berikut:

sequenceDiagram
    participant Client
    participant API
    participant DB
    participant EmailService

    Client->>API: POST /password-reset/request {email}
    API->>DB: Simpan token (hash) + expiry 15 menit
    API->>EmailService: Kirim email berisi link reset
    API-->>Client: 200 {accepted: true}

    Client->>API: POST /password-reset/confirm {token, newPassword}
    API->>DB: Validasi token (exists, belum expired, belum dipakai)
    alt Token valid
        API->>DB: Update password, hapus token
        API-->>Client: 200 {success: true}
    else Token expired/used
        API-->>Client: 410 ErrorResponse
    else Password tidak valid
        API-->>Client: 422 ErrorResponse
    end
Definisikan skema error response secara terpusat (seperti ErrorResponse di atas) dan pakai ulang di semua endpoint. Ini mencegah setiap endpoint punya format error yang berbeda-beda — masalah umum ketika beberapa endpoint dibangun oleh agent atau developer berbeda tanpa kontrak bersama.

Versioning dan Backward Compatibility sebagai Constraint

Salah satu constraint yang paling sering hilang dari spec API adalah kebijakan versioning — bagaimana kontrak boleh berubah seiring waktu tanpa merusak konsumen yang sudah ada.

Tanpa kebijakan eksplisit, agent yang diminta “menambah field baru” atau “mengubah validasi” tidak punya panduan apakah perubahan itu aman dilakukan langsung di endpoint existing, atau harus lewat versi baru. Beberapa aturan yang sebaiknya dinyatakan eksplisit di level spec, bukan diasumsikan:

Constraint Versioning:
- Menambah field baru yang optional pada response: AMAN, tidak perlu
  versi baru
- Menghapus field dari response: BREAKING, wajib versi baru
- Mengubah tipe data field yang sudah ada: BREAKING, wajib versi baru
- Mengubah field dari optional menjadi required pada request: BREAKING,
  wajib versi baru
- Menambah endpoint baru: AMAN, tidak perlu versi baru
- Mengubah perilaku endpoint existing (meski signature tidak berubah):
  BREAKING, wajib versi baru atau feature flag

Aturan semacam ini bisa dianggap sebagai “constraint global” yang berlaku di semua spec API dalam satu project — tidak perlu ditulis ulang di setiap spec endpoint, tapi harus didokumentasikan sekali di level project (misalnya di file konvensi yang dibaca semua agent sebelum bekerja) dan dirujuk dari tiap spec individual.

Untuk API yang sudah punya konsumen eksternal, pertimbangkan juga menyatakan masa hidup minimum versi lama secara eksplisit:

Constraint Lifecycle:
- Versi API lama harus tetap didukung minimal 6 bulan setelah versi baru
  dirilis
- Endpoint yang deprecated harus mengembalikan header
  Deprecation: true dan Sunset: <tanggal>
- Breaking change tidak boleh dirilis tanpa pengumuman minimal 30 hari
  sebelumnya ke konsumen terdaftar

Anti-Pattern dalam Spec API

Beberapa pola yang sering muncul dan melemahkan kontrak yang seharusnya ketat:

Schema yang terlalu longgar. Semua field ditandai optional, atau tipe data dibiarkan any/object tanpa struktur jelas. Ini terlihat fleksibel tapi sebenarnya memindahkan beban validasi ke setiap konsumen, yang masing-masing akan menebak dengan caranya sendiri.

ANTI-PATTERN:
properties:
  data:
    type: object
    description: "Data response, struktur bervariasi"

BENAR:
properties:
  data:
    type: object
    required: [id, status]
    properties:
      id:
        type: string
        format: uuid
      status:
        type: string
        enum: [pending, completed, failed]

Tidak mendefinisikan error response. Banyak spec API hanya mendefinisikan response sukses dan mengabaikan bentuk error, padahal dari sisi konsumen, menangani error dengan benar sama pentingnya dengan menangani sukses. Tanpa skema error yang jelas, setiap endpoint cenderung mengembalikan format error yang berbeda-beda.

Tidak menyatakan kebijakan versioning. Seperti dibahas di atas — tanpa aturan eksplisit, breaking change bisa masuk tanpa sengaja, terutama ketika beberapa agent atau developer bekerja di endpoint yang sama pada waktu berbeda.

Mendokumentasikan perilaku, bukan kontrak. Spec yang menjelaskan “endpoint ini melakukan X, Y, Z secara internal” alih-alih mendefinisikan bentuk request/response yang harus dipatuhi. Detail implementasi internal sebaiknya tidak masuk ke kontrak API — kontrak hanya peduli pada apa yang terlihat dari luar (input, output, error), bukan bagaimana itu dicapai di dalam.

Kontrak API yang baik adalah kontrak yang tetap valid meskipun implementasi internalnya ditulis ulang total. Jika perubahan implementasi internal memaksa kontrak ikut berubah, kemungkinan kontrak itu bocor terlalu banyak detail internal ke permukaan publiknya.

Ringkasan

  • Kontrak API dan skema data berbeda dari spec fitur biasa karena dikonsumsi banyak pihak secara paralel — ambiguitas di sini jauh lebih mahal untuk diperbaiki setelah dipakai luas
  • OpenAPI menjadikan kontrak REST API executable: bisa divalidasi otomatis, generate stub/client, dan dokumentasi yang selalu sinkron dengan spec
  • JSON Schema mendefinisikan struktur data secara presisi — required, enum, dan additionalProperties: false mencegah ambiguitas tentang field mana yang wajib dan boleh berubah
  • Protobuf lebih cocok untuk komunikasi antar service internal yang butuh performa dan strong typing lintas bahasa, dibanding REST+JSON yang lebih cocok untuk klien eksternal
  • Setiap status code dan error response dalam spec API sebaiknya terhubung langsung ke acceptance criteria yang sudah didefinisikan di spec fitur
  • Kebijakan versioning harus dinyatakan eksplisit sebagai constraint — perubahan apa yang aman dilakukan langsung dan apa yang wajib lewat versi baru
  • Hindari schema yang terlalu longgar (semua optional/any type), error response yang tidak terdefinisi, dan kebocoran detail implementasi internal ke dalam kontrak publik

Portofolio