Tampilan
Kenapa penting
Hampir semua backend modern berbicara dalam JSON: REST API menerima dan mengembalikan JSON, webhook payment gateway dikirim sebagai JSON, pesan di message broker sering berupa JSON, bahkan konfigurasi dan log terstruktur pun memakainya. Pekerjaan sehari-hari kamu adalah mengubah JSON menjadi object Java (deserialization) lalu mengubah object kembali menjadi JSON (serialization). Di ekosistem Java, library standar de facto untuk itu adalah Jackson — dan Spring Boot memakainya di balik layar setiap kali controller menerima @RequestBody atau mengembalikan DTO. Memahami Jackson secara langsung membuat kamu bisa men-debug error "JSON parse error" dengan cepat dan menghindari celah keamanan yang sudah berkali-kali menimpa aplikasi production.
Konsep inti
Format JSON dalam lima menit
JSON (JavaScript Object Notation, RFC 8259) hanya punya enam jenis nilai:
| JSON | Contoh | Tipe Java yang umum |
|---|---|---|
| object | {"sku":"KOPI-250"} | record/class, atau Map<String, ...> |
| array | ["kopi","promo"] | List<...>, array |
| string | "Kopi Gayo" | String, enum, LocalDate, Instant |
| number | 85000, 12.5 | int, long, BigDecimal |
| boolean | true, false | boolean |
| null | null | null (tidak bisa ke primitif) |
Kunci object selalu string berkutip ganda, tidak ada komentar, dan tidak ada koma di elemen terakhir. JSON tidak punya tipe tanggal — tanggal dikirim sebagai string, idealnya berformat ISO-8601 seperti "2026-10-23T03:15:00Z".
Jackson 2 vs Jackson 3
Ada dua generasi Jackson yang akan kamu temui:
| Aspek | Jackson 2 (kode lama) | Jackson 3 (default Spring Boot 4) |
|---|---|---|
| groupId & paket | com.fasterxml.jackson.* | tools.jackson.* |
| Anotasi | com.fasterxml.jackson.annotation | sama — tetap artefak anotasi 2.x |
| Exception | JsonProcessingException (checked) | JacksonException (unchecked) |
java.time | perlu modul jackson-datatype-jsr310 | sudah bawaan, ditulis ISO-8601 secara default |
| Properti tak dikenal | gagal secara default | diabaikan secara default |
| Konfigurasi | ObjectMapper bisa diubah setelah dibuat | immutable, dibangun lewat JsonMapper.builder() |
Bab ini memakai Jackson 3 karena itulah yang dipakai Spring Boot 4. Bila membaca kode lama, konsepnya sama; biasanya cukup mengganti import com.fasterxml.jackson.databind → tools.jackson.databind. Dependency Maven (dicek di Maven Central, Oktober 2026):
xml
<dependency>
<groupId>tools.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>3.2.4</version>
</dependency>jackson-databind otomatis menarik jackson-core (parser) dan jackson-annotations. Di proyek Spring Boot kamu tidak menulis versi — Boot yang mengaturnya. Padanan Jackson 2-nya adalah com.fasterxml.jackson.core:jackson-databind:2.22.4 plus com.fasterxml.jackson.datatype:jackson-datatype-jsr310 untuk java.time.
Menulis dan membaca: JsonMapper
JsonMapper adalah turunan ObjectMapper khusus JSON. Buat sekali, lalu pakai ulang — ia mahal dibuat tetapi thread-safe setelah jadi. Record adalah pasangan alami Jackson: nama komponen menjadi nama property JSON.
java
import java.util.List;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.json.JsonMapper;
public class JsonBasics {
record Product(String sku, String name, long price, boolean active, List<String> tags) {}
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build(); // buat sekali, pakai ulang
Product kopi = new Product("KOPI-250", "Kopi Gayo 250 g", 85_000, true, List.of("kopi", "promo"));
String json = mapper.writeValueAsString(kopi); // object → JSON
System.out.println(json);
Product back = mapper.readValue(json, Product.class); // JSON → object
System.out.println(back);
System.out.println(back.equals(kopi));
String arrayJson = """
[{"sku":"TEH-100","name":"Teh Melati","price":40000,"active":true,"tags":[]},
{"sku":"GULA-500","name":"Gula Aren","price":30000,"active":false,"tags":["lokal"]}]
""";
List<Product> products = mapper.readValue(arrayJson, new TypeReference<List<Product>>() {});
System.out.println(products.size() + " " + products.get(1).name());
System.out.println(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(products.get(1)));
}
}Output:
text
{"sku":"KOPI-250","name":"Kopi Gayo 250 g","price":85000,"active":true,"tags":["kopi","promo"]}
Product[sku=KOPI-250, name=Kopi Gayo 250 g, price=85000, active=true, tags=[kopi, promo]]
true
2 Gula Aren
{
"sku" : "GULA-500",
"name" : "Gula Aren",
"price" : 30000,
"active" : false,
"tags" : [ "lokal" ]
}Kenapa new TypeReference<List<Product>>() {}? Karena type erasure (bab generics): List.class tidak membawa informasi Product. Anonymous subclass TypeReference "mengunci" tipe generic lengkap supaya Jackson tahu tiap elemen harus dijadikan Product, bukan Map.
Anotasi untuk mengatur bentuk JSON
API sering memakai snake_case, sementara Java memakai camelCase; ada juga field yang tidak boleh bocor. Tiga anotasi paling sering dipakai:
java
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import tools.jackson.databind.json.JsonMapper;
public class JsonAnnotations {
@JsonInclude(JsonInclude.Include.NON_NULL) // field bernilai null tidak ditulis
record CustomerResponse(
@JsonProperty("customer_id") String id, // nama di JSON berbeda dari nama di Java
String email,
String phone,
@JsonIgnore String passwordHash) {} // tidak pernah ikut ke JSON
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
var budi = new CustomerResponse("C-001", "budi@contoh.id", null, "$2a$10$abc...");
System.out.println(mapper.writeValueAsString(budi));
var sari = new CustomerResponse("C-002", "sari@contoh.id", "0812-0000-1111", "$2a$10$xyz...");
System.out.println(mapper.writeValueAsString(sari));
String incoming = """
{"customer_id":"C-003","email":"andi@contoh.id","passwordHash":"coba-sisip"}
""";
System.out.println(mapper.readValue(incoming, CustomerResponse.class));
}
}Output:
text
{"customer_id":"C-001","email":"budi@contoh.id"}
{"customer_id":"C-002","email":"sari@contoh.id","phone":"0812-0000-1111"}
CustomerResponse[id=C-003, email=andi@contoh.id, phone=null, passwordHash=null]@JsonIgnore bekerja dua arah: passwordHash tidak ditulis, dan nilai kiriman client pun tidak dibaca. Meski begitu, pola yang lebih aman adalah DTO terpisah yang memang tidak punya field sensitif — @JsonIgnore mudah terlupa saat class berubah.
Tanggal java.time sebagai ISO-8601
Jackson 3 langsung mengenal Instant, LocalDate, OffsetDateTime, dan kawan-kawan dari bab sebelumnya, dan menulisnya sebagai string ISO-8601:
java
import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import tools.jackson.databind.json.JsonMapper;
public class JsonTime {
record Shipment(String orderId, Instant createdAt, LocalDate estimatedArrival, OffsetDateTime pickupAt) {}
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
var shipment = new Shipment("ORD-1001",
Instant.parse("2026-10-23T03:15:00Z"),
LocalDate.of(2026, 10, 25),
OffsetDateTime.parse("2026-10-23T14:00:00+07:00"));
String json = mapper.writeValueAsString(shipment);
System.out.println(json);
Shipment back = mapper.readValue(json, Shipment.class);
System.out.println(back.createdAt().plusSeconds(3600));
System.out.println(back.pickupAt().toInstant());
try {
mapper.readValue("""
{"orderId":"ORD-1002","createdAt":"23/10/2026 10:15"}
""", Shipment.class);
} catch (tools.jackson.core.JacksonException e) {
System.out.println(e.getClass().getSimpleName() + ": " + e.getOriginalMessage());
}
}
}Output:
text
{"orderId":"ORD-1001","createdAt":"2026-10-23T03:15:00Z","estimatedArrival":"2026-10-25","pickupAt":"2026-10-23T14:00:00+07:00"}
2026-10-23T04:15:00Z
2026-10-23T07:00:00Z
InvalidFormatException: Cannot deserialize value of type `java.time.Instant` from String "23/10/2026 10:15": Failed to deserialize `java.time.Instant` (with format 'ParseCaseSensitive(false)Instant()'): (java.time.format.DateTimeParseException) Text '23/10/2026 10:15' could not be parsed at index 0Untuk waktu kejadian (transaksi, log), kirim Instant atau OffsetDateTime agar zona waktunya jelas; LocalDate cocok untuk tanggal kalender seperti tanggal lahir. Format lokal seperti 23/10/2026 ditolak — itulah yang kamu mau. Di Jackson 2, Instant gagal diserialisasi tanpa modul jsr310, dan setelah modul dipasang pun ditulis sebagai angka timestamp kecuali WRITE_DATES_AS_TIMESTAMPS dimatikan — sumber bug klasik saat dua service berbeda konfigurasi saling bertukar data.
Properti tak dikenal dan error parsing
Jackson 3 mengabaikan property yang tidak ada di target. Ini membuat API toleran terhadap field baru dari pihak lain, tetapi juga menyembunyikan salah ketik. Untuk endpoint internal yang ketat, aktifkan FAIL_ON_UNKNOWN_PROPERTIES:
java
import tools.jackson.core.JacksonException;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.json.JsonMapper;
public class JsonStrict {
record CreateOrderRequest(String sku, int quantity) {}
public static void main(String[] args) {
String json = """
{"sku":"KOPI-250","quantity":2,"qty":5}
""";
JsonMapper lenient = JsonMapper.builder().build();
System.out.println(lenient.readValue(json, CreateOrderRequest.class));
JsonMapper strict = JsonMapper.builder()
.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build();
try {
strict.readValue(json, CreateOrderRequest.class);
} catch (JacksonException e) {
System.out.println(e.getClass().getSimpleName() + ": " + e.getOriginalMessage());
}
try {
lenient.readValue("{\"sku\":\"KOPI-250\",\"quantity\":}", CreateOrderRequest.class);
} catch (JacksonException e) {
System.out.println(e.getClass().getSimpleName() + ": " + e.getOriginalMessage());
}
// field "quantity" tidak dikirim sama sekali
System.out.println(lenient.readValue("{\"sku\":\"KOPI-250\"}", CreateOrderRequest.class));
}
}Output:
text
CreateOrderRequest[sku=KOPI-250, quantity=2]
UnrecognizedPropertyException: Unrecognized property "qty" (class JsonStrict$CreateOrderRequest), not marked as ignorable
StreamReadException: Unexpected character ('}' (code 125)): expected a value
CreateOrderRequest[sku=KOPI-250, quantity=0]Baris terakhir penting: field yang tidak dikirim diisi nilai default (0 untuk int, null untuk object). Jackson hanya memetakan; memastikan field wajib terisi adalah tugas validasi (nanti dengan Bean Validation di Spring). Karena JacksonException unchecked, kamu tidak dipaksa menangkapnya — tangkap di batas sistem (handler request, consumer pesan) lalu ubah menjadi respons error yang jelas.
JsonNode untuk JSON dinamis
Kadang struktur JSON besar, berubah-ubah, atau kamu hanya butuh dua field. readTree membaca JSON menjadi pohon JsonNode tanpa perlu membuat record:
java
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.node.ObjectNode;
public class JsonTree {
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
String webhook = """
{"event":"payment.settled",
"data":{"order_id":"ORD-1001","amount":170000,
"customer":{"email":"budi@contoh.id"},
"items":[{"sku":"KOPI-250","qty":2}]}}
""";
JsonNode root = mapper.readTree(webhook);
System.out.println(root.get("event").asString());
System.out.println(root.path("data").path("amount").asLong());
System.out.println(root.path("data").path("customer").path("email").asString());
System.out.println(root.path("data").path("items").get(0).path("sku").asString());
System.out.println(root.path("data").path("refund").isMissingNode());
System.out.println(root.get("refund"));
ObjectNode reply = mapper.createObjectNode();
reply.put("received", true);
reply.put("orderId", root.path("data").path("order_id").asString());
reply.putArray("warnings").add("amount belum dicek");
System.out.println(mapper.writeValueAsString(reply));
}
}Output:
text
payment.settled
170000
budi@contoh.id
KOPI-250
true
null
{"received":true,"orderId":"ORD-1001","warnings":["amount belum dicek"]}get(name) mengembalikan null bila field tidak ada, sedangkan path(name) mengembalikan MissingNode sehingga rantai pemanggilan tidak pernah NullPointerException. Di Jackson 3, nilai string diambil dengan asString() (Jackson 2: asText()). Untuk data yang strukturnya stabil, tetap pilih record — lebih aman dan terbaca.
Keamanan: polymorphic deserialization
Bila JSON boleh berisi beberapa subtype, Jackson perlu "type id" untuk tahu class mana yang dibuat. Bahaya muncul bila type id berupa nama class yang dikirim client (fitur default typing atau JsonTypeInfo.Id.CLASS): penyerang bisa meminta Jackson membuat class lain di classpath yang efek sampingnya berbahaya. Puluhan CVE Jackson 2 berasal dari pola ini. Jackson 3 mewajibkan PolymorphicTypeValidator untuk default typing, tetapi cara paling aman adalah nama logis dengan daftar tertutup:
java
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.json.JsonMapper;
public class JsonPolymorphic {
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "method")
@JsonSubTypes({
@JsonSubTypes.Type(value = Card.class, name = "card"),
@JsonSubTypes.Type(value = VirtualAccount.class, name = "va")
})
sealed interface PaymentMethod permits Card, VirtualAccount {}
record Card(String last4) implements PaymentMethod {}
record VirtualAccount(String bank, String number) implements PaymentMethod {}
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
System.out.println(mapper.writeValueAsString(new VirtualAccount("BCA", "8808123456")));
PaymentMethod pm = mapper.readValue("""
{"method":"card","last4":"4242"}
""", PaymentMethod.class);
String label = switch (pm) {
case Card c -> "Kartu **** " + c.last4();
case VirtualAccount va -> "VA " + va.bank();
};
System.out.println(label);
try {
mapper.readValue("""
{"method":"java.lang.ProcessBuilder","command":["rm","-rf","/"]}
""", PaymentMethod.class);
} catch (JacksonException e) {
System.out.println(e.getClass().getSimpleName() + ": " + e.getOriginalMessage());
}
}
}Output:
text
{"method":"va","bank":"BCA","number":"8808123456"}
Kartu **** 4242
InvalidTypeIdException: Could not resolve type id 'java.lang.ProcessBuilder' as a subtype of `JsonPolymorphic$PaymentMethod`: known type ids = [card, va]Sealed interface + switch pattern matching membuat compiler ikut memastikan semua subtype ditangani.
Contoh di dunia backend
Payment gateway memanggil endpoint webhook-mu saat pembayaran berubah status. Payload memakai snake_case, nominal sebaiknya BigDecimal (jangan double untuk uang), status dipetakan ke enum, dan gateway bisa menambah field baru kapan saja — sehingga mode toleran Jackson 3 cocok. Handler membalas JSON yang ringkas.
java
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.math.BigDecimal;
import java.time.Instant;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.json.JsonMapper;
enum PaymentStatus { PENDING, SETTLED, FAILED }
record PaymentNotification(
@JsonProperty("order_id") String orderId,
@JsonProperty("gross_amount") BigDecimal amount,
PaymentStatus status,
@JsonProperty("paid_at") Instant paidAt) {}
@JsonInclude(JsonInclude.Include.NON_NULL)
record WebhookResult(boolean accepted, String orderId, String error) {}
class PaymentWebhookHandler {
// JsonMapper thread-safe setelah dibuat: satu instance untuk seluruh aplikasi
private static final JsonMapper MAPPER = JsonMapper.builder().build();
String handle(String body) {
WebhookResult result;
try {
PaymentNotification n = MAPPER.readValue(body, PaymentNotification.class);
if (n.orderId() == null || n.amount() == null || n.status() == null) {
result = new WebhookResult(false, n.orderId(), "field wajib kosong");
} else {
// di aplikasi nyata: cek tanda tangan, cocokkan nominal dengan pesanan, update status
result = new WebhookResult(true, n.orderId(), null);
}
} catch (JacksonException e) {
result = new WebhookResult(false, null, "JSON tidak valid");
}
return MAPPER.writeValueAsString(result);
}
}
public class PaymentWebhookDemo {
public static void main(String[] args) {
var handler = new PaymentWebhookHandler();
System.out.println(handler.handle("""
{"order_id":"ORD-1001","gross_amount":"170000.00","status":"SETTLED",
"paid_at":"2026-10-23T03:15:00Z","signature_key":"9f2c..."}
"""));
System.out.println(handler.handle("""
{"order_id":"ORD-1002","gross_amount":"50000.00"}
"""));
System.out.println(handler.handle("""
{"order_id":"ORD-1003","status":"REFUNDED","gross_amount":1}
"""));
}
}Output:
text
{"accepted":true,"orderId":"ORD-1001"}
{"accepted":false,"orderId":"ORD-1002","error":"field wajib kosong"}
{"accepted":false,"error":"JSON tidak valid"}Permintaan pertama punya field ekstra signature_key yang diabaikan. Permintaan ketiga gagal karena REFUNDED bukan nilai PaymentStatus — enum yang tidak dikenal memicu InvalidFormatException. Pesan error ke luar sengaja umum; detail exception cukup dicatat di log server.
Pertanyaan interview
1. Apa beda serialization dan deserialization, dan kenapa ObjectMapper sebaiknya dibuat sekali?
Serialization mengubah object Java menjadi JSON; deserialization sebaliknya. ObjectMapper/JsonMapper menyimpan cache metadata class (property, constructor, serializer) yang mahal dibangun. Setelah dikonfigurasi ia thread-safe, jadi satu instance cukup untuk seluruh aplikasi — di Spring Boot, instance itu disediakan sebagai bean.
2. Bagaimana kamu menangani field JSON yang tidak dikenal?
Tergantung kontraknya. Untuk payload dari pihak luar yang bisa menambah field (webhook, API publik), abaikan field tak dikenal — default Jackson 3 — agar integrasi tidak rusak saat mereka menambah field. Untuk API internal yang ingin mendeteksi salah ketik, aktifkan FAIL_ON_UNKNOWN_PROPERTIES. Di Jackson 2 defaultnya kebalikan, jadi cek versi yang dipakai.
3. Kenapa deserialisasi polymorphic bisa berbahaya?
Bila type id berupa nama class yang dikendalikan input, penyerang bisa memaksa pembuatan class "gadget" yang efek samping constructor/setter-nya menjalankan perintah atau membuka koneksi. Mitigasinya: jangan aktifkan default typing untuk input tidak tepercaya, pakai JsonTypeInfo.Id.NAME dengan @JsonSubTypes eksplisit, atau validator yang membatasi paket yang diizinkan.
Jebakan umum
- Membuat
JsonMapperbaru di setiap request. Boros CPU dan memori; buat sekali dan pakai ulang. - Mencampur import Jackson 2 dan 3.
com.fasterxml.jackson.databind.ObjectMappertidak memakai konfigurasitools.jackson— dan sebaliknya. Anotasicom.fasterxml.jackson.annotationadalah satu-satunya yang dipakai bersama. - Menganggap field hilang akan ditolak. Jackson mengisi
0/null; validasi field wajib tetap tugasmu. - Memakai
doubleuntuk nominal uang. PakaiBigDecimalataulongdalam satuan terkecil. - Mengirim tanggal tanpa zona waktu untuk kejadian. Pakai
Instant/OffsetDateTime(ISO-8601 denganZatau offset). - Mengaktifkan default typing atau
Id.CLASSuntuk JSON dari luar — membuka celah eksekusi kode.
Ringkasan
- JSON punya enam jenis nilai; tanggal dikirim sebagai string ISO-8601.
- Jackson 3 (
tools.jackson, default Spring Boot 4) memakaiJsonMapper.builder().build(), exception unchecked, dan mendukungjava.timebawaan; anotasinya tetapcom.fasterxml.jackson.annotation. writeValueAsStringdanreadValuemengubah record ↔ JSON;TypeReferenceuntuk tipe generic sepertiList<T>.@JsonProperty,@JsonIgnore,@JsonIncludemengatur nama, kerahasiaan, dan nilainulldi JSON.- Property tak dikenal diabaikan di Jackson 3; field hilang menjadi default — validasi tetap perlu.
JsonNode(path,asString) untuk JSON dinamis; polymorphic hanya dengan nama logis dan daftar subtype tertutup.