Request and Response Design: DTO Boundaries, Validation Semantics, and Evolution Safety
Learn Java API Contract Engineering, Event Contract Engineering & Schema Governance - Part 007
Request and response design for stable Java API contracts: DTO boundaries, validation semantics, field lifecycle, data types, and evolution safety.
Part 007 — Request and Response Design: DTO Boundaries, Validation Semantics, and Evolution Safety
Tujuan Pembelajaran
Pada part ini kita tidak belajar “cara membuat DTO” secara basic. Itu sudah terlalu rendah untuk level contract engineering. Fokus kita adalah bagaimana request dan response menjadi public promise yang dapat hidup lama, dapat diuji, dapat berevolusi, dan tidak membocorkan struktur internal sistem Java.
Setelah menyelesaikan part ini, kamu harus mampu:
- mendesain request/response yang stabil walaupun domain, database, dan service implementation berubah;
- membedakan required, optional, nullable, absent, defaulted, derived, immutable, mutable, dan deprecated field;
- menentukan kapan field boleh ditambah, diubah, disembunyikan, atau dipensiunkan;
- membuat payload yang ramah consumer tanpa mengorbankan correctness;
- menghindari leakage dari Java type, ORM entity, enum internal, database column, dan validation implementation;
- membaca contract diff dan menilai apakah perubahan DTO aman, berbahaya, atau breaking;
- membangun policy review untuk request/response di level enterprise.
Part ini adalah jembatan antara contract theory dan implementasi API Java produksi.
1. Kaufman Skill Decomposition
Josh Kaufman menekankan bahwa skill harus dipecah menjadi sub-skill kecil yang bisa dilatih dan dikoreksi. Untuk request/response design, skill besarnya adalah:
“Mampu mendesain payload API yang benar secara domain, mudah dipakai consumer, aman berevolusi, dan tidak rapuh terhadap perubahan internal.”
Skill ini kita pecah menjadi sub-skill berikut.
| Sub-skill | Output yang harus terlihat |
|---|---|
| Payload boundary design | DTO tidak sama dengan entity, command, event, atau domain aggregate |
| Field semantics | Setiap field punya arti, lifecycle, ownership, dan compatibility class |
| Optionality modelling | Required, optional, nullable, absent, default tidak tercampur |
| Type selection | Money, time, ID, enum, state, reference tidak dimodelkan asal-asalan |
| Validation placement | Bisa membedakan syntax validation, structural validation, dan business validation |
| Evolution safety | Bisa menambah field tanpa mematahkan consumer dan tahu kapan perubahan breaking |
| Contract documentation | OpenAPI/JSON Schema menjelaskan real behavior, bukan hanya bentuk JSON |
| Review discipline | Bisa menemukan risiko jangka panjang dari payload yang tampak “benar” |
Praktik terbaiknya bukan menghafal template DTO. Praktik terbaiknya adalah membangun instinct:
“Apa yang akan rusak kalau field ini berubah dua tahun lagi?”
2. Request/Response Bukan Sekadar JSON Shape
Kesalahan umum engineer menengah adalah berpikir:
DTO = class Java yang di-serialize menjadi JSON
Dalam contract engineering, definisinya lebih ketat:
Request contract = bentuk, batasan, intensi, dan konsekuensi input yang consumer kirim ke provider.
Response contract = bentuk, arti, stabilitas, dan interpretasi output yang provider janjikan ke consumer.
Payload bukan hanya struktur data. Payload adalah boundary artifact.
Contoh field sederhana:
{
"status": "ACTIVE"
}
Pertanyaan contract engineer:
- Apakah
statusadalah state internal atau public state? - Apakah consumer boleh mengambil keputusan berdasarkan value ini?
- Apakah daftar value akan bertambah?
- Apakah value lama bisa hilang?
- Apakah state ini konsisten dengan endpoint lain?
- Apakah
ACTIVEberarti dapat digunakan, dapat login, dapat menerima transaksi, atau hanya belum dihapus? - Apakah ada state transisi seperti
SUSPENDING,PENDING_REVIEW,LOCKED,ARCHIVED? - Apa yang terjadi jika consumer lama menerima value baru?
Satu field bisa membawa banyak kontrak tersembunyi.
3. Mental Model: Payload as External Truth Projection
Model domain internal mungkin kompleks:
DTO bukan salinan entity. DTO adalah projection dari kebenaran internal ke dunia luar.
Projection berarti:
- ada pemilihan field;
- ada normalisasi istilah;
- ada penyembunyian detail internal;
- ada stabilisasi semantics;
- ada transformasi tipe;
- ada explicit compatibility promise.
Contoh buruk:
@Entity
@Table(name = "customer_master")
public class CustomerEntity {
@Id
private Long id;
@Column(name = "cust_stat_cd")
private String statusCode;
@Column(name = "upd_ts")
private LocalDateTime updatedTimestamp;
@Column(name = "kyc_flag")
private String kycFlag;
}
Lalu entity ini langsung diekspos:
{
"id": 123,
"statusCode": "A",
"updatedTimestamp": "2026-06-29T09:15:00",
"kycFlag": "Y"
}
Masalah:
| Leakage | Dampak |
|---|---|
id sebagai number internal | Sulit migrasi ID format, raw enumeration, atau sharding |
statusCode dengan value "A" | Consumer belajar kode internal |
LocalDateTime tanpa offset | Ambiguity timezone |
kycFlag "Y"/"N" | Database encoding bocor ke API |
| Nama field internal | Database refactor jadi breaking change |
| Tidak ada lifecycle semantics | Consumer tidak tahu mana field stabil |
Projection yang lebih baik:
{
"customerId": "cus_01J2T7Q7BSM8PV8K4J6JYQH7TR",
"lifecycleStatus": "ACTIVE",
"kycStatus": "VERIFIED",
"lastUpdatedAt": "2026-06-29T02:15:00Z"
}
Bukan berarti ini otomatis sempurna, tapi sudah lebih dekat ke external contract.
4. DTO Boundary Rules
Aturan utama:
Jangan pernah menggunakan type internal sebagai public contract hanya karena convenient.
Boundary type harus dipisah dari:
- persistence entity;
- domain aggregate;
- database projection;
- Kafka event payload internal;
- framework request object;
- generated client model dari service lain;
- security principal internal;
- UI form state;
- batch import record;
- third-party vendor payload.
4.1 Recommended Java Package Boundary
Contoh struktur package:
com.acme.customer
├── api
│ ├── CustomerController.java
│ ├── request
│ │ ├── CreateCustomerRequest.java
│ │ └── UpdateCustomerProfileRequest.java
│ ├── response
│ │ ├── CustomerResponse.java
│ │ └── CustomerSummaryResponse.java
│ └── error
│ └── CustomerProblemTypes.java
├── application
│ ├── CreateCustomerCommand.java
│ └── CustomerApplicationService.java
├── domain
│ ├── Customer.java
│ ├── CustomerId.java
│ └── CustomerLifecycleStatus.java
├── persistence
│ ├── CustomerEntity.java
│ └── CustomerRepository.java
└── mapping
└── CustomerApiMapper.java
Catatan:
CreateCustomerRequestbukanCreateCustomerCommand.CustomerResponsebukanCustomer.CustomerEntitytidak keluar dari persistence.- Mapper berada di boundary, bukan menyebar ke seluruh codebase.
- Contract type diberi nama sesuai perspektif consumer.
4.2 DTO as Record?
Java record cocok untuk DTO immutable:
public record CustomerResponse(
String customerId,
String lifecycleStatus,
String kycStatus,
OffsetDateTime lastUpdatedAt
) {}
Namun record bukan silver bullet.
Gunakan record ketika:
- object adalah pure data carrier;
- field relatif flat;
- tidak butuh framework mutation;
- constructor validation jelas;
- serializer mendukung dengan baik;
- tidak ada inheritance/polymorphic complexity.
Hati-hati ketika:
- butuh backward-compatible deserialization dengan default;
- butuh optional field yang kompleks;
- ada
@JsonCreatorrumit; - generated OpenAPI model tidak cocok;
- field lifecycle perlu custom setter behavior;
- API berubah sangat sering.
5. Required, Optional, Nullable, Absent: Empat Konsep Berbeda
Ini salah satu bagian paling penting.
Banyak API rusak bukan karena algoritma salah, tetapi karena contract tidak jelas tentang kehadiran field.
5.1 Vocabulary
| Konsep | Arti |
|---|---|
| Required | Field harus ada dalam payload |
| Optional | Field boleh tidak ada |
| Nullable | Field boleh ada dengan value null |
| Absent | Field tidak dikirim sama sekali |
| Empty | Field ada tetapi berisi empty value, misalnya "", [], {} |
| Defaulted | Field tidak dikirim, provider mengisi default |
| Derived | Field tidak dikirim, provider menghitung dari data lain |
| Ignored | Field dikirim tapi tidak dipakai |
| Rejected | Field dikirim dan dianggap error |
5.2 Matrix Semantics
| Payload | Required? | Nullable? | Meaning |
|---|---|---|---|
| field absent | no | irrelevant | consumer tidak memberikan value |
| field absent | yes | irrelevant | invalid |
"field": null | yes/no | yes | explicit null |
"field": null | yes/no | no | invalid |
"field": "" | yes/no | no/yes | empty string, bukan null |
"field": [] | yes/no | no/yes | empty collection |
"field": {} | yes/no | no/yes | empty object |
Jangan menyamakan null, empty string, dan absent.
5.3 OpenAPI 3.1/3.2 Style
OpenAPI 3.1+ alignment dengan JSON Schema membuat null dapat dimodelkan sebagai type.
components:
schemas:
CustomerProfile:
type: object
required:
- displayName
properties:
displayName:
type: string
minLength: 1
middleName:
type:
- string
- "null"
description: >
Optional middle name. If absent, provider leaves current value unchanged
in PATCH semantics. If null, provider clears the value.
Catatan:
requiredbicara kehadiran property.type: ["string", "null"]bicara value yang diizinkan.- Field bisa required sekaligus nullable.
- Field bisa optional tetapi non-nullable.
5.4 OpenAPI 3.0 Style
Di OpenAPI 3.0, umum ditemukan:
middleName:
type: string
nullable: true
Masalahnya, banyak tool memiliki interpretasi berbeda antara nullable, required, dan Java generated type. Untuk sistem baru, prefer OpenAPI 3.1+ bila tooling sudah matang di organisasi.
6. Request Semantics: Create, Replace, Patch, Action
Tidak semua request sama. Desain DTO harus mengikuti operation semantics.
6.1 Create Request
Create request biasanya menyatakan intent untuk membuat resource baru.
{
"externalReference": "CRM-928812",
"fullName": "Ayu Lestari",
"birthDate": "1994-05-18",
"emailAddress": "ayu@example.com"
}
Pertanyaan desain:
- Apakah client boleh menentukan ID?
- Apakah request idempotent?
- Apakah duplicate externalReference ditolak atau mengembalikan resource existing?
- Field apa yang wajib pada waktu create?
- Field apa yang dihasilkan provider?
- Apakah create langsung final atau masuk state pending?
Contoh OpenAPI fragment:
CreateCustomerRequest:
type: object
required:
- externalReference
- fullName
- birthDate
additionalProperties: false
properties:
externalReference:
type: string
minLength: 1
maxLength: 100
description: Consumer-provided stable reference for idempotency and reconciliation.
fullName:
type: string
minLength: 1
maxLength: 200
birthDate:
type: string
format: date
emailAddress:
type: string
format: email
6.2 Replace Request
PUT-like replace berarti consumer mengirim representasi lengkap.
Bahaya:
- field yang tidak dikirim bisa dianggap dihapus;
- consumer lama yang tidak tahu field baru bisa menghapus data baru;
- full replacement sulit aman untuk resource yang berevolusi.
Gunakan replace hanya bila:
- resource representation kecil dan stabil;
- ownership seluruh field jelas di consumer;
- optimistic concurrency tersedia;
- API mendokumentasikan absent sebagai delete/reset;
- consumer upgrade discipline kuat.
6.3 Patch Request
PATCH-like request cocok untuk partial update, tetapi semantics harus eksplisit.
Ada beberapa model.
Merge Patch Style
{
"displayName": "Ayu L.",
"middleName": null
}
Semantics:
- absent = no change;
- null = clear;
- value = replace.
Ini mudah digunakan, tetapi berbahaya jika null semantics tidak jelas.
Operation Patch Style
{
"operations": [
{
"op": "replace",
"path": "/displayName",
"value": "Ayu L."
},
{
"op": "remove",
"path": "/middleName"
}
]
}
Lebih eksplisit, tetapi lebih kompleks.
Domain Patch Style
{
"displayNameChange": {
"newValue": "Ayu L."
},
"middleNameChange": {
"clear": true
}
}
Lebih verbose, tetapi cocok untuk domain yang high-risk.
6.4 Action Request
Kadang endpoint bukan CRUD, tetapi action domain:
POST /cases/{caseId}:approve
POST /accounts/{accountId}:freeze
POST /claims/{claimId}:escalate
Request DTO harus merepresentasikan decision context:
{
"reasonCode": "RISK_THRESHOLD_EXCEEDED",
"comment": "Escalated after second failed verification.",
"evidenceIds": [
"evd_01J2T9XGQYV1F3E5F2D7W8AR9Z"
]
}
Action request bukan tempat mengirim seluruh state resource. Ia harus fokus pada:
- decision;
- reason;
- evidence;
- actor intent;
- idempotency;
- concurrency guard.
7. Response Semantics: Representation, Acknowledgement, Projection, Result
Response tidak selalu resource representation.
7.1 Resource Representation
{
"customerId": "cus_01J2T7Q7BSM8PV8K4J6JYQH7TR",
"lifecycleStatus": "ACTIVE",
"kycStatus": "VERIFIED",
"createdAt": "2026-06-29T02:15:00Z",
"lastUpdatedAt": "2026-06-29T02:15:00Z"
}
Gunakan ketika consumer membutuhkan current state.
7.2 Command Acknowledgement
{
"commandId": "cmd_01J2TBH5KZTR2TH63AWY5N5RG9",
"acceptedAt": "2026-06-29T02:16:00Z",
"statusUrl": "/commands/cmd_01J2TBH5KZTR2TH63AWY5N5RG9"
}
Gunakan ketika operation asynchronous.
7.3 Search Result
{
"items": [
{
"customerId": "cus_01J2T7Q7BSM8PV8K4J6JYQH7TR",
"displayName": "Ayu Lestari",
"lifecycleStatus": "ACTIVE"
}
],
"page": {
"nextCursor": "eyJvZmZzZXQiOjEwMH0",
"hasMore": true
}
}
Jangan mengembalikan raw array untuk list endpoint jika pagination, metadata, trace, atau expansion mungkin dibutuhkan.
Bad:
[
{ "customerId": "cus_1" },
{ "customerId": "cus_2" }
]
Lebih baik:
{
"items": [
{ "customerId": "cus_1" },
{ "customerId": "cus_2" }
],
"page": {
"nextCursor": null,
"hasMore": false
}
}
Alasannya evolvability. Object wrapper memungkinkan field baru tanpa breaking major shape.
8. DTO Naming: Consumer Language, Not Internal Language
Nama field adalah contract. Nama yang buruk bisa membekukan model yang salah selama bertahun-tahun.
8.1 Naming Principles
| Principle | Example buruk | Example lebih baik |
|---|---|---|
| Use domain language | statCd | lifecycleStatus |
| Avoid storage terms | custIdPk | customerId |
| Avoid implementation terms | retryFlag | retryable |
| Avoid ambiguous flags | active | lifecycleStatus |
| Avoid overloaded field | type | caseCategory, documentKind, accountClass |
| Use time semantics | date | createdAt, effectiveFrom, expiresAt |
8.2 Boolean Trap
Boolean sering terlihat simpel tetapi miskin semantics.
Bad:
{
"active": true
}
Apa arti active?
- boleh login?
- belum dihapus?
- subscription aktif?
- sudah KYC?
- tidak sedang suspend?
- masih valid untuk transaksi?
Lebih baik:
{
"lifecycleStatus": "ACTIVE",
"accessStatus": "ALLOWED",
"kycStatus": "VERIFIED"
}
Gunakan boolean hanya jika domain benar-benar binary dan stabil.
Contoh boolean yang masuk akal:
{
"hasMore": true,
"retryable": false
}
9. Identifier Contract
ID bukan detail kecil. ID adalah external reference surface.
9.1 Internal Numeric ID Leakage
Bad:
{
"customerId": 12345
}
Masalah:
- mudah ditebak;
- mengungkap volume atau urutan;
- sulit migrasi ke distributed ID;
- raw database key bocor;
- consumer mungkin menyimpan asumsi numeric;
- bisa memperbesar risiko enumeration.
Lebih baik:
{
"customerId": "cus_01J2T7Q7BSM8PV8K4J6JYQH7TR"
}
9.2 ID Prefix
Prefix membantu debugging dan validasi manusia:
| Prefix | Meaning |
|---|---|
cus_ | customer |
acc_ | account |
case_ | case |
evd_ | evidence |
cmd_ | command |
evt_ | event |
Namun prefix adalah contract. Jangan mengganti seenaknya.
9.3 External Reference vs Provider ID
Pisahkan:
{
"customerId": "cus_01J2T7Q7BSM8PV8K4J6JYQH7TR",
"externalReference": "CRM-928812"
}
| Field | Owner |
|---|---|
customerId | provider |
externalReference | consumer atau upstream system |
Jangan membuat consumer bingung mana ID otoritatif.
10. Monetary Field Contract
Money adalah sumber bug besar di enterprise systems.
Bad:
{
"amount": 1000
}
Pertanyaan:
- Currency apa?
- Minor unit atau major unit?
- Precision berapa?
- Apakah termasuk tax?
- Apakah amount bisa negative?
- Apakah rounding sudah diterapkan?
- Apakah ini authorized amount, settled amount, outstanding amount, atau fee amount?
Lebih baik:
{
"outstandingAmount": {
"currency": "IDR",
"value": "1000.00"
}
}
Gunakan string decimal untuk menghindari floating point ambiguity di consumer lintas bahasa.
Java internal:
public record MoneyDto(
String currency,
String value
) {}
Mapping internal:
public record Money(
Currency currency,
BigDecimal amount
) {}
OpenAPI:
Money:
type: object
required:
- currency
- value
additionalProperties: false
properties:
currency:
type: string
minLength: 3
maxLength: 3
example: IDR
value:
type: string
pattern: '^-?[0-9]+(\.[0-9]+)?$'
example: "1000.00"
Policy:
- jangan expose
doubleuntuk money; - dokumentasikan rounding;
- dokumentasikan sign;
- dokumentasikan currency;
- dokumentasikan apakah tax included;
- dokumentasikan scale expectation bila domain membutuhkannya.
11. Temporal Field Contract
Waktu tidak boleh dimodelkan asal-asalan.
11.1 Common Temporal Types
| Contract type | JSON form | Java type candidate | Use case |
|---|---|---|---|
| Instant timestamp | 2026-06-29T02:15:00Z | Instant / OffsetDateTime | audit, event time |
| Local date | 2026-06-29 | LocalDate | birth date, due date by jurisdiction |
| Local time | 09:00:00 | LocalTime | schedule template |
| Date range | object | custom value object | validity period |
| Duration | PT15M | Duration | timeout, SLA |
| Period | P1M | Period | billing cycle |
11.2 Avoid LocalDateTime in Public API
Bad:
{
"createdAt": "2026-06-29T09:15:00"
}
Tidak jelas timezone-nya.
Lebih baik:
{
"createdAt": "2026-06-29T02:15:00Z"
}
atau jika memang jurisdiction-local:
{
"businessDate": "2026-06-29",
"jurisdiction": "ID"
}
11.3 Time Name Matters
| Field | Meaning |
|---|---|
createdAt | provider created resource |
updatedAt | provider last updated resource |
occurredAt | business event happened |
publishedAt | message was published |
receivedAt | provider received request/message |
effectiveFrom | rule/state starts to apply |
expiresAt | no longer valid after this timestamp |
businessDate | domain date, not necessarily technical timestamp |
Jangan menggunakan field generik seperti date, timestamp, atau time tanpa qualifier.
12. Enum Contract
Enum adalah compatibility trap.
Bad:
{
"status": "ACTIVE"
}
Bukan karena string enum salah, tetapi karena evolution-nya sering tidak dipikirkan.
12.1 Enum Evolution Questions
- Apakah value baru boleh ditambah?
- Apakah consumer lama harus ignore unknown value?
- Apakah provider boleh menghapus value lama?
- Apakah enum mewakili state machine?
- Apakah enum mewakili taxonomy yang berubah?
- Apakah ada default fallback?
- Apakah enum value digunakan dalam business rule consumer?
12.2 Closed Enum vs Open Enum
Closed enum:
kycStatus:
type: string
enum:
- NOT_STARTED
- PENDING
- VERIFIED
- REJECTED
Cocok bila:
- value jarang berubah;
- consumer harus menangani semua value;
- unknown value harus dianggap error;
- provider dan consumer release cadence terkendali.
Open enum pattern:
riskBand:
type: string
description: >
Known values include LOW, MEDIUM, HIGH. Consumers must tolerate unknown
values and treat them as requiring manual/default handling.
Cocok bila:
- taxonomy dapat bertambah;
- consumer harus robust;
- value tidak selalu hard-coded.
12.3 Java Enum Leakage
Bad:
public enum InternalCustomerStatus {
A,
B,
C,
X1,
TEMP_LOCK_2
}
Diekspos langsung ke API:
{
"status": "TEMP_LOCK_2"
}
Lebih baik mapping explicit:
public enum CustomerLifecycleStatusDto {
ACTIVE,
SUSPENDED,
CLOSED,
PENDING_REVIEW
}
Mapper:
public CustomerLifecycleStatusDto toDto(InternalCustomerStatus status) {
return switch (status) {
case A -> CustomerLifecycleStatusDto.ACTIVE;
case B -> CustomerLifecycleStatusDto.PENDING_REVIEW;
case C -> CustomerLifecycleStatusDto.CLOSED;
case X1, TEMP_LOCK_2 -> CustomerLifecycleStatusDto.SUSPENDED;
};
}
Rule:
Internal enum boleh berubah cepat. Public enum harus berubah lambat.
13. Polymorphic Payload Contract
Polymorphism sering dibutuhkan, tetapi bisa membuat contract sulit dipakai.
Contoh:
{
"verificationMethod": {
"type": "DOCUMENT",
"documentType": "PASSPORT",
"documentNumber": "A1234567"
}
}
Alternatif:
{
"verificationMethodType": "DOCUMENT",
"documentVerification": {
"documentType": "PASSPORT",
"documentNumber": "A1234567"
},
"biometricVerification": null
}
Atau oneOf schema:
VerificationMethod:
oneOf:
- $ref: '#/components/schemas/DocumentVerification'
- $ref: '#/components/schemas/BiometricVerification'
discriminator:
propertyName: type
Trade-off:
| Pattern | Pro | Kontra |
|---|---|---|
| Discriminator + oneOf | expressive, schema-validatable | tool support bervariasi |
| Separate nullable branches | mudah di beberapa client | bisa verbose dan invalid combination |
| Type + generic object | fleksibel | validation lemah |
| Separate endpoint | jelas | lebih banyak operation |
Gunakan polymorphism hanya saat benar-benar ada variasi shape yang stabil. Jangan pakai hanya karena domain model memakai inheritance.
14. Validation Semantics: Jangan Campur Semua di Satu Layer
Validation memiliki beberapa level.
14.1 Syntax Validation
Contoh:
- invalid JSON;
- invalid content type;
- malformed date;
- malformed UUID.
Biasanya menghasilkan 400 Bad Request.
14.2 Structural Validation
Contoh:
- required field missing;
- value type salah;
- string too long;
- array exceeds max items;
- unknown property rejected.
Bisa 400, sering juga dipisah sebagai validation problem.
14.3 Semantic Field Validation
Contoh:
birthDatedi masa depan;effectiveFromsetelaheffectiveTo;- currency tidak didukung;
- enum valid secara schema tetapi tidak valid untuk tenant.
14.4 Business Rule Validation
Contoh:
- customer belum KYC sehingga tidak bisa membuka account;
- case tidak bisa ditutup karena mandatory evidence belum ada;
- transaction melebihi limit.
Ini bukan sekadar DTO validation. Jangan sembunyikan sebagai @NotNull atau @Pattern.
14.5 State Transition Validation
Contoh:
DRAFT -> SUBMITTED -> UNDER_REVIEW -> APPROVED
Request approve invalid jika current state masih DRAFT.
Ini harus menjadi domain error atau conflict, bukan field validation biasa.
15. Bean Validation Annotation as Implementation, Not Contract Strategy
Annotation seperti @NotNull, @Size, @Pattern, @Email berguna, tetapi bukan strategi contract lengkap.
Contoh:
public record CreateCustomerRequest(
@NotBlank
@Size(max = 200)
String fullName,
@NotNull
LocalDate birthDate,
@Email
String emailAddress
) {}
Ini baik sebagai enforcement di Java boundary. Namun contract engineer tetap harus memastikan:
- OpenAPI schema sesuai annotation;
- error response stabil;
- validation group tidak membocorkan operation internal;
- custom validator tidak menyembunyikan business rule;
- optional/null semantics terdokumentasi;
- consumer tahu apakah field absent, null, atau empty valid;
- test membuktikan runtime sesuai spec.
Annotation adalah guardrail. Contract tetap harus explicit.
16. Unknown Properties: Strict or Tolerant?
Keputusan additionalProperties sangat penting.
16.1 Strict Request
CreateCustomerRequest:
type: object
additionalProperties: false
Pro:
- typo cepat ketahuan;
- contract bersih;
- mengurangi accidental field;
- security lebih baik untuk mass-assignment risk.
Kontra:
- consumer yang mengirim field eksperimen akan gagal;
- forward compatibility lebih rendah;
- gateway/proxy yang menambahkan field bisa mematahkan request.
16.2 Tolerant Request
CreateCustomerRequest:
type: object
additionalProperties: true
Pro:
- lebih tolerant;
- cocok untuk extension field;
- bisa mendukung vendor-specific metadata.
Kontra:
- typo diam-diam diabaikan;
- consumer punya false confidence;
- provider sulit mendeteksi misuse;
- governance lemah.
16.3 Recommended Enterprise Policy
Untuk high-value command request:
additionalProperties: false
Untuk metadata extension yang disengaja:
metadata:
type: object
additionalProperties:
type: string
Aturan:
- default strict untuk command;
- explicit extension point jika dibutuhkan;
- log rejected unknown fields;
- test typo fields;
- jangan silently ignore field penting.
17. Field Lifecycle
Setiap field harus punya lifecycle.
17.1 Proposed
Field belum masuk public contract. Masih dalam desain.
17.2 Experimental
Field tersedia untuk limited consumer.
Risiko:
- begitu external consumer bergantung, field sulit dihapus;
- experimental harus diberi scope dan expiry.
17.3 Stable
Field menjadi public promise.
Stable berarti:
- name tidak berubah;
- type tidak berubah breaking;
- meaning tidak berubah diam-diam;
- constraints tidak diperketat tanpa migration;
- enum value lama tetap dipahami;
- response field tetap tersedia sesuai dokumentasi.
17.4 Deprecated
Deprecated bukan removed.
Harus ada:
- alasan deprecation;
- replacement field;
- migration window;
- target removal date bila memungkinkan;
- consumer inventory;
- telemetry usage;
- changelog.
OpenAPI example:
oldStatus:
type: string
deprecated: true
description: >
Deprecated. Use lifecycleStatus instead. This field will remain available
until all registered consumers migrate.
17.5 Retired
Field sudah tidak dikirim atau tidak diterima. Ini breaking jika masih ada consumer. Harus melalui governance.
18. Safe Evolution Rules
18.1 Usually Safe Changes
| Change | Condition |
|---|---|
| Add optional response field | Consumer ignores unknown field |
| Add optional request field | Provider has default behavior |
| Widen string max length | Downstream can handle |
| Add enum value | Consumer tolerates unknown |
| Add new error code | Consumer has fallback |
| Add new link/metadata | Wrapper object supports it |
18.2 Dangerous Changes
| Change | Why dangerous |
|---|---|
| Add required request field | Old consumer cannot send it |
| Remove response field | Old consumer may depend on it |
| Change field type | Deserialization break |
| Tighten validation | Previously valid requests fail |
| Change enum meaning | Silent semantic break |
| Rename field | Equivalent to remove + add |
| Change date format | Parser break |
| Change ID format | Stored references may break |
| Change nullability | Consumer assumptions break |
| Change default | Behavior changes silently |
18.3 Semantic Breaking Change
Perubahan bisa breaking walaupun schema diff terlihat aman.
Contoh:
{
"riskScore": 720
}
Sebelumnya range 0-1000. Kemudian provider mengubah menjadi 0-100 tanpa rename.
Schema mungkin tetap integer, tetapi semantics berubah total.
Rule:
Contract diff harus membaca meaning, bukan hanya type.
19. Request Field Ownership
Dalam request, setiap field harus jelas siapa owner-nya.
| Field | Owner | Provider behavior |
|---|---|---|
externalReference | consumer | persisted for reconciliation |
customerId | provider | usually not accepted on create |
requestedLimit | consumer | subject to provider approval |
approvedLimit | provider | not accepted from consumer |
reasonCode | consumer or provider taxonomy | validated |
metadata | consumer | stored or ignored by explicit policy |
Bad request:
{
"customerId": "cus_123",
"approvedLimit": {
"currency": "IDR",
"value": "10000000.00"
},
"riskBand": "LOW"
}
Jika field seharusnya provider-derived, jangan terima dari consumer. Bila diterima untuk admin override, operation harus berbeda dan authorization jelas.
20. Response Field Stability
Response field bisa dibagi berdasarkan stability.
| Stability | Meaning |
|---|---|
| Stable | Consumer boleh hard-depend |
| Informational | Consumer boleh tampilkan, jangan gunakan untuk decision |
| Experimental | Limited use, bisa berubah |
| Deprecated | Jangan mulai dependency baru |
| Debug-only | Tidak untuk business logic |
| Derived | Bisa berubah jika source berubah |
| Eventually consistent | Tidak selalu langsung update |
Contoh:
riskBand:
type: string
description: >
Informational risk classification for display and triage. Consumers must
not use this field as the sole authorization decision input.
Jika consumer boleh mengambil keputusan berdasarkan field, field tersebut harus memiliki compatibility guarantee yang lebih kuat.
21. DTO Mapping Pattern
21.1 API Request to Application Command
@RestController
@RequestMapping("/customers")
public class CustomerController {
private final CustomerApplicationService service;
private final CustomerApiMapper mapper;
@PostMapping
public ResponseEntity<CustomerResponse> createCustomer(
@Valid @RequestBody CreateCustomerRequest request,
@RequestHeader(name = "Idempotency-Key", required = false) String idempotencyKey
) {
CreateCustomerCommand command = mapper.toCommand(request, idempotencyKey);
CustomerResult result = service.createCustomer(command);
return ResponseEntity
.created(URI.create("/customers/" + result.customerId().value()))
.body(mapper.toResponse(result));
}
}
Request DTO:
public record CreateCustomerRequest(
@NotBlank
@Size(max = 100)
String externalReference,
@NotBlank
@Size(max = 200)
String fullName,
@NotNull
LocalDate birthDate,
@Email
String emailAddress
) {}
Application command:
public record CreateCustomerCommand(
ExternalReference externalReference,
FullName fullName,
BirthDate birthDate,
Optional<EmailAddress> emailAddress,
Optional<IdempotencyKey> idempotencyKey
) {}
Mapper:
@Component
public class CustomerApiMapper {
public CreateCustomerCommand toCommand(
CreateCustomerRequest request,
String idempotencyKey
) {
return new CreateCustomerCommand(
ExternalReference.of(request.externalReference()),
FullName.of(request.fullName()),
BirthDate.of(request.birthDate()),
Optional.ofNullable(request.emailAddress()).map(EmailAddress::of),
Optional.ofNullable(idempotencyKey).map(IdempotencyKey::of)
);
}
public CustomerResponse toResponse(CustomerResult result) {
return new CustomerResponse(
result.customerId().value(),
result.lifecycleStatus().name(),
result.kycStatus().name(),
result.createdAt(),
result.lastUpdatedAt()
);
}
}
Key point:
- boundary validation ada di DTO;
- domain validation ada di value object/application service;
- mapping explicit;
- idempotency header masuk command, bukan request body.
22. Avoiding Mass Assignment
Mass assignment terjadi ketika request payload langsung di-bind ke entity atau object internal yang memiliki field lebih banyak dari yang consumer boleh ubah.
Bad:
@PatchMapping("/{id}")
public CustomerEntity update(
@PathVariable Long id,
@RequestBody CustomerEntity request
) {
request.setId(id);
return repository.save(request);
}
Risiko:
- consumer mengubah field internal;
- authorization bypass;
- status dapat diubah tanpa state machine;
- audit field dimanipulasi;
- sensitive flag bocor;
- schema sulit dikendalikan.
Lebih baik:
public record UpdateCustomerProfileRequest(
@Size(max = 200)
String displayName,
@Email
String emailAddress
) {}
Lalu application service memutuskan perubahan yang valid.
23. Partial Response and Field Expansion
Untuk API besar, consumer sering ingin subset atau expansion.
Contoh:
GET /customers/{customerId}?include=accounts,addresses
Response:
{
"customerId": "cus_01J2T7Q7BSM8PV8K4J6JYQH7TR",
"displayName": "Ayu Lestari",
"accounts": [
{
"accountId": "acc_01J2T7ZVJ3VN6P4Q3KACB3TR1Y",
"status": "OPEN"
}
]
}
Governance concerns:
includevalues adalah contract;- nested response punya lifecycle sendiri;
- performance implications harus jelas;
- authorization untuk expanded resource harus dicek;
- default response tidak boleh berubah terlalu besar;
- partial response jangan membuat required fields ambigu.
Alternatif:
GET /customers/{customerId}/accounts
Trade-off antara ergonomics dan explicit boundary.
24. Pagination Contract
Pagination bukan afterthought.
24.1 Offset Pagination
{
"items": [],
"page": {
"offset": 0,
"limit": 50,
"totalItems": 1280
}
}
Cocok untuk small stable data. Kurang cocok untuk high-write datasets.
24.2 Cursor Pagination
{
"items": [],
"page": {
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA2LTI5VDAyOjE1OjAwWiJ9",
"hasMore": true
}
}
Cocok untuk scalable API.
Contract rules:
- cursor opaque;
- consumer tidak boleh parse cursor;
- cursor expiry didokumentasikan;
- sort order stabil;
- filter perubahan invalidates cursor;
- duplicate/missing items possibility dijelaskan bila dataset mutable.
OpenAPI:
PageInfo:
type: object
required:
- hasMore
properties:
nextCursor:
type:
- string
- "null"
description: Opaque cursor. Consumers must not parse or persist indefinitely.
hasMore:
type: boolean
25. Sorting and Filtering Contract
Filter dan sort adalah contract grammar.
Bad:
GET /customers?filter=active
Apa arti active?
Lebih baik:
GET /customers?lifecycleStatus=ACTIVE&createdFrom=2026-01-01&sort=createdAt:desc
Atau structured filter untuk domain kompleks:
{
"criteria": {
"lifecycleStatus": ["ACTIVE", "SUSPENDED"],
"createdAt": {
"from": "2026-01-01T00:00:00Z",
"to": "2026-06-29T00:00:00Z"
}
},
"sort": [
{
"field": "createdAt",
"direction": "DESC"
}
],
"page": {
"limit": 50
}
}
Policy:
- allowed filter fields harus terdokumentasi;
- allowed sort fields harus terdokumentasi;
- default sort harus stabil;
- case sensitivity jelas;
- timezone jelas;
- unsupported filter harus rejected, bukan silently ignored;
- expensive filters butuh guardrail.
26. Contract Examples Are Tests in Disguise
Example di OpenAPI sering dianggap kosmetik. Pada contract engineering, example adalah executable learning artifact.
Bad example:
example:
customerId: string
status: string
Good example:
example:
customerId: cus_01J2T7Q7BSM8PV8K4J6JYQH7TR
lifecycleStatus: ACTIVE
kycStatus: VERIFIED
createdAt: "2026-06-29T02:15:00Z"
lastUpdatedAt: "2026-06-29T02:15:00Z"
Better: multiple examples.
examples:
activeVerified:
summary: Active customer with verified KYC
value:
customerId: cus_01J2T7Q7BSM8PV8K4J6JYQH7TR
lifecycleStatus: ACTIVE
kycStatus: VERIFIED
createdAt: "2026-06-29T02:15:00Z"
lastUpdatedAt: "2026-06-29T02:15:00Z"
suspendedPendingReview:
summary: Suspended customer pending review
value:
customerId: cus_01J2V6RQ33YJ2V6P7S4P3Y8KMC
lifecycleStatus: SUSPENDED
kycStatus: PENDING_REVIEW
createdAt: "2026-06-01T05:00:00Z"
lastUpdatedAt: "2026-06-29T01:00:00Z"
Examples harus mencakup:
- happy path;
- edge case;
- nullable field;
- absent optional field;
- enum variasi penting;
- error case;
- pagination boundary.
27. Request/Response Contract Review Checklist
Gunakan checklist ini saat review PR contract.
27.1 Boundary
- Apakah DTO terpisah dari entity/domain/generated type?
- Apakah nama field memakai bahasa consumer?
- Apakah ada field internal yang bocor?
- Apakah ID internal bocor?
- Apakah field provider-derived diterima dari consumer?
27.2 Optionality
- Apakah required field benar-benar wajib untuk semua consumer?
- Apakah nullable berbeda dari absent?
- Apakah default behavior jelas?
- Apakah empty string/list/object semantics jelas?
- Apakah PATCH semantics jelas?
27.3 Type
- Apakah money memakai decimal string dan currency?
- Apakah timestamp punya timezone/offset?
- Apakah date-only memang date-only?
- Apakah enum bisa bertambah?
- Apakah boolean tidak menyembunyikan state machine?
27.4 Compatibility
- Apakah perubahan menambah required request field?
- Apakah response field dihapus/di-rename?
- Apakah validation diperketat?
- Apakah enum value berubah meaning?
- Apakah format ID/date/money berubah?
- Apakah default berubah?
27.5 Consumer Experience
- Apakah response list memakai wrapper?
- Apakah pagination jelas?
- Apakah error response stabil?
- Apakah examples realistis?
- Apakah generated client akan usable?
- Apakah field description cukup untuk implementasi tanpa bertanya ke provider?
28. Practice Lab
Lab 1 — Refactor Leaky DTO
Input buruk:
{
"id": 912,
"cust_nm": "Ayu Lestari",
"stat_cd": "A",
"kyc_flg": "Y",
"upd_ts": "2026-06-29T09:15:00"
}
Tugas:
- desain response DTO yang consumer-friendly;
- definisikan ID contract;
- ubah temporal field;
- ubah status field;
- buat OpenAPI schema;
- jelaskan compatibility rule.
Target output:
{
"customerId": "cus_01J2T7Q7BSM8PV8K4J6JYQH7TR",
"displayName": "Ayu Lestari",
"lifecycleStatus": "ACTIVE",
"kycStatus": "VERIFIED",
"lastUpdatedAt": "2026-06-29T02:15:00Z"
}
Lab 2 — Design PATCH Semantics
Desain request untuk update profile:
Requirements:
displayNamebisa diubah;middleNamebisa diubah atau dihapus;emailAddressbisa diubah tetapi harus verified ulang;- absent berarti no change;
- null untuk
middleNameberarti clear; - null untuk
emailAddresstidak boleh.
Tugas:
- buat JSON examples;
- buat schema;
- buat Java record;
- buat validation rule;
- buat error cases.
Lab 3 — Identify Breaking Changes
Klasifikasikan perubahan:
birthDatedari required menjadi optional;emailAddressdari optional menjadi required;customerIddari string menjadi number;- tambah optional response field
riskBand; - tambah enum value
PENDING_REVIEW; - hapus field
oldStatus; - ubah
amount.valuedari string menjadi number; - perketat
displayName.maxLengthdari 200 ke 100; - ubah default sort dari
createdAt desckedisplayName asc; - ubah
nullsemantics pada PATCH dari “clear” menjadi “ignore”.
Jawaban yang matang harus membedakan structural compatibility dan semantic compatibility.
29. Senior Engineer Heuristics
- DTO harus berubah lebih lambat dari domain model.
- Public field adalah dependency injection ke masa depan.
- Nama field buruk lebih mahal daripada mapper tambahan.
- Required field adalah hutang compatibility.
- Nullable field tanpa semantics adalah bug yang ditunda.
- Enum value baru bisa breaking jika consumer tidak punya fallback.
- Boolean adalah red flag untuk hidden state machine.
- Raw array response menghambat evolusi.
- Money tanpa currency bukan money contract.
- Timestamp tanpa timezone adalah ambiguity contract.
- Validation tightening adalah breaking change untuk consumer lama.
- DTO reuse antar operation sering membuat contract terlalu besar.
- Generated DTO tidak otomatis berarti contract yang baik.
- Field yang “hanya untuk UI” tetap bisa menjadi business dependency.
- Consumer akan bergantung pada apa pun yang kamu expose.
30. Mini Architecture Pattern: Contract DTO Layer
Boundary rule:
- Request DTO receives external data.
- Mapper translates into application semantics.
- Domain model enforces invariant.
- Response DTO projects stable public truth.
- OpenAPI describes the same boundary.
31. Summary
Request/response design adalah inti API contract engineering. Ia bukan pekerjaan kosmetik dan bukan sekadar serialisasi Java object. Ia adalah desain public boundary yang menentukan bagaimana consumer memahami, menyimpan, mengandalkan, dan bereaksi terhadap sistem kita.
Pelajaran utama:
- DTO adalah external projection, bukan entity.
- Required, optional, nullable, absent, empty, dan default harus dibedakan.
- Create, replace, patch, dan action membutuhkan request semantics berbeda.
- Response harus didesain untuk evolusi, terutama list response dan pagination.
- Money, time, ID, enum, dan boolean adalah field berisiko tinggi.
- Validation harus dibagi antara syntax, structural, semantic, business, authorization, dan state transition.
- Field lifecycle harus eksplisit sejak desain awal.
- Compatibility bukan hanya schema compatibility, tetapi juga semantic compatibility.
- Contract review harus mencari risiko masa depan, bukan hanya apakah code compile.
Part berikutnya membahas error contract engineering: bagaimana failure diekspose sebagai contract yang stabil, machine-readable, defensible, dan aman untuk distributed consumers.
You just completed lesson 07 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.