Kamu sudah nyaman menulis Go, tapi kadang ada perasaan bahwa workflow-mu bisa lebih cepat — terlalu banyak langkah manual, terlalu banyak boilerplate yang ditulis berulang, atau test yang susah dirawat seiring project berkembang. Go punya banyak fitur bawaan dan konvensi komunitas yang justru sering dilewatkan karena tidak terlalu disorot di tutorial dasar.
Artikel ini membahas 10 trik yang langsung bisa kamu terapkan di project Go berikutnya — dari cara mengelola lifecycle request yang lebih rapi, hingga pola pengujian yang mengurangi duplikasi kode secara dramatis. Sebagian besar tidak membutuhkan library tambahan, cukup memanfaatkan apa yang sudah ada di ekosistem Go.
Bawa context ke Setiap Layer
context.Context bukan sekadar formalitas. Ia adalah mekanisme utama untuk mengirim sinyal pembatalan, deadline, dan tracing ID menembus batas fungsi dan goroutine. Tanpa context, sulit mengontrol berapa lama sebuah operasi boleh berjalan — terutama saat menangani database query atau HTTP call ke service lain.
Bayangkan sebuah handler yang memproses ekspor laporan. Jika user menutup koneksi di tengah jalan, kamu ingin query ke database juga berhenti:
// handler/report.go
func ExportReportHandler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
reportID := r.URL.Query().Get("id")
data, err := reportService.Generate(ctx, reportID)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "Timeout: laporan butuh terlalu lama", http.StatusGatewayTimeout)
return
}
http.Error(w, "Gagal generate laporan", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
Dengan context.WithTimeout, jika Generate belum selesai dalam 10 detik, semua operasi turunan yang menerima ctx yang sama — termasuk query SQL di dalam reportService — akan menerima sinyal untuk berhenti. Tidak ada resource yang terbuang percuma.
Selalu teruskan ctx sebagai argumen pertama di setiap fungsi yang melakukan I/O. Ini adalah konvensi idiomatik Go yang membuat kode lebih mudah ditelusuri dan diuji.
Gunakan go run untuk Eksperimen Cepat
Setiap kali kamu ingin menguji sebuah fungsi kecil atau ide baru, tidak perlu repot menyiapkan binary. Cukup jalankan langsung dengan:
go run main.go
Untuk project multi-file di direktori yang sama, gunakan:
go run .
Ini menghilangkan satu gesekan kecil yang kalau dibiarkan bisa memperlambat siklus eksperimen kamu secara keseluruhan. Khususnya berguna saat menulis utilitas kecil atau skrip one-off yang belum layak dijadikan binary permanen.
Otomasi Repetisi dengan go generate
go generate adalah perintah yang sering diabaikan, padahal potensinya besar. Ia membaca komentar khusus di source code dan menjalankan perintah eksternal saat kamu memanggil go generate ./....
Contoh penggunaan umum: membuat mock dari interface secara otomatis menggunakan mockgen.
// service/notification.go
//go:generate mockgen -source=notification.go -destination=../mocks/notification_mock.go -package=mocks
type NotificationSender interface {
Send(ctx context.Context, to string, message string) error
SendBulk(ctx context.Context, recipients []string, message string) (int, error)
}
Setelah menambahkan komentar //go:generate, jalankan satu perintah:
go generate ./...
Go akan membaca setiap file, menemukan komentar //go:generate, lalu mengeksekusinya. Hasilnya adalah file mock yang selalu sinkron dengan interface aslinya — tanpa perlu update manual setiap kali interface berubah.
Batasi Akses Paket dengan Direktori internal
Go punya fitur akses kontrol bawaan yang elegan: direktori bernama internal. Paket yang berada di dalam internal/ hanya bisa diimpor oleh kode yang berada satu level di atasnya atau di bawahnya dalam hierarki direktori — tidak bisa diimpor dari luar modul.
project/
cmd/
api/
main.go
internal/
repository/
user_repo.go <- hanya bisa diakses dari dalam project/
domain/
user.go
pkg/
validator/
email.go <- bisa diakses dari luar
Ini cara terbaik untuk menegakkan boundary antar layer tanpa menulis kode boilerplate. Kalau UserRepository tidak seharusnya diakses langsung dari luar modul, letakkan di internal/repository/ — Go compiler sendiri yang akan menolak import yang tidak sah.
Untuk arsitektur yang lebih lengkap, baca panduan Clean Architecture di Go yang membahas cara mengorganisir layer ini secara sistematis.
Struct Tag JSON yang Benar dari Awal
Kesalahan kecil di struct tag JSON bisa menyebabkan API response yang tidak konsisten atau data yang tidak terkirim. Dua hal yang sering dilewatkan: menggunakan omitempty untuk field opsional, dan memastikan nama field mengikuti konvensi snake_case yang umum di API.
// domain/invoice.go
type Invoice struct {
ID string `json:"id"`
CustomerID string `json:"customer_id"`
TotalAmount float64 `json:"total_amount"`
PaidAt *time.Time `json:"paid_at,omitempty"`
Notes string `json:"notes,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
Field PaidAt bertipe pointer ke time.Time — jika nil, omitempty akan menghapusnya dari output JSON sepenuhnya, bukan mengirimkan null. Field Notes yang string kosong juga tidak akan muncul di response. Ini menjaga payload API tetap bersih dan tidak membingungkan client.
omitempty pada tipe non-pointer seperti int atau bool akan mengabaikan nilai 0 dan false — yang bisa jadi bug jika nilai tersebut memang bermakna. Gunakan pointer untuk field yang harus bisa bernilai zero secara eksplisit.
Table-Driven Tests untuk Pengujian yang Skalabel
Pola table-driven test adalah idiom Go yang paling banyak direkomendasikan untuk situasi di mana kamu perlu menguji banyak skenario input/output dari satu fungsi. Daripada menulis 10 fungsi test terpisah, kamu definisikan satu tabel kasus uji dan iterasi di atasnya.
// domain/discount_test.go
func TestCalculateDiscount(t *testing.T) {
cases := []struct {
name string
originalPrice float64
memberTier string
expectedResult float64
}{
{"harga normal tanpa member", 100_000, "none", 100_000},
{"diskon 10% untuk silver", 100_000, "silver", 90_000},
{"diskon 20% untuk gold", 100_000, "gold", 80_000},
{"diskon 30% untuk platinum", 100_000, "platinum", 70_000},
{"harga nol tetap nol", 0, "gold", 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result := CalculateDiscount(tc.originalPrice, tc.memberTier)
if result != tc.expectedResult {
t.Errorf("got %.0f, want %.0f", result, tc.expectedResult)
}
})
}
}
Menambah skenario baru cukup dengan menambah satu baris di slice cases. Tidak perlu menyentuh logika test sama sekali. Ini juga membuat test failures lebih mudah dibaca karena nama subtest langsung menggambarkan kasus yang gagal.
Constructor Function untuk Inisialisasi yang Konsisten
Menginisialisasi struct secara langsung (ProjectService{}) di berbagai tempat rentan terhadap inkonsistensi — satu tempat lupa set field tertentu, field baru ditambahkan tapi tidak semua inisialisasi ikut diperbarui. Constructor function menyelesaikan masalah ini.
// service/project.go
type ProjectService struct {
repo ProjectRepository
mailer MailSender
logger *slog.Logger
}
func NewProjectService(repo ProjectRepository, mailer MailSender, logger *slog.Logger) *ProjectService {
if repo == nil {
panic("ProjectService: repo tidak boleh nil")
}
return &ProjectService{
repo: repo,
mailer: mailer,
logger: logger,
}
}
Dengan NewProjectService, setiap pembuatan ProjectService dijamin melewati satu pintu masuk yang sama. Validasi dependency bisa dilakukan di satu tempat, dan field baru yang wajib diisi bisa dipaksa ada dengan mengubah signature constructor — compiler langsung menginformasikan semua tempat yang perlu diperbarui.
sync.Once untuk Inisialisasi Mahal yang Cukup Sekali
Beberapa resource seperti koneksi database, konfigurasi yang di-load dari file, atau klien HTTP yang sudah dikonfigurasi — seharusnya dibuat hanya sekali, terlepas dari berapa goroutine yang memintanya secara bersamaan. sync.Once menjamin ini dengan zero boilerplate locking manual.
// infrastructure/db.go
var (
dbInstance *sql.DB
dbOnce sync.Once
)
func GetDB() *sql.DB {
dbOnce.Do(func() {
dsn := os.Getenv("DATABASE_URL")
db, err := sql.Open("postgres", dsn)
if err != nil {
log.Fatalf("gagal membuka koneksi database: %v", err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
dbInstance = db
})
return dbInstance
}
dbOnce.Do memastikan fungsi di dalamnya hanya dieksekusi satu kali — bahkan jika GetDB() dipanggil dari ratusan goroutine secara bersamaan saat startup. Goroutine lain akan menunggu inisialisasi selesai, lalu mendapat instance yang sama.
Interface Kecil untuk Arsitektur yang Mudah Diuji
Di Go, interface terbaik adalah yang kecil. Interface dengan satu atau dua method lebih mudah diimplementasi, lebih mudah di-mock, dan lebih mudah dikomposisikan. Prinsip ini sering disebut interface segregation — dan Go mendorong kamu ke sana secara natural karena interface diimplementasi secara implisit.
// port/storage.go — interface di sisi yang membutuhkan, bukan di sisi yang mengimplementasi
type TaskReader interface {
FindByID(ctx context.Context, id string) (*domain.Task, error)
FindByProject(ctx context.Context, projectID string) ([]*domain.Task, error)
}
type TaskWriter interface {
Save(ctx context.Context, task *domain.Task) error
Delete(ctx context.Context, id string) error
}
// Gabungkan hanya jika benar-benar butuh keduanya
type TaskRepository interface {
TaskReader
TaskWriter
}
Dengan memisahkan TaskReader dan TaskWriter, handler yang hanya membaca data tidak perlu tahu tentang operasi tulis — dan mock yang dibuat untuk test juga lebih kecil dan lebih mudah dipahami. Lihat contoh pola ini di arsitektur lengkap dalam artikel Clean Architecture di Go.
Jalankan go mod tidy Secara Rutin
go mod tidy adalah perintah yang satu ini lebih penting dari yang terlihat. Ia menghapus dependency yang tidak lagi dipakai dari go.mod dan go.sum, sekaligus menambahkan yang masih hilang. Tanpanya, file go.mod mudah membengkak dengan dependency yang sudah tidak relevan.
# Jalankan setelah menambah, menghapus, atau mengubah import
go mod tidy
# Verifikasi hasilnya
go mod verify
Jadikan ini kebiasaan — setidaknya sebelum commit, atau masukkan ke dalam Makefile bersama perintah lain yang sering dipakai:
# Makefile
.PHONY: tidy build test
tidy:
go mod tidy
go mod verify
build:
go build -o bin/app ./cmd/api
test:
go test ./... -race -count=1
Makefile sederhana seperti ini mengurangi cognitive overhead: tidak perlu mengingat flag apa yang dipakai, cukup make test atau make build.
Kesimpulan
Produktivitas di Go bukan soal menulis kode lebih cepat — tapi soal mengurangi keputusan kecil yang harus dibuat berulang. context yang konsisten, constructor function yang terpercaya, table-driven test yang mudah dikembangkan, dan go mod tidy yang rutin adalah fondasi yang membuat kode Go terasa terkendali bahkan saat project makin besar. Kamu tidak perlu menerapkan semuanya sekaligus — pilih dua atau tiga yang paling relevan dengan project saat ini, lalu lihat hasilnya dalam beberapa sprint ke depan.