Design Pattern Essential: Singleton, Factory, dan Observer dalam Praktik

Ditulis oleh: nopal9 Agustus 20260 Views

Design Pattern Essential: Singleton, Factory, dan Observer dalam Praktik

Setiap developer pasti pernah menghadapi masalah yang terasa familiar: bagaimana memastikan hanya ada satu instance dari sebuah koneksi database di seluruh aplikasi? Bagaimana membuat objek tanpa harus tahu detail implementasi konkretnya? Bagaimana memberi tahu banyak bagian sistem ketika sebuah event terjadi, tanpa membuat semuanya saling bergantung erat? Design pattern hadir sebagai jawaban atas pertanyaan-pertanyaan semacam ini — bukan sebagai teori abstrak, tapi sebagai solusi yang sudah teruji dan dipakai berulang kali oleh developer di seluruh dunia.

Dalam artikel ini kita akan membahas tiga design pattern yang paling sering muncul dalam kode sehari-hari: Singleton, Factory, dan Observer. Ketiganya berasal dari kategori yang berbeda, punya use case yang jelas, dan — yang tidak kalah penting — punya jebakan yang perlu dihindari agar tidak berujung pada over-engineering.

Apa Itu Design Pattern?

Design pattern adalah solusi umum dan dapat digunakan kembali (reusable) untuk masalah yang berulang kali muncul dalam desain perangkat lunak berorientasi objek. Penting dicatat, design pattern bukan kode siap pakai yang bisa langsung disalin-tempel, melainkan template atau deskripsi tentang bagaimana menyelesaikan masalah tertentu yang bisa diadaptasi sesuai konteks bahasa pemrograman dan kebutuhan proyek.

Sejarah Singkat: Gang of Four

Istilah "design pattern" dalam konteks software engineering dipopulerkan oleh buku Design Patterns: Elements of Reusable Object-Oriented Software yang terbit tahun 1994, ditulis oleh Erich Gamma, Richard Helm, Ralph Johnson, dan John Vlissides — yang kemudian dikenal secara kolektif sebagai "Gang of Four" (GoF). Buku ini mendokumentasikan 23 pattern klasik yang dikelompokkan ke dalam tiga kategori besar, dan hingga hari ini masih menjadi rujukan utama meskipun sudah berusia lebih dari tiga dekade.

Tiga Kategori Design Pattern

  • Creational Patterns — berfokus pada cara pembuatan objek, menyembunyikan logika instansiasi agar sistem tidak bergantung pada cara spesifik objek dibuat. Contoh: Singleton, Factory Method, Abstract Factory, Builder, Prototype.
  • Structural Patterns — berfokus pada bagaimana class dan objek disusun untuk membentuk struktur yang lebih besar. Contoh: Adapter, Decorator, Facade, Composite, Proxy.
  • Behavioral Patterns — berfokus pada bagaimana objek berkomunikasi dan berbagi tanggung jawab satu sama lain. Contoh: Observer, Strategy, Command, State, Iterator.

Singleton dan Factory termasuk kategori Creational, sementara Observer termasuk kategori Behavioral. Mari kita bahas satu per satu secara mendalam.

Singleton Pattern

Singleton adalah pattern yang memastikan sebuah class hanya memiliki satu instance sepanjang siklus hidup aplikasi, dan menyediakan satu titik akses global ke instance tersebut. Pattern ini sering dipakai untuk hal-hal seperti koneksi database, konfigurasi aplikasi, logger, atau connection pool, di mana membuat banyak instance justru bisa menyebabkan pemborosan resource atau bahkan bug yang sulit dilacak.

Implementasi Singleton

Berikut contoh implementasi Singleton dalam JavaScript modern menggunakan module pattern, yang secara alami memanfaatkan sifat module di Node.js/ES Modules yang hanya di-load sekali:

class DatabaseConnection {
  static #instance = null;
  #connection = null;

  constructor() {
    if (DatabaseConnection.#instance) {
      throw new Error("Gunakan DatabaseConnection.getInstance()");
    }
    this.#connection = this.#connect();
  }

  #connect() {
    console.log("Membuka koneksi baru ke database...");
    return { status: "connected", id: Date.now() };
  }

  static getInstance() {
    if (!DatabaseConnection.#instance) {
      DatabaseConnection.#instance = new DatabaseConnection();
    }
    return DatabaseConnection.#instance;
  }

  query(sql) {
    console.log(`Menjalankan query: ${sql}`);
    return this.#connection;
  }
}

const db1 = DatabaseConnection.getInstance();
const db2 = DatabaseConnection.getInstance();
console.log(db1 === db2); // true, keduanya instance yang sama

Di Python, pattern serupa bisa diimplementasikan dengan memanfaatkan __new__:

class ConfigManager:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance._settings = {}
        return cls._instance

    def set(self, key, value):
        self._settings[key] = value

    def get(self, key):
        return self._settings.get(key)


config1 = ConfigManager()
config2 = ConfigManager()
print(config1 is config2)  # True

Kontroversi Seputar Singleton

Meskipun populer, Singleton adalah salah satu pattern yang paling banyak dikritik di kalangan praktisi software engineering modern. Beberapa alasannya:

  • Global state tersembunyi — Singleton pada dasarnya adalah variabel global yang dibungkus dengan class, yang bisa membuat alur data sulit dilacak dan menciptakan dependency tersembunyi antar modul.
  • Sulit diuji — karena state-nya persisten sepanjang aplikasi berjalan, unit test bisa saling memengaruhi satu sama lain jika tidak di-reset dengan benar antar test case.
  • Melanggar Single Responsibility Principle — class Singleton sering kali bertanggung jawab atas dua hal: logika bisnisnya sendiri, dan logika mengontrol instansiasinya sendiri.
  • Masalah di lingkungan concurrent — pada aplikasi multi-threaded, implementasi Singleton yang naif bisa menciptakan race condition saat instance pertama kali dibuat.

Alternatif yang lebih disukai di banyak framework modern adalah Dependency Injection, di mana instance tunggal tetap dipertahankan (misalnya sebagai "singleton service" di NestJS atau Spring), tapi dikelola oleh container DI, bukan oleh class itu sendiri. Dengan begitu, class tidak perlu tahu tentang mekanisme instansiasinya sendiri, dan jauh lebih mudah diganti dengan mock saat testing.

Factory Pattern

Factory pattern menyediakan cara untuk membuat objek tanpa harus mengekspos logika pembuatannya secara langsung ke kode pemanggil (client code). Alih-alih memanggil new SomeClass() secara langsung di banyak tempat, kode pemanggil memanggil sebuah factory function atau factory method yang menentukan class konkret mana yang harus diinstansiasi.

Simple Factory vs Factory Method vs Abstract Factory

Sering terjadi kebingungan karena sebenarnya ada beberapa variasi pattern ini:

  • Simple Factory — bukan pattern resmi dari GoF, tapi idiom yang sangat umum: satu fungsi atau class dengan method statis yang menentukan objek mana yang dibuat berdasarkan parameter.
  • Factory Method — pattern resmi GoF di mana subclass menentukan class konkret mana yang diinstansiasi, dengan mendefinisikan ulang sebuah method di parent class.
  • Abstract Factory — menyediakan interface untuk membuat keluarga objek yang saling berkaitan tanpa menyebutkan class konkretnya, biasanya melibatkan beberapa factory method sekaligus.

Contoh Simple Factory

class CreditCardPayment {
  process(amount) {
    console.log(`Memproses pembayaran kartu kredit sebesar Rp${amount}`);
  }
}

class BankTransferPayment {
  process(amount) {
    console.log(`Memproses transfer bank sebesar Rp${amount}`);
  }
}

class EWalletPayment {
  process(amount) {
    console.log(`Memproses e-wallet sebesar Rp${amount}`);
  }
}

class PaymentFactory {
  static create(type) {
    switch (type) {
      case "credit_card":
        return new CreditCardPayment();
      case "bank_transfer":
        return new BankTransferPayment();
      case "e_wallet":
        return new EWalletPayment();
      default:
        throw new Error(`Metode pembayaran tidak dikenal: ${type}`);
    }
  }
}

// Kode pemanggil tidak perlu tahu class konkretnya
const payment = PaymentFactory.create("e_wallet");
payment.process(150000);

Keuntungan pola ini terlihat jelas: jika suatu hari perlu menambah metode pembayaran baru, misalnya QRIS, kode pemanggil (client code) tidak perlu diubah sama sekali. Cukup tambahkan satu class baru dan satu case di factory. Ini sejalan dengan prinsip Open/Closed dari SOLID — terbuka untuk ekstensi, tertutup untuk modifikasi.

Contoh Factory Method di Python

from abc import ABC, abstractmethod

class NotificationSender(ABC):
    @abstractmethod
    def send(self, message: str) -> None:
        pass

class EmailSender(NotificationSender):
    def send(self, message: str) -> None:
        print(f"Mengirim email: {message}")

class SmsSender(NotificationSender):
    def send(self, message: str) -> None:
        print(f"Mengirim SMS: {message}")

class NotificationCreator(ABC):
    @abstractmethod
    def create_sender(self) -> NotificationSender:
        pass

    def notify(self, message: str) -> None:
        sender = self.create_sender()
        sender.send(message)

class EmailNotificationCreator(NotificationCreator):
    def create_sender(self) -> NotificationSender:
        return EmailSender()

class SmsNotificationCreator(NotificationCreator):
    def create_sender(self) -> NotificationSender:
        return SmsSender()

creator = SmsNotificationCreator()
creator.notify("Pesanan Anda telah dikirim")

Observer Pattern

Observer pattern mendefinisikan relasi satu-ke-banyak antara objek, di mana ketika satu objek (disebut "subject" atau "publisher") berubah state, semua objek yang bergantung padanya (disebut "observer" atau "subscriber") akan diberi tahu dan diperbarui secara otomatis. Pattern ini menjadi dasar dari banyak sistem event-driven, termasuk event listener di browser, sistem pub/sub, dan reactive programming.

Implementasi Observer

class EventEmitter {
  #listeners = new Map();

  on(eventName, callback) {
    if (!this.#listeners.has(eventName)) {
      this.#listeners.set(eventName, []);
    }
    this.#listeners.get(eventName).push(callback);
  }

  off(eventName, callback) {
    const callbacks = this.#listeners.get(eventName) || [];
    this.#listeners.set(
      eventName,
      callbacks.filter((cb) => cb !== callback)
    );
  }

  emit(eventName, payload) {
    const callbacks = this.#listeners.get(eventName) || [];
    for (const callback of callbacks) {
      callback(payload);
    }
  }
}

class OrderService extends EventEmitter {
  createOrder(order) {
    console.log(`Order ${order.id} dibuat`);
    this.emit("order.created", order);
  }
}

const orderService = new OrderService();

// Observer 1: kirim notifikasi email
orderService.on("order.created", (order) => {
  console.log(`[Email] Mengirim konfirmasi untuk order ${order.id}`);
});

// Observer 2: catat ke sistem analitik
orderService.on("order.created", (order) => {
  console.log(`[Analytics] Mencatat order ${order.id}`);
});

orderService.createOrder({ id: "ORD-001" });

Dengan pattern ini, OrderService tidak perlu tahu apa pun tentang sistem email atau analitik. Ia hanya perlu mengumumkan bahwa sebuah event terjadi, dan siapa pun yang berkepentingan bisa mendaftar untuk mendengarkan event tersebut. Ini mengurangi coupling secara drastis dan membuat sistem lebih mudah dikembangkan — menambah observer baru tidak memerlukan perubahan apa pun di subject.

Observer dalam Kehidupan Nyata Developer

Anda mungkin sudah memakai Observer pattern setiap hari tanpa sadar:

  • addEventListener di DOM browser adalah implementasi Observer pattern.
  • RxJS dan reactive programming secara keseluruhan dibangun di atas konsep Observable/Observer.
  • Sistem pub/sub seperti Redis Pub/Sub, Kafka, atau RabbitMQ menerapkan prinsip yang sama di level arsitektur terdistribusi.
  • State management library seperti Redux atau Vuex menggunakan pattern serupa: komponen "subscribe" ke perubahan state.
  • Webhook adalah implementasi Observer pattern di level HTTP antar sistem yang berbeda.

Mengombinasikan Ketiga Pattern dalam Aplikasi Nyata

Dalam praktiknya, ketiga pattern ini sering dipakai bersamaan dalam satu sistem. Bayangkan sebuah aplikasi e-commerce sederhana:

  • Singleton dipakai untuk connection pool database dan konfigurasi aplikasi yang dibaca dari environment variable.
  • Factory dipakai untuk membuat instance payment gateway yang berbeda-beda (Midtrans, Xendit, Stripe) berdasarkan konfigurasi tenant.
  • Observer dipakai agar ketika status pembayaran berubah menjadi "sukses", banyak modul terpisah (inventori, notifikasi, laporan keuangan) bisa bereaksi secara independen tanpa saling mengetahui satu sama lain.

Kombinasi ini menunjukkan bahwa design pattern bukan pilihan eksklusif — mereka adalah alat yang bisa dipadukan sesuai kebutuhan masalah yang dihadapi.

Kapan Sebaiknya TIDAK Menggunakan Design Pattern

Salah satu jebakan terbesar bagi developer yang baru belajar design pattern adalah kecenderungan untuk memaksakan pattern ke masalah yang sebenarnya tidak membutuhkannya — fenomena ini sering disebut "pattern-itis" atau over-engineering. Beberapa tanda bahwa Anda mungkin over-engineering:

  • Anda membuat Factory untuk class yang hanya punya satu implementasi dan tidak ada rencana untuk menambah yang lain.
  • Anda menggunakan Singleton hanya karena "praktik terbaik", padahal dependency injection sederhana sudah cukup dan lebih mudah diuji.
  • Anda membuat sistem Observer yang rumit untuk komunikasi antara dua komponen yang sebenarnya bisa langsung memanggil fungsi satu sama lain.
  • Kode menjadi lebih sulit ditelusuri karena terlalu banyak lapisan abstraksi untuk masalah yang sebenarnya sederhana.

Prinsip yang baik untuk dipegang: gunakan pattern ketika masalah nyata muncul dan kompleksitasnya memang membutuhkan solusi terstruktur, bukan karena "kelihatannya profesional". Kode yang sederhana dan langsung sering kali jauh lebih baik daripada kode yang penuh abstraksi tapi tidak dibutuhkan.

Tabel Perbandingan Singkat

PatternKategoriTujuan UtamaContoh Use Case
SingletonCreationalMemastikan satu instance sajaConnection pool, konfigurasi global
FactoryCreationalMenyembunyikan logika pembuatan objekPayment gateway, parser file
ObserverBehavioralNotifikasi otomatis ke banyak listenerEvent system, pub/sub, UI reactive

Strategy Pattern: Pattern Bonus yang Sering Dipasangkan dengan Factory

Selain tiga pattern utama di atas, ada satu pattern behavioral lain yang sangat sering muncul berdampingan dengan Factory dalam kode nyata: Strategy pattern. Strategy mendefinisikan sekumpulan algoritma yang bisa dipertukarkan (interchangeable), membungkus masing-masing ke dalam class terpisah, sehingga algoritma bisa diganti saat runtime tanpa mengubah kode yang menggunakannya.

Kombinasi Factory dan Strategy sangat umum: Factory bertugas membuat strategi yang tepat berdasarkan kondisi tertentu, sementara Strategy mendefinisikan bagaimana masing-masing algoritma tersebut bekerja.

class FlatShippingStrategy {
  calculate(order) {
    return 15000;
  }
}

class WeightBasedShippingStrategy {
  calculate(order) {
    return order.weightInKg * 8000;
  }
}

class FreeShippingStrategy {
  calculate(order) {
    return 0;
  }
}

class ShippingStrategyFactory {
  static create(order) {
    if (order.total >= 500000) return new FreeShippingStrategy();
    if (order.weightInKg > 5) return new WeightBasedShippingStrategy();
    return new FlatShippingStrategy();
  }
}

function calculateShippingCost(order) {
  const strategy = ShippingStrategyFactory.create(order);
  return strategy.calculate(order);
}

Perhatikan betapa mudahnya menambah aturan ongkos kirim baru di masa depan: cukup buat class strategi baru dan tambahkan kondisi di factory, tanpa perlu menyentuh fungsi calculateShippingCost sama sekali. Inilah kekuatan sesungguhnya dari mengombinasikan beberapa design pattern secara tepat — masing-masing menyelesaikan satu aspek masalah dengan rapi.

Studi Kasus: Refactoring Sistem Notifikasi dengan Ketiga Pattern

Untuk melihat bagaimana ketiga pattern ini bekerja bersama dalam skenario yang lebih realistis, mari bayangkan sebuah sistem notifikasi pada aplikasi e-commerce yang awalnya ditulis dengan cara yang berantakan — satu fungsi raksasa yang mengecek tipe notifikasi dengan banyak if-else, langsung memanggil SDK pihak ketiga, dan tidak bisa diuji secara terpisah.

Setelah refactoring dengan menerapkan ketiga pattern:

  • NotificationConfig diimplementasikan sebagai Singleton, menyimpan API key dan endpoint layanan pihak ketiga yang dibaca sekali dari environment variable saat aplikasi start.
  • NotificationChannelFactory menentukan channel mana (email, SMS, push notification, WhatsApp) yang harus dipakai berdasarkan preferensi pengguna, tanpa kode pemanggil perlu tahu detail SDK masing-masing provider.
  • OrderEventEmitter menerapkan Observer pattern, sehingga saat status order berubah, seluruh channel notifikasi yang relevan otomatis terpicu tanpa modul order harus tahu detail implementasi notifikasi.

Hasilnya, kode menjadi jauh lebih mudah diuji — setiap komponen bisa diuji terpisah dengan mock, menambah channel notifikasi baru tidak memerlukan perubahan di modul order, dan konfigurasi terpusat di satu tempat yang jelas. Studi kasus sederhana ini menggambarkan bagaimana pemahaman mendalam tentang design pattern langsung berdampak pada maintainability kode dalam proyek nyata, bukan sekadar latihan akademis di atas kertas.

Tips Praktis Memilih Pattern yang Tepat

Bagi developer yang baru mulai berkenalan dengan design pattern, berikut beberapa panduan praktis yang bisa dipakai sebagai pegangan sehari-hari saat menghadapi masalah desain:

  • Jika Anda menemukan diri Anda menulis new SomeClass() di banyak tempat berbeda, dan class konkret mana yang dipakai bergantung pada kondisi tertentu, itu tanda kuat untuk mempertimbangkan Factory.
  • Jika sebuah resource benar-benar hanya boleh ada satu (misalnya satu connection pool, satu logger global) dan Anda sudah mempertimbangkan dependency injection tapi tetap membutuhkan kontrol instansiasi tunggal, Singleton (idealnya dikelola lewat DI container) bisa dipakai.
  • Jika satu perubahan pada satu bagian sistem perlu memicu reaksi di banyak bagian lain yang tidak saling terkait erat, dan Anda ingin menghindari pemanggilan langsung yang membuat coupling tinggi, Observer adalah pilihan alami.
  • Selalu tanyakan pada diri sendiri: "apakah masalah ini benar-benar butuh abstraksi tambahan, atau saya hanya mengikuti pattern karena terlihat keren?" Jawaban jujur atas pertanyaan ini akan menyelamatkan Anda dari over-engineering.
  • Baca kode open-source yang sudah matang (misalnya library populer di ekosistem yang Anda pakai) untuk melihat bagaimana pattern-pattern ini diterapkan dalam konteks nyata, bukan hanya dari contoh buku teks.

Kesimpulan

Singleton, Factory, dan Observer adalah tiga pilar design pattern yang akan terus Anda temui sepanjang karier sebagai software engineer, baik secara eksplisit maupun tersembunyi di dalam framework yang Anda pakai sehari-hari. Singleton membantu mengontrol jumlah instance sebuah class, Factory menyembunyikan kompleksitas pembuatan objek, dan Observer memungkinkan komunikasi longgar antar komponen melalui mekanisme event.

Yang perlu selalu diingat, design pattern adalah alat, bukan tujuan. Pattern yang tepat bisa membuat kode jauh lebih fleksibel dan mudah dikembangkan, tapi pattern yang dipaksakan justru menambah kompleksitas tanpa manfaat nyata. Pelajari masalah yang ingin Anda selesaikan terlebih dahulu, lalu pilih pattern yang paling sesuai — bukan sebaliknya. Dengan pemahaman yang matang tentang kapan dan mengapa sebuah pattern dipakai, Anda akan menulis kode yang tidak hanya berfungsi, tapi juga mudah dipahami dan dikembangkan oleh tim di masa depan.

nopal

Ditulis oleh

@nopal

Advertisement

Ruang Iklan Tersedia

Hubungi Admin untuk menempatkan banner iklan atau Adsense Anda di sini.

Bagaimana pendapat Anda tentang artikel ini?

Komentar Pembaca (0)

Tinggalkan Komentar

Belum ada komentar. Jadilah yang pertama!