Singleton Pattern #
Bayangkan sebuah aplikasi yang memiliki puluhan modul berbeda — semuanya membutuhkan akses ke database, semua perlu menulis log, dan semuanya membaca konfigurasi yang sama. Tanpa koordinasi yang benar, setiap modul akan membuat instance-nya sendiri: puluhan koneksi database yang boros resource, puluhan logger yang saling tindih, dan konfigurasi yang bisa berbeda di tiap bagian aplikasi. Singleton Pattern hadir untuk menyelesaikan masalah ini — memastikan bahwa hanya ada satu instance dari sebuah struct yang aktif selama lifecycle aplikasi, dan semua pihak mengakses instance yang persis sama. Pola ini termasuk dalam kelompok creational design pattern dan menjadi fondasi penting yang perlu dipahami sebelum membahas pola-pola lain yang lebih kompleks.
Apa itu Singleton Pattern? #
Singleton Pattern adalah creational design pattern yang menjamin hanya ada satu instance dari sebuah class atau struct selama aplikasi berjalan, sekaligus menyediakan satu titik akses global ke instance tersebut.
Kata kuncinya ada dua: satu instance dan akses global yang terkontrol. Bukan sekadar variabel global biasa — Singleton memiliki mekanisme untuk mencegah pembuatan instance kedua, ketiga, dan seterusnya, tidak peduli dari bagian mana kode mencoba membuatnya.
Tiga karakteristik utama Singleton:
- Instance dibuat tepat satu kali — tidak ada duplikasi, tidak ada instance paralel yang berdiri sendiri
- Instance digunakan bersama oleh seluruh bagian aplikasi yang membutuhkannya
- Pembuatan instance tidak bisa dilakukan sembarangan dari luar — hanya melalui satu pintu yang terkontrol
Penting untuk memahami masalah yang dipecahkan Singleton sebelum menggunakannya. Pattern ini bukan solusi untuk “saya ingin akses mudah ke object ini dari mana saja” — itu adalah variabel global, bukan Singleton. Pattern ini adalah solusi untuk resource yang memang secara fundamental hanya boleh ada satu: satu koneksi pool ke database yang sama, satu logger yang menulis ke file yang sama, satu config yang dibaca dari satu sumber.
flowchart TD
A[Modul A] -->|GetInstance| S{Instance\nsudah ada?}
B[Modul B] -->|GetInstance| S
C[Modul C] -->|GetInstance| S
S -- Belum --> D[Buat instance baru]
D --> E[Simpan sebagai\ninstance tunggal]
E --> F[Kembalikan instance]
S -- Sudah --> F
F --> G[Instance yang sama\ndigunakan oleh semua modul]
Mengapa Singleton Dibutuhkan #
Sebelum masuk ke implementasi, penting untuk memahami mengapa pattern ini ada. Ada tiga masalah nyata yang dipecahkan Singleton.
Masalah 1: Resource yang Mahal untuk Dibuat #
Membuka koneksi database bukan operasi murah. Setiap koneksi memerlukan handshake jaringan, autentikasi, dan alokasi resource di sisi server. Jika setiap bagian kode membuat koneksi sendiri, aplikasi akan kehabisan koneksi tersedia dan performa akan anjlok.
// ANTI-PATTERN: setiap fungsi membuat koneksi baru
func GetUserByID(id int) (*User, error) {
db, err := sql.Open("postgres", dsn) // koneksi baru setiap kali!
if err != nil {
return nil, err
}
defer db.Close()
// query...
}
func GetProductByID(id int) (*Product, error) {
db, err := sql.Open("postgres", dsn) // lagi membuat koneksi baru!
if err != nil {
return nil, err
}
defer db.Close()
// query...
}
// BENAR: satu koneksi pool yang dibuat sekali, dipakai bersama
var dbPool *sql.DB
func GetDB() *sql.DB {
if dbPool == nil {
dbPool, _ = sql.Open("postgres", dsn)
}
return dbPool
}
Masalah 2: State yang Harus Konsisten #
Config yang dibaca oleh satu modul harus persis sama dengan config yang dibaca modul lain. Jika ada dua instance Config yang berbeda — misalnya karena file config berubah di antara dua pembacaan — behavior aplikasi akan menjadi tidak konsisten dan sulit di-debug.
Masalah 3: Koordinasi Terpusat #
Logger, metrics collector, dan cache manager perlu melihat semua data yang melewati sistem. Jika ada dua instance Logger, setengah log akan masuk ke satu instance dan setengahnya lagi ke instance lain. Tidak ada yang melihat gambaran lengkap.
Anatomi Singleton di Golang #
Golang tidak memiliki class seperti Java atau Python. Namun justru karena itu, implementasi Singleton di Golang lebih eksplisit dan mudah dipahami. Ada tiga komponen yang bekerja bersama.
flowchart LR
subgraph Package Level
direction TB
I[instance *Config\nvariable privat]
O[once sync.Once\nkontrol inisialisasi]
end
subgraph Public API
G["GetConfig() *Config\nsatu-satunya pintu masuk"]
end
G -->|pertama kali dipanggil| O
O -->|once.Do| I
G -->|berikutnya| I
Tiga komponen tersebut:
- Package-level variable privat — menyimpan instance, tidak bisa diakses langsung dari luar package
sync.Once— memastikan inisialisasi hanya terjadi satu kali, aman untuk concurrent access- Fungsi getter publik — satu-satunya cara untuk mengakses instance dari luar package
Implementasi Dasar #
Mari mulai dari implementasi paling sederhana untuk memahami strukturnya, sebelum menambahkan lapisan keamanan untuk concurrent access.
Implementasi naif berikut tidak thread-safe, tetapi berguna untuk memahami pola dasarnya:
package config
// Config menyimpan konfigurasi aplikasi.
// Field-field ini hanya dibaca setelah inisialisasi — tidak ada write setelahnya.
type Config struct {
AppName string
Environment string
Port int
DatabaseDSN string
}
var instance *Config
// GetConfig mengembalikan instance Config yang sama setiap kali dipanggil.
// PERINGATAN: implementasi ini tidak thread-safe.
func GetConfig() *Config {
if instance == nil {
instance = &Config{
AppName: "MyApp",
Environment: "production",
Port: 8080,
DatabaseDSN: "postgres://user:pass@localhost/mydb",
}
}
return instance
}
Kode di atas bekerja, tapi hanya jika aplikasi bersifat single-threaded. Di Golang yang dirancang untuk concurrency, ini bukan asumsi yang bisa dipegang.
Bahaya Race Condition
Jika dua goroutine memanggil
GetConfig()secara bersamaan saatinstancemasihnil, keduanya akan masuk ke blokif instance == nilsecara bersamaan. Hasilnya: dua instance akan dibuat. Instance mana yang “menang” bersifat non-deterministic. Ini adalah race condition yang bisa sangat sulit di-reproduce dan di-debug.
Implementasi Thread-Safe dengan sync.Once
#
Solusi standar dan yang direkomendasikan di Golang adalah menggunakan sync.Once. Ini adalah primitive dari standard library yang menjamin sebuah fungsi hanya dieksekusi tepat satu kali, bahkan jika dipanggil secara konkuren dari banyak goroutine sekaligus.
package config
import (
"os"
"sync"
)
// Config menyimpan semua konfigurasi runtime aplikasi.
type Config struct {
AppName string
Environment string
Port int
DatabaseDSN string
LogLevel string
}
var (
instance *Config
once sync.Once
)
// GetConfig mengembalikan instance Config tunggal.
// Aman untuk dipanggil dari banyak goroutine secara bersamaan.
func GetConfig() *Config {
once.Do(func() {
instance = &Config{
AppName: getEnv("APP_NAME", "MyApp"),
Environment: getEnv("APP_ENV", "production"),
Port: 8080,
DatabaseDSN: getEnv("DATABASE_DSN", "postgres://localhost/mydb"),
LogLevel: getEnv("LOG_LEVEL", "info"),
}
})
return instance
}
// getEnv membaca environment variable dengan fallback ke nilai default.
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
Cara penggunaan dari package lain sangat sederhana:
package main
import (
"fmt"
"myapp/config"
)
func main() {
cfg := config.GetConfig()
fmt.Printf("Starting %s on port %d\n", cfg.AppName, cfg.Port)
// Memanggil lagi menghasilkan instance yang sama persis
cfg2 := config.GetConfig()
fmt.Println(cfg == cfg2) // true — pointer yang identik
}
Mengapa sync.Once adalah Pilihan Tepat
#
sync.Once bukan sekadar mutex dengan cara penulisan yang berbeda. Ada beberapa sifat penting yang membuatnya ideal untuk Singleton:
// Tanpa sync.Once — implementasi manual dengan mutex (lebih verbose, rawan bug)
var (
instance *Config
mu sync.Mutex
)
func GetConfig() *Config {
mu.Lock()
defer mu.Unlock()
if instance == nil {
instance = &Config{} // lock setiap kali dipanggil — tidak efisien
}
return instance
}
// Dengan sync.Once — bersih dan efisien
var once sync.Once
func GetConfig() *Config {
once.Do(func() {
instance = &Config{} // hanya dieksekusi sekali, tanpa lock setelahnya
})
return instance
}
Keunggulan sync.Once dibanding manual mutex:
| Aspek | Manual Mutex | sync.Once |
|---|---|---|
| Lock setelah inisialisasi | Setiap panggilan ter-lock | Tidak ada lock setelah init |
| Risiko lupa unlock | Ada | Tidak ada |
| Keterbacaan kode | Verbose | Minimal |
| Jaminan “tepat satu kali” | Harus diimplementasikan sendiri | Built-in |
| Thread-safety | Tergantung implementasi | Dijamin oleh standard library |
Singleton untuk Use Case Nyata #
Tiga use case paling umum Singleton di aplikasi produksi adalah database connection pool, logger, dan metrics client. Setiap satu punya karakteristik berbeda yang membuat Singleton menjadi pilihan tepat.
Database Connection Pool #
package database
import (
"database/sql"
"sync"
"time"
_ "github.com/lib/pq"
)
type DBPool struct {
db *sql.DB
}
var (
pool *DBPool
once sync.Once
)
// GetPool mengembalikan connection pool yang sudah dikonfigurasi.
// Pool hanya dibuat sekali dan digunakan bersama seluruh aplikasi.
func GetPool(dsn string) (*DBPool, error) {
var initErr error
once.Do(func() {
db, err := sql.Open("postgres", dsn)
if err != nil {
initErr = err
return
}
// Konfigurasi pool — ini yang membuat pool efisien
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
if err = db.Ping(); err != nil {
initErr = err
return
}
pool = &DBPool{db: db}
})
if initErr != nil {
return nil, initErr
}
return pool, nil
}
// Query menjalankan query menggunakan connection dari pool.
func (p *DBPool) Query(query string, args ...interface{}) (*sql.Rows, error) {
return p.db.Query(query, args...)
}
Logger Terpusat #
package logger
import (
"log/slog"
"os"
"sync"
)
type Logger struct {
handler *slog.Logger
}
var (
log *Logger
once sync.Once
)
// Get mengembalikan instance Logger yang digunakan seluruh aplikasi.
func Get() *Logger {
once.Do(func() {
handler := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
log = &Logger{handler: handler}
})
return log
}
func (l *Logger) Info(msg string, args ...any) {
l.handler.Info(msg, args...)
}
func (l *Logger) Error(msg string, args ...any) {
l.handler.Error(msg, args...)
}
Penggunaan dari berbagai bagian aplikasi:
// Di handler HTTP
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
logger.Get().Info("fetching user", "user_id", r.PathValue("id"))
// ...
}
// Di repository
func (r *UserRepository) Save(user *User) error {
logger.Get().Info("saving user", "email", user.Email)
// ...
}
// Keduanya menulis ke logger yang sama persis — tidak ada log yang hilang
Alur Lifecycle Singleton #
Memahami kapan instance dibuat dan bagaimana ia hidup selama aplikasi berjalan penting untuk menghindari masalah pada skenario seperti graceful shutdown atau testing.
stateDiagram-v2
[*] --> Uninitialized : Aplikasi start
Uninitialized --> Initializing : Pertama kali GetInstance() dipanggil
Initializing --> Ready : once.Do selesai
Ready --> Ready : GetInstance() dipanggil lagi\n(mengembalikan instance yang sama)
Ready --> [*] : Aplikasi shutdown
note right of Initializing
Goroutine lain yang memanggil
GetInstance() pada saat ini
akan menunggu sampai
once.Do selesai
end note
Beberapa hal penting tentang lifecycle ini:
- Instance tidak dibuat saat aplikasi start — ia dibuat lazy, saat pertama kali dibutuhkan
- Semua goroutine yang memanggil
GetInstance()saat inisialisasi sedang berlangsung akan menunggu sampaionce.Doselesai — mereka tidak akan membuat instance baru - Instance hidup sampai aplikasi shutdown — tidak ada mekanisme bawaan untuk me-reset atau menghapus instance di tengah jalan (yang membuat testing lebih sulit, seperti yang akan dibahas kemudian)
Kapan Menggunakan dan Kapan Tidak #
Singleton bukan solusi universal. Salah satu kesalahan terbesar developer adalah menggunakannya terlalu luas. Panduan berikut membantu kamu memutuskan:
GUNAKAN Singleton jika:
✓ Resource mahal untuk dibuat (koneksi DB, HTTP client dengan pool)
✓ State harus konsisten dan identik di seluruh aplikasi
✓ Objek bersifat stateless atau state-nya memang shared secara global (config, logger)
✓ Hanya ada satu sumber fisik yang direpresentasikan (satu DB server, satu file log)
✓ Aplikasi single-process (bukan distributed system)
HINDARI Singleton jika:
✗ Hanya alasan "supaya mudah diakses dari mana saja" — gunakan DI
✗ Objek menyimpan state yang sering berubah dan tidak boleh shared
✗ Kamu perlu membuat multiple instance di konteks berbeda (misalnya, testing)
✗ Objek merepresentasikan business logic, bukan infrastructure
✗ Tim belum sepakat tentang lifecycle object tersebut
Singleton di Distributed System
Singleton menjamin satu instance per process. Jika aplikasi kamu di-deploy sebagai beberapa instance (horizontal scaling, Kubernetes pods), masing-masing process punya Singleton-nya sendiri. Untuk state yang benar-benar global di seluruh sistem terdistribusi, kamu membutuhkan solusi eksternal seperti Redis, database terpusat, atau distributed lock.
Singleton vs Dependency Injection #
Perdebatan “Singleton vs DI” adalah salah satu diskusi paling sering muncul di software engineering. Kenyataannya, keduanya bukan musuh — mereka saling melengkapi jika digunakan dengan benar.
flowchart TD
subgraph SG1[Singleton Global Access]
S1[Module A] -->|"GetDB()"| SI[Singleton Instance]
S2[Module B] -->|"GetDB()"| SI
S3[Module C] -->|"GetDB()"| SI
end
subgraph SG2[Dependency Injection]
DI[DI Container] -->|"inject db"| D1[Module A]
DI -->|"inject db"| D2[Module B]
DI -->|"inject db"| D3[Module C]
end
Perbandingan karakteristik keduanya:
| Aspek | Singleton | Dependency Injection |
|---|---|---|
| Cara akses | GetInstance() dari mana saja |
Diterima sebagai parameter/constructor |
| Ketergantungan | Implisit, tersembunyi | Eksplisit, terlihat di signature |
| Testability | Sulit — sulit di-mock | Mudah — tinggal inject mock |
| Fleksibilitas | Rendah — satu instance untuk semua | Tinggi — bisa inject implementasi berbeda |
| Boilerplate | Minimal | Lebih banyak setup |
| Cocok untuk | Infrastructure (DB, Logger, Config) | Business logic, service layer |
Rekomendasi praktis: gunakan Singleton untuk infrastructure layer (database pool, logger, config, metrics), dan gunakan Dependency Injection untuk business logic layer (service, repository, use case). Keduanya bisa hidup berdampingan dalam satu aplikasi yang dirancang dengan baik.
// Pola yang direkomendasikan: Singleton di lapisan infrastruktur,
// DI di lapisan business logic
// Infrastructure — Singleton OK
func NewUserRepository() *UserRepository {
return &UserRepository{
db: database.GetPool(), // Singleton
log: logger.Get(), // Singleton
}
}
// Business logic — gunakan DI, bukan Singleton
type UserService struct {
repo UserRepositoryInterface // interface, bukan concrete Singleton
}
func NewUserService(repo UserRepositoryInterface) *UserService {
return &UserService{repo: repo}
}
Dengan pendekatan ini, business logic tetap testable — kamu bisa inject mock UserRepository saat testing UserService tanpa menyentuh Singleton.
Testing Singleton #
Salah satu kelemahan Singleton yang sering dikeluhkan adalah sulitnya testing. Instance yang sama terus hidup antara satu test dan test berikutnya, menyebabkan test saling mempengaruhi.
Ada beberapa pendekatan untuk mengatasinya:
Pendekatan 1: Interface sebagai Abstraksi #
Alih-alih mengekspos struct konkret, ekspos interface. Test bisa menggunakan implementasi berbeda.
// Definisikan interface
type ConfigProvider interface {
GetAppName() string
GetPort() int
GetDatabaseDSN() string
}
// Implementasi produksi — Singleton
type prodConfig struct {
appName string
port int
databaseDSN string
}
var (
cfg *prodConfig
once sync.Once
)
func GetConfig() ConfigProvider {
once.Do(func() {
cfg = &prodConfig{
appName: os.Getenv("APP_NAME"),
port: 8080,
databaseDSN: os.Getenv("DATABASE_DSN"),
}
})
return cfg
}
func (c *prodConfig) GetAppName() string { return c.appName }
func (c *prodConfig) GetPort() int { return c.port }
func (c *prodConfig) GetDatabaseDSN() string { return c.databaseDSN }
Saat testing, kamu bisa membuat mockConfig yang mengimplementasikan interface yang sama tanpa perlu menyentuh Singleton:
// Dalam test file
type mockConfig struct{}
func (m *mockConfig) GetAppName() string { return "TestApp" }
func (m *mockConfig) GetPort() int { return 9999 }
func (m *mockConfig) GetDatabaseDSN() string { return "sqlite://test.db" }
func TestUserService(t *testing.T) {
cfg := &mockConfig{}
svc := NewUserService(cfg) // inject mock, tidak menyentuh Singleton
// test...
}
Pendekatan 2: Reset untuk Testing (Dengan Hati-hati) #
Jika kamu membutuhkan Singleton yang bisa di-reset antar test, kamu bisa mengekspos fungsi reset — tetapi hanya untuk keperluan testing:
package config
import "sync"
var (
instance *Config
once sync.Once
)
func GetConfig() *Config {
once.Do(func() {
instance = &Config{}
})
return instance
}
// resetForTesting hanya untuk digunakan dalam test.
// Tidak boleh dipanggil di production code.
func resetForTesting() {
instance = nil
once = sync.Once{}
}
// Dalam _test.go file
func TestGetConfig(t *testing.T) {
defer resetForTesting() // cleanup setelah test selesai
cfg := GetConfig()
// assert...
}
Jangan Ekspos Reset ke Production
Fungsi reset seperti
resetForTesting()tidak boleh masuk ke production code. Jika kamu merasa perlu me-reset Singleton di production, itu sinyal kuat bahwa Singleton bukan pilihan yang tepat untuk use case tersebut.
Kesalahan Umum #
Memahami kesalahan yang sering terjadi lebih efektif daripada sekadar membaca aturan. Berikut kesalahan paling umum beserta solusinya:
// ✗ Kesalahan 1: Menyimpan mutable state yang sering berubah
type AppState struct {
CurrentUser *User // state yang berubah-ubah
ActiveJobs []Job // tidak cocok untuk Singleton
TempData map[string]interface{}
}
// ✓ Solusi: Singleton hanya untuk konfigurasi dan resource, bukan application state
type AppConfig struct {
AppName string // dibaca satu kali, tidak berubah
Port int
}
// ✗ Kesalahan 2: Singleton sebagai pengganti parameter
func ProcessOrder(orderID string) error {
db := database.GetDB() // dependency tersembunyi
cfg := config.GetConfig() // dependency tersembunyi
log := logger.Get() // dependency tersembunyi
// Fungsi ini sulit di-test karena semua dependency tersembunyi
}
// ✓ Solusi: Gunakan DI untuk business logic
type OrderProcessor struct {
db DBInterface
cfg ConfigProvider
log LoggerInterface
}
func (p *OrderProcessor) Process(orderID string) error {
// dependency eksplisit, mudah di-test
}
// ✗ Kesalahan 3: Membuat Singleton untuk setiap service
var (
userServiceInstance *UserService
productServiceInstance *ProductService
orderServiceInstance *OrderService
// ... dan seterusnya
)
// ✓ Solusi: Service bukan Singleton — gunakan DI container atau wire
func NewUserService(repo UserRepo) *UserService {
return &UserService{repo: repo}
}
// ✗ Kesalahan 4: Singleton tanpa thread safety
var configInstance *Config
func GetConfig() *Config {
if configInstance == nil { // race condition di sini
configInstance = loadConfig()
}
return configInstance
}
// ✓ Solusi: Selalu gunakan sync.Once
var (
configInstance *Config
configOnce sync.Once
)
func GetConfig() *Config {
configOnce.Do(func() {
configInstance = loadConfig()
})
return configInstance
}
Checklist Review Singleton #
Gunakan checklist ini sebelum mengimplementasikan Singleton di project kamu:
KEBUTUHAN:
□ Ada alasan kuat mengapa harus ada tepat satu instance
□ Bukan karena "supaya mudah diakses" — itu alasan yang salah
□ Resource mahal untuk dibuat, atau state memang harus shared
IMPLEMENTASI:
□ Menggunakan sync.Once untuk thread safety
□ Variable instance bersifat privat (lowercase)
□ Hanya ada satu fungsi getter publik
□ Tidak ada cara lain untuk membuat instance dari luar package
STATE:
□ State bersifat read-only setelah inisialisasi, atau
□ State memang dirancang untuk shared (dan ada mekanisme locking yang benar)
□ Tidak menyimpan application state yang sering berubah
TESTABILITY:
□ Ada interface yang mengabstraksi Singleton
□ Business logic menerima interface melalui DI, bukan akses Singleton langsung
□ Ada mekanisme untuk testing tanpa side effect antar test case
LIFECYCLE:
□ Jelas kapan Singleton ini diinisialisasi
□ Ada handling untuk error inisialisasi
□ Jelas apa yang terjadi saat aplikasi shutdown (close connection, flush buffer)
Ringkasan #
- Singleton menjamin satu instance selama lifecycle aplikasi dan menyediakan satu titik akses terkontrol — bukan sekadar variabel global.
sync.Onceadalah pilihan standar di Golang untuk implementasi thread-safe tanpa overhead locking setelah inisialisasi selesai.- Cocok untuk infrastructure layer — database pool, logger, config loader, metrics client; objek yang secara fundamental hanya boleh ada satu.
- Bukan untuk business logic — service dan repository sebaiknya menggunakan Dependency Injection agar tetap testable dan fleksibel.
- Interface sebagai abstraksi — ekspos interface, bukan struct konkret, agar kode yang bergantung pada Singleton tetap bisa di-test dengan mock.
- Lazy initialization — instance tidak dibuat saat aplikasi start, melainkan saat pertama kali dibutuhkan; ini menghemat resource untuk komponen yang mungkin tidak selalu digunakan.
- Hati-hati dengan distributed system — Singleton hanya berlaku per process; untuk state global di sistem terdistribusi, butuh solusi eksternal.
- Test dengan reset atau interface — jika sulit di-test, itu sinyal bahwa Singleton bukan pilihan yang tepat, atau perlu diabstraksi lebih baik.