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 saat instance masih nil, keduanya akan masuk ke blok if instance == nil secara 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 sampai once.Do selesai — 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.Once adalah 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.

← Sebelumnya: Pendahuluan   Berikutnya: Factory Method →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact