01. domain: bentuk data HazardEvent

Bukan bagianmu: milik Grace (P1). File asli: internal/domain/hazard.go, di branch feat/p1 / feat/p3. Kamu perlu memahaminya karena store dan api memakai bentuk ini.


Kenapa ada

BMKG dan PVMBG punya format data masing-masing (SeismicEvent, VolcanicReport). Spek meminta satu canonical format (bentuk resmi tunggal) bernama HazardEvent, yang dipakai BNPB untuk menyatukan keduanya. Semua bagian aggregator menyentuh bentuk ini:

BagianPeran terhadap HazardEvent
mapping (Grace)menghasilkan HazardEvent dari data sumber
klien sumber (Grace)memanggil mapping, lalu mengembalikan HazardEvent
ingest (kamu)membawa HazardEvent dari klien ke handler
store (kamu)menyimpan dan membaca HazardEvent
api (kamu)menyajikan HazardEvent
publisher (Mike)mengirim HazardEvent ke Kafka

Kenapa dibutuhkan

Di Go, dua struct yang dideklarasikan di dua package berbeda dianggap dua tipe berbeda, walau isinya mirip. Jadi semua bagian harus meng-import satu definisi yang sama. Itulah fungsi package domain. Package ini hanya berisi tipe dan dua fungsi kecil, tanpa logika bisnis.


Bagian 1: tiga “enum”

Go tidak punya enum. Caranya: buat tipe baru dari string, lalu daftar nilai sahnya sebagai const. (Pelajaran 8.)

Dikutip dari internal/domain/hazard.go, baris 11 sampai 16

type Source string
 
const (
	SourceBMKG  Source = "BMKG"
	SourcePVMBG Source = "PVMBG"
)

Dikutip dari internal/domain/hazard.go, baris 25 sampai 32

type Severity string
 
const (
	SeverityNormal  Severity = "NORMAL"
	SeverityWaspada Severity = "WASPADA"
	SeveritySiaga   Severity = "SIAGA"
	SeverityAwas    Severity = "AWAS"
)

Manfaat: fungsi yang meminta Severity tidak bisa diberi sembarang string tanpa konversi eksplisit, dan tidak ada yang mengetik "awas" huruf kecil secara tidak sengaja. Nilai-nilainya persis seperti di spek: BMKG/PVMBG, SEISMIC/VOLCANIC, NORMAL/WASPADA/SIAGA/AWAS.


Bagian 2: struct HazardEvent

Dikutip dari internal/domain/hazard.go, baris 34 sampai 46

type HazardEvent struct {
	HazardID    string         `json:"hazard_id" bson:"hazard_id"`
	Source      Source         `json:"source" bson:"source"`
	SourceRefID string         `json:"source_ref_id" bson:"source_ref_id"`
	HazardType  HazardType     `json:"hazard_type" bson:"hazard_type"`
	Severity    Severity       `json:"severity" bson:"severity"`
	AreaName    string         `json:"area_name" bson:"area_name"`
	Latitude    float64        `json:"latitude" bson:"latitude"`
	Longitude   float64        `json:"longitude" bson:"longitude"`
	OccurredAt  time.Time      `json:"occurred_at" bson:"occurred_at"`
	IngestedAt  time.Time      `json:"ingested_at" bson:"ingested_at"`
	Attributes  map[string]any `json:"attributes" bson:"attributes"`
}

Sebelas field, urutannya sama dengan tabel di spek. Yang perlu kamu pahami untuk tugasmu:

  • Tag `json:"hazard_id" bson:"hazard_id"` memberi nama field di JSON (dipakai api) dan di MongoDB (dipakai store). Tanpa tag, namanya akan HazardID, bukan hazard_id seperti yang diminta spek. (Pelajaran 3.)
  • Attributes map[string]any adalah kantong untuk data khusus sumber yang tidak punya tempat di format resmi: magnitude, depth_km, eruption_count_24h, dan field baru seperti confidence_level. Bentuknya bebas, jadi field baru dari sumber tidak merusak apa pun. Inilah yang membuat Problem 1 (aggregator tidak crash) dan Problem 4 (penyimpanan tanpa migration) bisa terjadi. (Pelajaran 4.)
  • SourceRefID + Source adalah id asli dari instansi. store memakainya sebagai kunci unik untuk upsert.
  • IngestedAt adalah kapan BNPB menerima data. store.List memakainya untuk filter since. Diisi oleh mapper (mapper.now()), selisihnya dengan saat response tiba hanya milidetik.

Bagian 3: NewHazardID, ID yang deterministik

Dikutip dari internal/domain/hazard.go, baris 48 sampai 51

func NewHazardID(source Source, sourceRefID string) string {
	sum := sha256.Sum256([]byte(string(source) + ":" + sourceRefID))
	return "HZ-" + hex.EncodeToString(sum[:16])
}

hazard_id dibuat dari hash (SHA-256) gabungan source dan id aslinya, diberi awalan HZ-. Deterministik berarti masukan yang sama selalu menghasilkan ID yang sama.

Kenapa ini penting untuk P4: record yang sama selalu ber-ID sama. Jadi polling ulang, restart aggregator, dan koreksi severity menghasilkan HazardEvent dengan hazard_id identik, dan upsert di store menimpa dokumen yang sama alih-alih membuat dokumen baru. Kalau ID-nya UUID acak, setiap polling ulang akan menciptakan “peristiwa baru” untuk kejadian yang sama.


Bagian 4: Validate

Dikutip dari internal/domain/hazard.go, baris 53 sampai 83

func (event HazardEvent) Validate() error {
	var errs []error
	if event.HazardID == "" {
		errs = append(errs, errors.New("hazard_id is required"))
	}
	if event.Source != SourceBMKG && event.Source != SourcePVMBG {
		errs = append(errs, fmt.Errorf("unknown source %q", event.Source))
	}
	if event.SourceRefID == "" {
		errs = append(errs, errors.New("source_ref_id is required"))
	}
	if event.HazardType != HazardTypeSeismic && event.HazardType != HazardTypeVolcanic {
		errs = append(errs, fmt.Errorf("unknown hazard_type %q", event.HazardType))
	}
	if !validSeverity(event.Severity) {
		errs = append(errs, fmt.Errorf("unknown severity %q", event.Severity))
	}
	if event.AreaName == "" {
		errs = append(errs, errors.New("area_name is required"))
	}
	if event.OccurredAt.IsZero() {
		errs = append(errs, errors.New("occurred_at is required"))
	}
	if event.IngestedAt.IsZero() {
		errs = append(errs, errors.New("ingested_at is required"))
	}
	if event.Attributes == nil {
		errs = append(errs, errors.New("attributes is required"))
	}
	return errors.Join(errs...)
}

Memeriksa bahwa event lengkap dan memakai nilai enum yang sah. Ia mengumpulkan semua masalah (errors.Join) alih-alih berhenti di yang pertama. Mapper memanggilnya sebelum mengembalikan event, jadi event yang sampai ke store sudah valid. Karena MongoDB tidak punya constraint di sisi database, Validate inilah yang menggantikan perannya. (Pelajaran 2.)


Cara menjelaskan domain dalam 30 detik

HazardEvent adalah bentuk resmi tunggal untuk data dari BMKG dan PVMBG. Field khusus sumber, termasuk yang baru muncul seperti confidence_level, masuk ke Attributes yang bentuknya bebas. hazard_id dibuat deterministik dari source dan id aslinya, sehingga data yang sama selalu mendapat ID yang sama. Package ini milik Grace, dan kami memakainya apa adanya.

Latihan

  1. Kenapa domain tidak berisi logika apa pun selain NewHazardID dan Validate?
  2. Kalau hazard_id memakai UUID acak, apa yang berubah pada perilaku store.Save?
  3. Kenapa Attributes bertipe map[string]any dan bukan struct dengan field tetap?