Tampilan
Kenapa penting
Batas waktu pembayaran 24 jam, voucher yang berlaku sampai akhir bulan, laporan omzet harian, jadwal pengiriman, log kejadian yang harus diurutkan lintas server — semuanya soal waktu. Bug waktu terkenal licin: kode tampak benar saat dites siang hari di laptop, lalu salah saat server berjalan dalam UTC, saat pengguna ada di zona berbeda, atau saat negara mitra mengganti jam karena DST. Paket java.time (sejak Java 8) memberi tipe yang tegas untuk setiap konsep waktu, sehingga banyak kesalahan langsung terlihat dari tipenya. Bab ini membahas tipe-tipe itu dan aturan yang dipakai hampir semua tim backend: simpan momen dalam UTC, tampilkan dalam zona pengguna.
Konsep inti
Kenapa bukan Date dan Calendar
API lama java.util.Date dan Calendar punya banyak masalah: mutable (object bisa diubah diam-diam oleh kode lain), bulan dimulai dari 0, Date sebenarnya adalah momen tetapi toString()-nya memakai zona default JVM, dan SimpleDateFormat tidak thread-safe. Semua tipe java.time immutable dan thread-safe: method seperti plusDays selalu mengembalikan object baru. Kamu masih akan bertemu Date di library lama; jembatannya sederhana:
java
// Potongan — konversi dengan API lama
Date legacy = Date.from(instant); // Instant → Date
Instant back = legacy.toInstant(); // Date → InstantTipe lokal: tanggal dan jam dinding tanpa zona
LocalDate, LocalTime, dan LocalDateTime mewakili apa yang tertulis di kalender atau jam dinding — tanpa zona waktu. Cocok untuk tanggal lahir, tanggal jatuh tempo tagihan, atau jam buka toko.
java
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.Month;
public class LocalDemo {
public static void main(String[] args) {
LocalDate orderDate = LocalDate.of(2026, Month.JANUARY, 31);
LocalDate nextBilling = orderDate.plusMonths(1); // object baru; orderDate tidak berubah
System.out.println(orderDate + " → " + nextBilling);
System.out.println(nextBilling.getDayOfWeek() + " " + nextBilling.isLeapYear());
System.out.println(LocalDate.of(2028, 1, 31).plusMonths(1));
System.out.println(orderDate.isBefore(nextBilling));
LocalTime storeOpen = LocalTime.of(8, 0);
LocalTime cutoff = LocalTime.parse("15:30");
System.out.println(cutoff.minusMinutes(45) + " " + storeOpen.isBefore(cutoff));
LocalDateTime flashSale = LocalDateTime.of(orderDate, LocalTime.of(20, 0));
System.out.println(flashSale.plusHours(5));
System.out.println(flashSale.toLocalDate().getDayOfWeek() == DayOfWeek.SATURDAY);
}
}Output:
text
2026-01-31 → 2026-02-28
SATURDAY false
2028-02-29
true
14:45 true
2026-02-01T01:00
truePerhatikan plusMonths(1) dari 31 Januari: hasilnya hari terakhir Februari (28 di 2026, 29 di 2028), bukan error. Penting saat menghitung tagihan bulanan. Yang tidak bisa dilakukan tipe lokal: menjawab "kapan tepatnya ini terjadi?". 2026-02-01T01:00 di Jakarta dan di London adalah dua momen berbeda.
Instant: satu titik di garis waktu
Instant adalah momen absolut — jumlah detik (dan nanodetik) sejak epoch 1970-01-01T00:00:00Z — tanpa kalender dan tanpa zona. Selalu ditampilkan dalam UTC (akhiran Z). Inilah tipe untuk mencatat kejadian: pesanan dibuat, pembayaran diterima, token kedaluwarsa.
ZoneId, ZonedDateTime, dan OffsetDateTime
Untuk menampilkan momen kepada manusia, gabungkan dengan zona. ZoneId.of("Asia/Jakarta") adalah zona IANA (WIB); ada juga Asia/Makassar (WITA) dan Asia/Jayapura (WIT).
java
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
public class ZoneDemo {
public static void main(String[] args) {
Instant paidAt = Instant.parse("2026-10-23T03:15:00Z"); // satu titik di garis waktu, UTC
ZonedDateTime jakarta = paidAt.atZone(ZoneId.of("Asia/Jakarta"));
ZonedDateTime makassar = paidAt.atZone(ZoneId.of("Asia/Makassar"));
ZonedDateTime newYork = paidAt.atZone(ZoneId.of("America/New_York"));
System.out.println(paidAt);
System.out.println(jakarta);
System.out.println(makassar);
System.out.println(newYork);
System.out.println(jakarta.toInstant().equals(newYork.toInstant()));
OffsetDateTime fromPartner = OffsetDateTime.parse("2026-10-23T10:15:00+07:00");
System.out.println(fromPartner.toInstant());
System.out.println(fromPartner.withOffsetSameInstant(ZoneOffset.UTC));
System.out.println(Instant.ofEpochSecond(1_792_725_300L));
System.out.println(paidAt.getEpochSecond());
}
}Output:
text
2026-10-23T03:15:00Z
2026-10-23T10:15+07:00[Asia/Jakarta]
2026-10-23T11:15+08:00[Asia/Makassar]
2026-10-22T23:15-04:00[America/New_York]
true
2026-10-23T03:15:00Z
2026-10-23T03:15Z
2026-10-23T03:15:00Z
1792725300Empat baris pertama adalah momen yang sama dilihat dari tempat berbeda — di New York bahkan masih tanggal 22.
| Tipe | Isi | Pakai untuk |
|---|---|---|
LocalDate | tanggal | tanggal lahir, jatuh tempo, hari libur |
LocalTime | jam dinding | jam buka toko, batas jam pengiriman |
LocalDateTime | tanggal + jam, tanpa zona | jarang tepat untuk backend; jadwal "jam lokal" saja |
Instant | momen UTC | menyimpan kejadian, log, perbandingan waktu |
ZonedDateTime | momen + zona IANA (aturan DST ikut) | tampilan ke pengguna, perhitungan kalender per zona |
OffsetDateTime | momen + offset tetap (+07:00) | pertukaran data (API JSON, kolom database) |
Beda ZonedDateTime dan OffsetDateTime: offset hanyalah selisih angka dari UTC, sedangkan zona membawa aturan — kapan offset berubah karena DST atau keputusan pemerintah. Untuk menghitung "besok jam yang sama di New York", butuh zona. Untuk mengirim timestamp di API, offset sudah cukup.
Duration vs Period, dan jebakan DST
Duration mengukur waktu mesin (detik, jam). Period mengukur waktu kalender (tahun, bulan, hari). Bedanya terasa saat DST: di beberapa negara, jam dinding dimajukan satu jam di musim semi. Indonesia saat ini tidak memakai DST, tetapi mitra, pengguna, atau server di luar negeri bisa.
java
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Period;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
public class DurationPeriodDemo {
public static void main(String[] args) {
Duration paymentWindow = Duration.ofHours(24).plusMinutes(30);
System.out.println(paymentWindow + " = " + paymentWindow.toMinutes() + " menit");
Instant start = Instant.parse("2026-10-23T03:00:00Z");
Instant end = Instant.parse("2026-10-23T03:00:02.500Z");
System.out.println(Duration.between(start, end).toMillis() + " ms");
Period membership = Period.between(LocalDate.of(2024, 2, 29), LocalDate.of(2026, 10, 23));
System.out.println(membership + " → " + membership.getYears() + " tahun " + membership.getMonths() + " bulan");
System.out.println(ChronoUnit.DAYS.between(LocalDate.of(2026, 10, 1), LocalDate.of(2026, 12, 25)) + " hari");
// DST: 8 Maret 2026, jam 02:00 di New York melompat ke 03:00
ZoneId ny = ZoneId.of("America/New_York");
ZonedDateTime beforeJump = ZonedDateTime.of(LocalDateTime.of(2026, 3, 7, 12, 0), ny);
System.out.println(beforeJump.plus(Period.ofDays(1))); // jam dinding sama
System.out.println(beforeJump.plus(Duration.ofDays(1))); // tepat 24 jam
System.out.println(ZonedDateTime.of(LocalDateTime.of(2026, 3, 8, 2, 30), ny)); // jam yang "tidak ada"
}
}Output:
text
PT24H30M = 1470 menit
2500 ms
P2Y7M24D → 2 tahun 7 bulan
85 hari
2026-03-08T12:00-04:00[America/New_York]
2026-03-08T13:00-04:00[America/New_York]
2026-03-08T03:30-04:00[America/New_York]toString() keduanya memakai format ISO-8601: PT24H30M (Duration) dan P2Y7M24D (Period). Pilih Duration untuk batas waktu teknis (timeout, masa berlaku token, "bayar dalam 24 jam"), dan Period untuk aturan kalender ("langganan 1 bulan", "garansi 1 tahun"). Baris terakhir menunjukkan bahwa 02:30 pada 8 Maret 2026 tidak pernah ada di New York — Java menggesernya ke 03:30, bukan melempar error. Itu salah satu alasan untuk menyimpan Instant, bukan jam dinding.
Format & parse: DateTimeFormatter dan ISO-8601
Semua tipe java.time punya toString() dan parse() berformat ISO-8601 (2026-10-23, 2026-10-23T03:15:00Z) — format yang sebaiknya dipakai di API, log, dan file. Untuk tampilan ke manusia, pakai DateTimeFormatter. Formatter ini immutable dan thread-safe, jadi aman disimpan di konstanta static final.
java
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Locale;
public class FormatDemo {
private static final DateTimeFormatter RECEIPT =
DateTimeFormatter.ofPattern("d MMMM uuuu, HH:mm z", Locale.of("id", "ID"));
private static final DateTimeFormatter INPUT = DateTimeFormatter.ofPattern("dd/MM/uuuu");
public static void main(String[] args) {
Instant paidAt = Instant.parse("2026-10-23T03:15:00Z");
System.out.println(RECEIPT.format(paidAt.atZone(ZoneId.of("Asia/Jakarta"))));
LocalDate delivery = LocalDate.parse("25/12/2026", INPUT);
System.out.println(delivery + " " + delivery.format(DateTimeFormatter.ISO_LOCAL_DATE));
System.out.println(LocalDate.parse("2026-12-25").equals(delivery));
try {
LocalDate.parse("2026-13-01");
} catch (DateTimeParseException e) {
System.out.println(e.getMessage());
}
LocalDate endOfYear = LocalDate.of(2026, 12, 28);
System.out.println(endOfYear.format(DateTimeFormatter.ofPattern("YYYY-MM-dd", Locale.US)));
System.out.println(endOfYear.format(DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.US)));
}
}Output:
text
23 Oktober 2026, 10:15 WIB
2026-12-25 2026-12-25
true
Text '2026-13-01' could not be parsed: Invalid value for MonthOfYear (valid values 1 - 12): 13
2027-12-28
2026-12-28Huruf pola peka kapital: MM bulan, mm menit, HH jam 0–23, hh jam 1–12. uuuu/yyyy adalah tahun, sedangkan YYYY adalah tahun berbasis minggu — di akhir Desember bisa sudah "2027", seperti dua baris terakhir. Input yang tidak valid menghasilkan DateTimeParseException (unchecked); di endpoint API, tangkap dan ubah menjadi respons "format tanggal salah", jangan biarkan menjadi error 500.
Clock: waktu yang bisa dikendalikan
Instant.now() dan LocalDate.now() membaca jam sistem, sehingga kode yang memanggilnya langsung sulit dites: hasil test bergantung pada kapan test berjalan. Solusinya, suntikkan java.time.Clock dan panggil Instant.now(clock). Produksi memakai Clock.systemUTC(); test memakai Clock.fixed(...) untuk waktu beku atau Clock.offset(...) untuk "memajukan waktu" tanpa Thread.sleep. Contoh lengkapnya ada di bagian berikut.
Contoh di dunia backend
Layanan pesanan memberi batas pembayaran 24 jam. Waktu disimpan sebagai Instant (UTC), ditampilkan ke pembeli dalam WIB, dan laporan "pesanan tanggal X" menghitung batas hari di zona toko — bukan di UTC.
java
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
record Order(String id, Instant createdAt, Instant payBefore) {}
class OrderService {
private static final Duration PAYMENT_WINDOW = Duration.ofHours(24);
private final Clock clock; // disuntikkan, bukan memanggil Instant.now() langsung
OrderService(Clock clock) {
this.clock = clock;
}
Order create(String id) {
Instant now = Instant.now(clock);
return new Order(id, now, now.plus(PAYMENT_WINDOW));
}
boolean isExpired(Order order) {
return !Instant.now(clock).isBefore(order.payBefore());
}
/** Laporan "pesanan hari ini" versi toko di Jakarta: batas hari dihitung di zona toko, lalu ke UTC. */
static Instant[] dayRangeUtc(LocalDate day, ZoneId zone) {
Instant from = day.atStartOfDay(zone).toInstant();
Instant to = day.plusDays(1).atStartOfDay(zone).toInstant();
return new Instant[] {from, to};
}
}
public class PaymentDeadline {
private static final ZoneId JAKARTA = ZoneId.of("Asia/Jakarta");
private static final DateTimeFormatter DISPLAY =
DateTimeFormatter.ofPattern("EEEE, d MMM uuuu HH:mm z", Locale.of("id", "ID"));
public static void main(String[] args) {
Clock fixed = Clock.fixed(Instant.parse("2026-10-23T18:30:00Z"), ZoneId.of("UTC"));
OrderService service = new OrderService(fixed);
Order order = service.create("ORD-1001");
System.out.println("Disimpan (UTC) : " + order.createdAt() + " / " + order.payBefore());
System.out.println("Tampil ke user : bayar sebelum " + DISPLAY.format(order.payBefore().atZone(JAKARTA)));
System.out.println("Kedaluwarsa? : " + service.isExpired(order));
// "Waktu berjalan" 24 jam di test — tanpa Thread.sleep
OrderService later = new OrderService(Clock.offset(fixed, Duration.ofHours(24)));
System.out.println("24 jam kemudian: " + later.isExpired(order));
Instant[] range = OrderService.dayRangeUtc(LocalDate.of(2026, 10, 24), JAKARTA);
System.out.println("24 Okt WIB = [" + range[0] + ", " + range[1] + ")");
System.out.println("ORD-1001 hari itu? " + (!order.createdAt().isBefore(range[0]) && order.createdAt().isBefore(range[1])));
System.out.println("Tanggal UTC : " + LocalDate.ofInstant(order.createdAt(), ZoneId.of("UTC")));
System.out.println("Tanggal WIB : " + LocalDate.ofInstant(order.createdAt(), JAKARTA));
}
}Output:
text
Disimpan (UTC) : 2026-10-23T18:30:00Z / 2026-10-24T18:30:00Z
Tampil ke user : bayar sebelum Minggu, 25 Okt 2026 01:30 WIB
Kedaluwarsa? : false
24 jam kemudian: true
24 Okt WIB = [2026-10-23T17:00:00Z, 2026-10-24T17:00:00Z)
ORD-1001 hari itu? true
Tanggal UTC : 2026-10-23
Tanggal WIB : 2026-10-24Pesanan dibuat pukul 01:30 WIB tanggal 24, tetapi di UTC masih tanggal 23. Laporan yang memotong hari di UTC akan salah memasukkannya ke tanggal 23. Rentang dibuat setengah terbuka [from, to) agar pesanan tepat tengah malam tidak terhitung dua kali. Pola ini — Instant di penyimpanan, ZoneId hanya di batas tampilan dan aturan kalender, Clock yang disuntikkan — akan kamu pakai lagi saat menyimpan waktu ke kolom database timestamptz dan mengirimnya lewat JSON di bab-bab berikutnya.
Pertanyaan interview
1. Kapan memakai Instant, LocalDateTime, dan ZonedDateTime?
Instant untuk mencatat kapan sesuatu terjadi — momen absolut dalam UTC, ideal untuk disimpan dan dibandingkan. ZonedDateTime untuk menampilkan momen itu di zona pengguna atau menghitung aturan kalender per zona. LocalDateTime hanya untuk nilai yang memang tanpa zona (mis. "flash sale jam 20:00 waktu toko setempat"); ia tidak mewakili momen tertentu sampai digabung dengan zona.
2. Apa beda Duration dan Period?
Duration adalah jumlah waktu mesin (detik/nanodetik) — 1 hari selalu 24 jam. Period adalah jumlah kalender (tahun/bulan/hari) — "1 hari" berarti tanggal berikutnya di jam dinding yang sama, yang bisa 23 atau 25 jam saat DST. Batas waktu teknis memakai Duration, aturan bisnis berbasis kalender memakai Period.
3. Bagaimana menguji kode yang bergantung pada waktu sekarang?
Jangan memanggil Instant.now() langsung di logika bisnis. Suntikkan Clock lewat constructor dan pakai Instant.now(clock). Di test, berikan Clock.fixed(...) agar hasilnya deterministik, atau Clock.offset(...) untuk mensimulasikan waktu yang sudah berlalu.
Jebakan umum
- Menyimpan kejadian sebagai
LocalDateTime. Tanpa zona, nilainya ambigu dan bergeser saat zona server berubah. SimpanInstant/UTC. - Memotong "hari" di UTC untuk laporan lokal. Hitung batas hari dengan
atStartOfDay(zone)di zona bisnis. - Menambah
Duration.ofDays(1)untuk aturan kalender (atau sebaliknya) — hasilnya meleset satu jam saat DST. - Pola
YYYYalih-alihuuuu/yyyydanmmalih-alihMM— tanggal salah hanya di hari-hari tertentu. - Memanggil
Instant.now()langsung di logika bisnis — test jadi flaky; suntikkanClock. - Mengandalkan zona default JVM (
ZoneId.systemDefault(),LocalDate.now()tanpa argumen). Sebutkan zona secara eksplisit.
Ringkasan
- Tipe
java.timeimmutable dan thread-safe; tinggalkanDate/Calendarkecuali untuk integrasi kode lama. - Tipe lokal (
LocalDate,LocalTime,LocalDateTime) tidak punya zona;Instantadalah momen UTC. ZonedDateTimemembawa aturan zona (DST),OffsetDateTimehanya offset — cocok untuk pertukaran data.Durationuntuk waktu mesin,Perioduntuk kalender; keduanya berbeda saat DST.- ISO-8601 untuk API dan log,
DateTimeFormatter(konstanta, polauuuu) untuk tampilan. - Aturan backend: simpan
Instant, tampilkan di zona pengguna, suntikkanClockagar bisa dites.