← Semua pembelajaran / Go Nol → Enterprise
Fase 5 · Arsitektur Aplikasi

main.go — merakit seluruh aplikasi

main.go adalah satu-satunya tempat di aplikasimu yang boleh tahu segalanya. Menyusunnya dengan benar membuat sisa kode tetap bisa diuji, dan membuat container gagal cepat saat ada yang salah.

Sumber asli pkg.go.dev Resmi Rangkuman ~6 menit baca

Intisari

  • main tipis, jalankan(ctx) error tebal — supaya defer benar-benar berjalan.
  • Urutan bangun: konfigurasi → logger → infrastruktur → domain → transport → server.
  • Urutan tutup kebalikannya: server dulu, koneksi terakhir.
  • Gagal cepat: apa pun yang salah saat startup harus membuat proses keluar dengan kode bukan-nol.
  • Satu repo boleh punya beberapa binari — server, worker, migrate — berbagi seluruh paket internal/.

Kerangka lengkap

package main

func main() {
	if err := jalankan(); err != nil {
		// Ditulis ke stderr karena logger mungkin belum sempat dibuat.
		fmt.Fprintf(os.Stderr, "fatal: %v\n", err)
		os.Exit(1)
	}
}

func jalankan() error {
	// 1. Sinyal → context. Semua yang di bawah mewarisinya.
	ctx, hentikanSinyal := signal.NotifyContext(context.Background(),
		os.Interrupt, syscall.SIGTERM)
	defer hentikanSinyal()

	// 2. Konfigurasi. Gagal di sini = container tidak pernah melayani.
	cfg, err := config.Muat()
	if err != nil {
		return err
	}

	// 3. Logger — sebelum apa pun yang mungkin perlu dicatat.
	log := buatLogger(cfg)
	log.Info("mulai", "versi", versi, "env", cfg.Env, "commit", commit)

	// 4. Observability, dengan penutupan yang mengirim sisa data.
	matikanOtel, err := platform.SiapkanOtel(ctx, cfg)
	if err != nil {
		return fmt.Errorf("otel: %w", err)
	}
	defer matikanOtel(context.WithoutCancel(ctx))

	// 5. Infrastruktur. Ping di dalam konstruktornya: kredensial salah
	//    harus jadi kegagalan startup, bukan 500 pertama.
	pool, err := platform.BukaPool(ctx, cfg.DSN.Buka())
	if err != nil {
		return fmt.Errorf("database: %w", err)
	}
	defer pool.Close()

	rdb, err := platform.BukaRedis(ctx, cfg.RedisURL)
	if err != nil {
		return fmt.Errorf("redis: %w", err)
	}
	defer rdb.Close()

	// 6. Domain.
	produkSvc := produk.NewLayanan(
		postgres.NewProdukRepo(pool),
		cache.NewRedis(rdb, "produk", time.Minute),
		log)

	pesananSvc := pesanan.NewLayanan(
		postgres.NewPesananRepo(pool),
		produkSvc,
		antrean.NewOutbox(pool),
		log)

	// 7. Transport.
	h := httpapi.New(produkSvc, pesananSvc, log, cfg)

	srv := &http.Server{
		Addr:              cfg.Addr,
		Handler:           h.Rute(),
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       15 * time.Second,
		WriteTimeout:      15 * time.Second,
		IdleTimeout:       65 * time.Second,
		BaseContext:       func(net.Listener) context.Context { return ctx },
	}

	// 8. Jalankan sampai sinyal, lalu tutup rapi (Fase 3).
	return sajikan(ctx, srv, log, cfg)
}

BaseContext jarang dipakai tapi berguna: ia membuat r.Context() setiap permintaan mewarisi context aplikasi. Efeknya, sinyal berhenti tidak hanya menghentikan penerimaan koneksi baru — ia juga membatalkan kueri yang sedang berjalan begitu batas waktu shutdown terlampaui, alih-alih membiarkannya menahan koneksi sampai SIGKILL.

Urutan yang penting

BangunTutup
1. Konfigurasi6. —
2. Logger5. —
3. Observability4. Kirim sisa trace terakhir
4. Database, Redis3. Tutup setelah semua pemakainya berhenti
5. Service domain2. —
6. Server HTTP / worker1. Hentikan pertama

Menutup database sebelum server berhenti adalah kesalahan yang menghasilkan log kacau saat deploy: permintaan yang masih berjalan tiba-tiba menemui pool yang sudah tertutup, dan CloudWatch penuh error yang menyesatkan. Karena defer berjalan dengan urutan terbalik, menuliskannya sesuai urutan pembangunan sudah menghasilkan urutan penutupan yang benar — asalkan penghentian server dilakukan secara eksplisit sebelum jalankan mengembalikan nilai.

Versi build yang ikut ke dalam binari

var (
	versi  = "dev"
	commit = "none"
	waktu  = "unknown"
)
go build -ldflags "\
  -X main.versi=$(git describe --tags --always) \
  -X main.commit=$(git rev-parse --short HEAD) \
  -X main.waktu=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
  -s -w" \
  -o bin/server ./cmd/server

Baris log pertama yang menyebut versi dan commit menghemat banyak waktu saat insiden. Ia menjawab pertanyaan yang selalu muncul lebih dulu: "apakah task ini sudah versi baru?" Flag -s -w membuang tabel simbol dan info debug, memangkas ukuran binari sekitar 25% — berguna untuk image container (Fase 10), tapi matikan kalau kamu butuh stack trace yang lengkap.

Beberapa binari, satu modul

cmd/
├── server/main.go     # HTTP
├── worker/main.go     # pemroses antrean
├── migrate/main.go    # migrasi skema
└── seed/main.go       # data awal untuk staging
go build -o bin/server  ./cmd/server
go build -o bin/worker  ./cmd/worker
go build -o bin/migrate ./cmd/migrate

Semuanya berbagi internal/, dan semuanya masuk ke satu image Docker (Fase 10). Task definition yang berbeda cukup menimpa command-nya. Dengan begitu, tidak mungkin ada perbedaan versi antara server, worker, dan migrasi — satu tag image, satu kebenaran.

Daftar periksa startup

  1. Konfigurasi tidak lengkap → keluar dengan kode bukan-nol, sebutkan semua yang kurang.
  2. Database tidak bisa dihubungi → keluar, jangan melayani.
  3. Migrasi tertinggal → putuskan sadar: gagal, atau jalan dengan peringatan.
  4. Baris log pertama menyebut versi, commit, dan environment.
  5. /sehat siap sebelum port didengarkan, dan tidak menyentuh database.
  6. Setiap sumber daya yang dibuka punya defer penutupnya.

Latihan: susun ulang main.go aplikasimu jadi pola main tipis + jalankan() error. Buktikan perbedaannya: taruh defer fmt.Println("tutup") lalu panggil os.Exit(1) di tengah — dan lihat pesan itu tidak tercetak. Pindahkan os.Exit ke main dan lihat ia tercetak.

Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.