SOLID #

Lima huruf yang membedakan kode yang bisa bertumbuh sehat dari kode yang makin lama makin sulit diubah. SOLID adalah kumpulan prinsip desain yang diperkenalkan Robert C. Martin (Uncle Bob) — bukan aturan kaku, melainkan panduan yang membantu menjawab satu pertanyaan mendasar: bagaimana menulis kode yang mudah dipahami, mudah diuji, dan tahan terhadap perubahan? Meski berasal dari dunia OOP klasik, prinsip-prinsip ini sangat relevan untuk Go dan Dart karena keduanya mendukung interface, composition, dan dependency injection. Panduan ini membahas kelima prinsip secara mendalam — masing-masing dengan anti-pattern konkret, solusi yang benar, dan penjelasan mengapa itu penting — ditutup dengan cara kelima prinsip bekerja bersama, konteks kapan SOLID harus diterapkan, dan kapan tidak.

Mengapa SOLID Penting? #

Tanpa prinsip desain yang konsisten, codebase yang berkembang cenderung menuju satu arah: semakin sulit diubah. Perubahan kecil di satu tempat berdampak besar di tempat yang tidak terduga. Unit test membutuhkan setup database nyata. Menambahkan fitur baru berarti memodifikasi kode lama yang sudah berjalan. Engineer baru butuh waktu lama memahami alurnya — bukan karena domain bisnisnya kompleks, tapi karena kode tidak mencerminkan struktur yang jelas.

SOLID menyerang masalah-masalah ini dari sisi desain, bukan dari sisi tooling atau framework:

Masalah tanpa SOLID                  → Solusi dengan SOLID
────────────────────────────────       ────────────────────────────────────
Satu class melakukan segalanya         SRP: satu tanggung jawab,
                                       satu alasan untuk berubah

Tambah fitur = ubah kode lama          OCP: extend dengan code baru,
                                       jangan modifikasi yang sudah ada

Subtype merusak behavior parent        LSP: implementasi bisa disubstitusi
                                       tanpa kejutan dan tanpa defensive check

Interface besar memaksa implementasi   ISP: interface kecil dan fokus,
yang tidak dibutuhkan                  client hanya bergantung pada
                                       yang mereka pakai

Modul tingkat tinggi bergantung        DIP: bergantung pada abstraksi,
pada detail implementasi               bukan implementasi konkret

Bersama, kelima prinsip ini membentuk fondasi desain yang memungkinkan sistem tumbuh tanpa memperburuk kualitasnya. Mari bahas satu per satu.


S — Single Responsibility Principle #

Sebuah module hanya boleh memiliki satu alasan untuk berubah.

SRP bukan tentang “satu method per class” atau “class harus kecil”. Ia tentang kohesi — semua yang ada di dalam sebuah unit harus melayani satu tujuan yang terdefinisi dengan jelas. Jika ada dua alasan berbeda yang bisa memaksa class untuk berubah, itu tanda SRP dilanggar.

Cara mudah mengidentifikasi pelanggaran SRP: tulis kalimat “Class X bertanggung jawab untuk…” — jika kalimat itu memerlukan kata “dan” untuk menyambung dua hal yang berbeda, SRP kemungkinan besar dilanggar.

// ANTI-PATTERN: OrderService melakukan terlalu banyak hal
// Jika format email berubah, OrderService harus diubah.
// Jika schema database berubah, OrderService harus diubah.
// Jika business rule diskon berubah, OrderService harus diubah.
// Tiga alasan untuk berubah = tiga tanggung jawab = SRP dilanggar.
type OrderService struct{}

func (s *OrderService) CreateOrder(order Order) error {
    // Business logic
    if order.Total < 0 {
        return errors.New("invalid total")
    }
    // Database logic — tanggung jawab repository, bukan service
    db.Exec("INSERT INTO orders VALUES (?)", order)
    // Email logic — tanggung jawab notification service
    smtp.Send(order.UserEmail, "Order confirmed: "+order.ID)
    // PDF logic — tanggung jawab report service
    pdf.Generate(order)
    return nil
}

// BENAR: setiap komponen punya satu tanggung jawab yang jelas
type OrderService struct {
    repo     OrderRepository  // tanggung jawab: persistensi data
    notifier Notifier         // tanggung jawab: notifikasi
    reporter Reporter         // tanggung jawab: generate report
}

func (s *OrderService) CreateOrder(ctx context.Context, order Order) error {
    if err := validateOrder(order); err != nil { // validasi: tanggung jawab service
        return err
    }
    if err := s.repo.Save(ctx, order); err != nil {
        return err
    }
    go s.notifier.NotifyOrderCreated(order)  // async, tidak block alur utama
    go s.reporter.GenerateReceipt(order)     // async, tidak block alur utama
    return nil
}

Perhatikan perbedaannya: di versi yang benar, jika format email berubah, hanya Notifier yang perlu dimodifikasi. Jika schema database berubah, hanya OrderRepository yang terdampak. OrderService sendiri hanya perlu berubah jika business logic pembuatan order berubah — itulah satu-satunya tanggung jawabnya.

flowchart TD
    OS["OrderService\n(business logic)"]
    R["OrderRepository\n(persistensi)"]
    N["Notifier\n(notifikasi)"]
    RP["Reporter\n(laporan)"]

    OS -->|save| R
    OS -->|notify| N
    OS -->|generate| RP

    style OS fill:#4C9BE8,color:#fff
    style R fill:#5CB85C,color:#fff
    style N fill:#F0AD4E,color:#fff
    style RP fill:#D9534F,color:#fff

SRP juga berlaku di level function, tidak hanya class. Sebuah function yang melakukan validasi, transformasi data, dan logging sekaligus dalam satu blok linier adalah pelanggaran SRP di skala mikro.


O — Open/Closed Principle #

Software entity harus terbuka untuk ekstensi, tapi tertutup untuk modifikasi.

OCP adalah tentang mendesain kode sehingga menambahkan behavior baru tidak memerlukan perubahan pada kode yang sudah ada dan sudah diuji. Ini dicapai dengan mendefinisikan abstraksi (interface) dan menambahkan implementasi baru — bukan mengubah yang lama.

Pelanggaran OCP paling umum muncul dalam bentuk switch-case atau if-else yang terus berkembang setiap kali ada kebutuhan baru:

// ANTI-PATTERN: setiap tipe diskon baru memaksa modifikasi fungsi ini
// Menambahkan tipe "Premium" berarti mengubah kode yang sudah berjalan di production.
// Setiap perubahan di sini berisiko merusak tipe diskon yang sudah ada.
func CalculateDiscount(userType string, price float64) float64 {
    switch userType {
    case "VIP":
        return price * 0.8
    case "Member":
        return price * 0.9
    case "Premium":        // ← tambahan baru mengubah kode lama
        return price * 0.85
    case "Corporate":      // ← setiap sprint ada case baru
        return price * 0.75
    default:
        return price
    }
}

// BENAR: menambahkan diskon baru = tambah struct baru saja,
// tanpa menyentuh kode yang sudah berjalan
type DiscountStrategy interface {
    Apply(price float64) float64
    Label() string
}

type VIPDiscount struct{}
func (d VIPDiscount) Apply(price float64) float64 { return price * 0.8 }
func (d VIPDiscount) Label() string               { return "VIP (20% off)" }

type MemberDiscount struct{}
func (d MemberDiscount) Apply(price float64) float64 { return price * 0.9 }
func (d MemberDiscount) Label() string               { return "Member (10% off)" }

type PremiumDiscount struct{}
func (d PremiumDiscount) Apply(price float64) float64 { return price * 0.85 }
func (d PremiumDiscount) Label() string               { return "Premium (15% off)" }

type CorporateDiscount struct{}
func (d CorporateDiscount) Apply(price float64) float64 { return price * 0.75 }
func (d CorporateDiscount) Label() string               { return "Corporate (25% off)" }

// PriceCalculator tidak pernah perlu diubah,
// meski ada 10 tipe diskon baru sekalipun
type PriceCalculator struct{}

func (c *PriceCalculator) Calculate(price float64, discount DiscountStrategy) float64 {
    if discount == nil {
        return price
    }
    return discount.Apply(price)
}

OCP sangat bersinergi dengan Dependency Injection. Ketika dependency diinjeksikan sebagai interface, mengganti implementasinya (extend) tidak memerlukan perubahan di kode yang menggunakannya (closed for modification). Ini juga yang membuat strategy pattern, decorator pattern, dan plugin system bekerja dengan baik.

flowchart LR
    PC["PriceCalculator\n(closed for modification)"]
    DS["DiscountStrategy\n(interface)"]
    V["VIPDiscount"]
    M["MemberDiscount"]
    P["PremiumDiscount"]
    C["CorporateDiscount\n(tambahan baru)"]

    PC -->|bergantung pada| DS
    V -->|implement| DS
    M -->|implement| DS
    P -->|implement| DS
    C -->|implement| DS

    style DS fill:#4C9BE8,color:#fff
    style PC fill:#5CB85C,color:#fff
    style C fill:#F0AD4E,color:#fff

Prinsip ini tidak berarti kita tidak boleh pernah mengubah kode lama. Yang dihindari adalah perubahan yang dipicu oleh penambahan kasus baru — jika kamu harus membuka file calculator.go setiap kali ada tipe pengguna baru, itu sinyal OCP dilanggar.


L — Liskov Substitution Principle #

Object turunan harus bisa menggantikan object induknya tanpa merusak kebenaran program.

LSP memastikan bahwa ketika kode berinteraksi dengan sebuah interface, semua implementasinya berperilaku sesuai kontrak yang sama. Tidak ada implementasi yang melempar exception yang tidak diharapkan, mengembalikan nilai di luar kontrak, atau mengubah semantik dari method yang diwarisi.

Pelanggaran LSP sering tidak langsung terlihat. Gejalanya adalah kode yang menggunakan interface terpaksa melakukan type assertion atau pengecekan tipe konkret untuk bisa beroperasi dengan benar:

// ANTI-PATTERN: Penguin mengimplementasikan Bird tapi melanggar kontraknya
// Kode yang memanggil bird.Fly() harus melakukan defensive programming
type Bird interface {
    Fly() error
}

type Eagle struct{}
func (e Eagle) Fly() error { return nil } // ✓ bisa terbang

type Penguin struct{}
func (p Penguin) Fly() error {
    return errors.New("penguins cannot fly") // ← melanggar kontrak Bird
    // Caller yang menggunakan interface Bird tidak mengharapkan ini
}

// Akibatnya, semua kode yang menggunakan Bird terpaksa defensive:
func makeAllFly(birds []Bird) {
    for _, bird := range birds {
        if err := bird.Fly(); err != nil {
            // "Mungkin ini penguin" — ini adalah tanda klasik LSP dilanggar
            log.Printf("bird cannot fly: %v", err)
        }
    }
}

Solusinya bukan memperbaiki Penguin agar bisa terbang — tapi mendesain ulang hierarki interface agar lebih mencerminkan kemampuan nyata:

// BENAR: pisahkan interface berdasarkan kemampuan aktual
type Animal interface {
    Eat()
    Move()
}

type FlyingAnimal interface {
    Animal
    Fly() // hanya untuk yang benar-benar bisa terbang — kontrak ini dijamin
}

type SwimmingAnimal interface {
    Animal
    Swim() // hanya untuk yang benar-benar bisa berenang — kontrak ini dijamin
}

type Eagle struct{}
func (e Eagle) Eat()  {}
func (e Eagle) Move() {}
func (e Eagle) Fly()  {} // ✓ Eagle implement FlyingAnimal

type Penguin struct{}
func (p Penguin) Eat()  {}
func (p Penguin) Move() {}
func (p Penguin) Swim() {} // ✓ Penguin implement SwimmingAnimal, bukan FlyingAnimal

// Kode yang menggunakan FlyingAnimal bisa Fly() tanpa defensive check apapun
func makeAllFly(flyers []FlyingAnimal) {
    for _, f := range flyers {
        f.Fly() // dijamin berhasil — semua implementor benar-benar bisa terbang
    }
}

// Kode yang menggunakan SwimmingAnimal tidak tahu soal terbang sama sekali
func makeAllSwim(swimmers []SwimmingAnimal) {
    for _, s := range swimmers {
        s.Swim() // dijamin berhasil
    }
}
flowchart TD
    A["Animal\n(Eat, Move)"]
    FA["FlyingAnimal\n(Animal + Fly)"]
    SA["SwimmingAnimal\n(Animal + Swim)"]
    E["Eagle\n✓ FlyingAnimal"]
    P["Penguin\n✓ SwimmingAnimal"]
    D["Duck\n✓ FlyingAnimal + SwimmingAnimal"]

    FA -->|embed| A
    SA -->|embed| A
    E -->|implement| FA
    P -->|implement| SA
    D -->|implement| FA
    D -->|implement| SA

    style A fill:#4C9BE8,color:#fff
    style FA fill:#5CB85C,color:#fff
    style SA fill:#F0AD4E,color:#fff

LSP juga berlaku di luar inheritance klasik. Dalam Go yang menggunakan implicit interface, prinsip yang sama berlaku: setiap struct yang mengklaim mengimplementasikan sebuah interface harus benar-benar memenuhi semua janji yang dibuat interface tersebut — termasuk janji implisit seperti “method ini tidak akan panic” atau “method ini tidak akan mengembalikan nil jika tidak didokumentasikan demikian”.

Tanda LSP dilanggar: Jika kamu perlu menambahkan type assertion, type switch, atau switch v := x.(type) di dalam kode yang seharusnya bekerja secara polimorfis dengan interface — itu sinyal kuat bahwa hierarki interface tidak memenuhi LSP. Kode yang menggunakan interface seharusnya tidak perlu tahu tipe konkret di baliknya.

I — Interface Segregation Principle #

Jangan memaksa client bergantung pada interface yang tidak mereka gunakan.

ISP mendorong interface yang kecil dan fokus. Di Go, prinsip ini sangat idiomatis karena Go mendukung implicit interface implementation — kamu bisa mendefinisikan interface di sisi consumer, tepat sebesar yang dibutuhkan, tanpa library harus tahu tentang interface tersebut.

Pelanggaran ISP paling mudah dikenali: ada struct yang mengimplementasikan interface besar tapi sebagian methodnya harus dibiarkan kosong atau di-panic karena memang tidak relevan:

// ANTI-PATTERN: interface yang terlalu besar memaksa implementasi yang tidak relevan
type Storage interface {
    Save(data []byte) error
    Load(id string) ([]byte, error)
    Delete(id string) error
    List(prefix string) ([]string, error)
    GetMetadata(id string) (Metadata, error)
    SetTTL(id string, ttl time.Duration) error
    Flush() error
}

// ReadOnlyCache hanya butuh Load, tapi terpaksa implement 7 method
// Jika interface ini berubah (misalnya tambah Compress()), ReadOnlyCache terkena dampak
// padahal perubahan itu sama sekali tidak relevan untuknya
type ReadOnlyCache struct{}

func (r *ReadOnlyCache) Save(data []byte) error          { return errors.New("read only") }
func (r *ReadOnlyCache) Load(id string) ([]byte, error)  { /* implementasi nyata */ return nil, nil }
func (r *ReadOnlyCache) Delete(id string) error          { return errors.New("read only") }
func (r *ReadOnlyCache) List(prefix string) ([]string, error) { return nil, errors.New("not supported") }
func (r *ReadOnlyCache) GetMetadata(id string) (Metadata, error) { return Metadata{}, nil }
func (r *ReadOnlyCache) SetTTL(id string, ttl time.Duration) error { return errors.New("not supported") }
func (r *ReadOnlyCache) Flush() error                    { return nil }

// BENAR: interface kecil, sesuai kebutuhan masing-masing consumer
type DataLoader interface {
    Load(id string) ([]byte, error)
}

type DataSaver interface {
    Save(data []byte) error
}

type DataDeleter interface {
    Delete(id string) error
}

type TTLSetter interface {
    SetTTL(id string, ttl time.Duration) error
}

// Compose interface yang lebih besar dari yang kecil jika benar-benar diperlukan
type ReadWriteStorage interface {
    DataLoader
    DataSaver
}

type FullStorage interface {
    DataLoader
    DataSaver
    DataDeleter
    TTLSetter
}

// Setiap consumer hanya bergantung pada yang dibutuhkan
type ReadOnlyService struct {
    loader DataLoader // hanya interface ini — minimal dan fokus
}

type WriteService struct {
    saver DataSaver
}

type CacheService struct {
    storage ReadWriteStorage
    ttl     TTLSetter
}

Di Go, interface yang ideal sering berisi hanya 1–3 method. Standard library Go adalah contoh terbaik: io.Reader (satu method Read), io.Writer (satu method Write), io.Closer (satu method Close), fmt.Stringer (satu method String). Interface kecil lebih mudah diimplementasikan, lebih mudah di-mock dalam testing, dan lebih fleksibel untuk di-compose.

flowchart TD
    DL["DataLoader\n(Load)"]
    DS["DataSaver\n(Save)"]
    DD["DataDeleter\n(Delete)"]
    TL["TTLSetter\n(SetTTL)"]

    RWS["ReadWriteStorage\n(DataLoader + DataSaver)"]
    FS["FullStorage\n(semua)"]

    ROS["ReadOnlyService\n→ butuh DataLoader saja"]
    WS["WriteService\n→ butuh DataSaver saja"]
    CS["CacheService\n→ butuh ReadWriteStorage + TTLSetter"]

    RWS -->|embed| DL
    RWS -->|embed| DS
    FS -->|embed| RWS
    FS -->|embed| DD
    FS -->|embed| TL

    ROS -->|depend on| DL
    WS -->|depend on| DS
    CS -->|depend on| RWS
    CS -->|depend on| TL

    style DL fill:#4C9BE8,color:#fff
    style DS fill:#4C9BE8,color:#fff
    style DD fill:#4C9BE8,color:#fff
    style TL fill:#4C9BE8,color:#fff
    style RWS fill:#5CB85C,color:#fff
    style FS fill:#F0AD4E,color:#fff

ISP juga berdampak langsung pada kualitas unit test. Mock dari interface kecil jauh lebih mudah ditulis dan dipahami daripada mock dari interface besar. Ketika kamu harus implement Flush() dan GetMetadata() hanya untuk bisa test fungsi yang butuh Load() saja — itu sinyal ISP dilanggar.


D — Dependency Inversion Principle #

High-level module tidak boleh bergantung pada low-level module. Keduanya harus bergantung pada abstraksi.

DIP adalah prinsip yang paling langsung berdampak pada testability. Ketika high-level module (business logic) bergantung pada implementasi konkret (database, HTTP client, email server), kamu tidak bisa menguji business logic tanpa menyiapkan infrastruktur nyata. Ini yang membuat unit test “butuh database” atau “butuh koneksi internet” — pertanda DIP dilanggar.

// ANTI-PATTERN: UserService bergantung langsung pada PostgresRepository
// Tidak ada cara untuk unit test tanpa database nyata
type PostgresUserRepository struct {
    db *sql.DB
}

func (r *PostgresUserRepository) FindActive() ([]*User, error) {
    rows, _ := r.db.Query("SELECT * FROM users WHERE active = true")
    // ... scan rows
    return users, nil
}

type UserService struct {
    repo *PostgresUserRepository // ← concrete type, bukan interface
    // Terikat selamanya pada Postgres. Tidak bisa test tanpa database.
}

func (s *UserService) GetActiveUsers() ([]*User, error) {
    return s.repo.FindActive()
}

// Test: mustahil tanpa database nyata — atau butuh library mock yang berat
func TestGetActiveUsers(t *testing.T) {
    // Bagaimana inject fake repo? Tidak bisa karena field-nya concrete type.
    service := &UserService{repo: ???}
}
// BENAR: UserService bergantung pada interface, bukan implementasi konkret
type UserRepository interface {
    FindActive(ctx context.Context) ([]*User, error)
    Save(ctx context.Context, user *User) error
    FindByID(ctx context.Context, id string) (*User, error)
}

type UserService struct {
    repo UserRepository // ← interface, tidak tahu implementasinya apa
}

func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

func (s *UserService) GetActiveUsers(ctx context.Context) ([]*User, error) {
    return s.repo.FindActive(ctx)
}

// Production: inject PostgresUserRepository
func main() {
    db := connectToPostgres()
    repo := postgres.NewUserRepository(db)
    service := NewUserService(repo) // ← inject konkret di edge application
}

// Test: inject FakeUserRepository — tidak butuh database sama sekali
type fakeUserRepository struct {
    users []*User
}

func (f *fakeUserRepository) FindActive(_ context.Context) ([]*User, error) {
    active := []*User{}
    for _, u := range f.users {
        if u.Active {
            active = append(active, u)
        }
    }
    return active, nil
}

func (f *fakeUserRepository) Save(_ context.Context, u *User) error {
    f.users = append(f.users, u)
    return nil
}

func (f *fakeUserRepository) FindByID(_ context.Context, id string) (*User, error) {
    for _, u := range f.users {
        if u.ID == id {
            return u, nil
        }
    }
    return nil, errors.New("not found")
}

func TestGetActiveUsers(t *testing.T) {
    repo := &fakeUserRepository{
        users: []*User{
            {ID: "1", Active: true},
            {ID: "2", Active: false},
            {ID: "3", Active: true},
        },
    }
    service := NewUserService(repo) // inject fake tanpa database
    users, err := service.GetActiveUsers(context.Background())

    assert.NoError(t, err)
    assert.Len(t, users, 2) // hanya yang active
}

DIP berlaku di semua bahasa, termasuk Dart:

// ANTI-PATTERN: NotificationService bergantung pada Firebase secara langsung
class NotificationService {
    // Terikat pada Firebase — tidak bisa test tanpa koneksi Firebase
    Future<void> notify(String userId, String message) async {
        await FirebaseMessaging.instance.send(RemoteMessage(
            token: userId,
            data: {'message': message},
        ));
    }
}

// BENAR: bergantung pada abstraksi
abstract class NotificationRepository {
    Future<void> send(String userId, String message);
}

class NotificationService {
    final NotificationRepository _repository;

    // Constructor injection — dependency masuk dari luar
    NotificationService(this._repository);

    Future<void> notifyUser(String userId, String event) async {
        final message = _buildMessage(event);
        await _repository.send(userId, message);
    }

    String _buildMessage(String event) {
        // business logic: format message
        return 'Event occurred: $event';
    }
}

// Production
class FirebaseNotificationRepository implements NotificationRepository {
    @override
    Future<void> send(String userId, String message) async {
        await FirebaseMessaging.instance.send(RemoteMessage(
            token: userId,
            data: {'message': message},
        ));
    }
}

// Testing — tidak butuh Firebase, tidak butuh koneksi apapun
class FakeNotificationRepository implements NotificationRepository {
    final List<({String userId, String message})> sent = [];

    @override
    Future<void> send(String userId, String message) async {
        sent.add((userId: userId, message: message));
    }
}

void main() {
    test('notifyUser sends correct message', () async {
        final fake = FakeNotificationRepository();
        final service = NotificationService(fake);

        await service.notifyUser('user-123', 'order_created');

        expect(fake.sent.length, equals(1));
        expect(fake.sent.first.userId, equals('user-123'));
    });
}
sequenceDiagram
    participant Main as main() / DI Container
    participant Service as UserService
    participant IRepo as UserRepository (interface)
    participant PgRepo as PostgresUserRepository
    participant FakeRepo as fakeUserRepository

    Main->>PgRepo: instantiate (production)
    Main->>Service: NewUserService(pgRepo)
    Service->>IRepo: FindActive(ctx)
    IRepo-->>PgRepo: dispatch ke implementasi konkret
    PgRepo-->>Service: []*User

    Note over Main,FakeRepo: Saat testing:
    Main->>FakeRepo: instantiate (testing)
    Main->>Service: NewUserService(fakeRepo)
    Service->>IRepo: FindActive(ctx)
    IRepo-->>FakeRepo: dispatch ke fake
    FakeRepo-->>Service: []*User (dari memory)

DIP tidak berarti setiap fungsi harus punya interface-nya. Yang perlu diabstraksi adalah batas antara modul — terutama batas antara business logic dan infrastruktur (database, cache, HTTP, email, storage). Internal detail yang tidak punya alasan untuk diganti boleh tetap konkret.


SOLID Bekerja Bersama #

Kelima prinsip tidak berdiri sendiri — mereka saling memperkuat satu sama lain. Menerapkan salah satu tanpa yang lain sering membuat desain terasa aneh atau tidak konsisten.

flowchart TD
    SRP["SRP\nUserService hanya\nhandle business logic user"]
    OCP["OCP\nPenambahan tipe user baru\nmenggunakan interface,\nbukan switch-case"]
    ISP["ISP\nUserRepository kecil,\nhanya method yang\ndibutuhkan service"]
    DIP["DIP\nUserService bergantung\npada UserRepository interface,\nbukan Postgres"]
    LSP["LSP\nSemua implementasi UserRepository\nberperilaku konsisten\nsesuai kontrak"]
    RESULT["Hasil Akhir\n• Unit test tanpa database\n• Tambah implementasi baru tanpa ubah service\n• Setiap komponen bisa dikembangkan independen"]

    SRP -->|mendorong| OCP
    OCP -->|memerlukan| ISP
    ISP -->|memungkinkan| DIP
    DIP -->|menghasilkan| LSP
    SRP & OCP & ISP & DIP & LSP --> RESULT

    style RESULT fill:#5CB85C,color:#fff
    style SRP fill:#4C9BE8,color:#fff
    style OCP fill:#4C9BE8,color:#fff
    style ISP fill:#4C9BE8,color:#fff
    style DIP fill:#4C9BE8,color:#fff
    style LSP fill:#4C9BE8,color:#fff

Bayangkan sebuah sistem yang menerapkan semua prinsip secara konsisten:

  • UserService punya satu tanggung jawab: orkestrasi business logic user (SRP)
  • Ketika ada tipe user baru, kamu tambah struct baru yang implement interface UserType — tidak menyentuh UserService (OCP)
  • Interface UserRepository kecil, hanya punya method yang benar-benar dipakai UserService (ISP)
  • UserService bergantung pada UserRepository interface, sehingga bisa ditest tanpa database (DIP)
  • Semua implementasi UserRepository — Postgres, MySQL, atau fake — berperilaku konsisten (LSP)

Hasilnya: menambahkan fitur baru ke sistem ini hanya memerlukan penambahan kode baru, bukan modifikasi kode lama. Unit test berjalan cepat tanpa infrastruktur. Setiap komponen bisa dikembangkan dan di-deploy secara independen.


Kapan Tidak Perlu Memaksakan SOLID #

SOLID adalah panduan, bukan dogma. Ada situasi di mana over-application justru merusak simplicity dan membuat kode lebih sulit dipahami, bukan lebih mudah.

TERAPKAN SOLID ketika:
  ✓ Sistem akan berkembang dan butuh maintenance jangka panjang
  ✓ Multiple engineer bekerja di codebase yang sama
  ✓ Testability adalah prioritas
  ✓ Behavior yang sama perlu bisa diganti (storage, notifikasi, payment gateway)
  ✓ Ada lebih dari satu implementasi yang mungkin untuk sebuah abstraksi

PERTIMBANGKAN ULANG ketika:
  ✗ Script sekali pakai atau prototype cepat yang tidak akan di-maintain
  ✗ Aplikasi kecil yang tidak akan berkembang dan dikerjakan seorang diri
  ✗ Interface untuk sesuatu yang hanya ada satu implementasi dan tidak akan pernah berubah
  ✗ Abstraksi yang dibuat tidak dibutuhkan saat ini — ini melanggar YAGNI
  ✗ Ukuran codebase sangat kecil sehingga overhead abstraksi lebih besar dari manfaatnya

Tanda over-application SOLID yang perlu diwaspadai:

  • Interface yang hanya punya satu implementasi dan tidak pernah di-mock dalam test
  • Layer abstraksi yang ada hanya karena “mungkin nanti berguna” — tanpa use case nyata
  • Nama yang terlalu generik dan tidak mencerminkan domain (misalnya Processor, Manager, Handler tanpa konteks)
  • NewService(repo Repository, notifier Notifier, reporter Reporter, auditor Auditor, logger Logger) — constructor injection yang terlalu dalam bisa jadi tanda SRP dilanggar di level yang lebih tinggi
Premature abstraction lebih berbahaya dari no abstraction. Abstraksi yang salah terlalu mahal untuk di-refactor karena sudah tersebar di mana-mana. Lebih baik mulai dengan implementasi konkret yang jelas, lalu ekstrak interface ketika ada kebutuhan nyata untuk substitusi atau testing.

Anti-Pattern dalam Satu Pandangan #

Berikut ringkasan visual seluruh pelanggaran SOLID yang perlu dihindari:

// ✗ SRP: satu struct melakukan terlalu banyak hal
type GodService struct{} // handle user, order, payment, notif, report sekaligus

// ✗ OCP: perlu modifikasi setiap kali ada penambahan behavior
func process(eventType string) {
    if eventType == "order" { /* ... */ } else if eventType == "payment" { /* ... */ }
    // Setiap case baru = risiko merusak case yang sudah ada
}

// ✗ LSP: implementasi tidak memenuhi kontrak interface yang dijanjikan
func (s *ReadOnlyStorage) Save(data []byte) error {
    panic("not supported") // ← melanggar kontrak DataSaver
}

// ✗ ISP: interface besar memaksa implementasi yang tidak relevan
type MegaRepository interface {
    Read()
    Write()
    Delete()
    Archive()
    Export()
    Import()
    Compress()
    Encrypt()
    // Struct yang hanya butuh Read terpaksa implement 7 method lainnya
}

// ✗ DIP: bergantung langsung pada implementasi konkret
type OrderService struct {
    db    *MySQLDatabase  // tidak bisa di-swap ke Postgres atau di-mock
    cache *RedisCache     // tidak bisa ditest tanpa Redis berjalan
}

Checklist Review SOLID #

SINGLE RESPONSIBILITY:
  □ Setiap struct/class punya satu tanggung jawab yang bisa dideskripsikan
    tanpa kata "dan"
  □ Database logic ada di repository, bukan di service
  □ Notifikasi ada di notification service, bukan di business service
  □ Tidak ada "GodObject" atau "UtilService" yang menampung segalanya

OPEN/CLOSED:
  □ Menambahkan behavior baru tidak memerlukan modifikasi file yang sudah ada
  □ Tidak ada switch-case atau if-else bertingkat yang bertambah setiap sprint
  □ Ekstensi dilakukan melalui interface baru atau struct baru

LISKOV SUBSTITUTION:
  □ Tidak ada implementasi interface yang panic atau return error
    di luar yang didokumentasikan
  □ Kode yang menggunakan interface tidak butuh type assertion
    untuk berfungsi dengan benar
  □ Semua implementasi interface bisa disubstitusi tanpa mengubah
    perilaku program

INTERFACE SEGREGATION:
  □ Interface tidak punya method yang tidak relevan bagi semua consumer-nya
  □ Implementasi interface tidak punya method yang di-panic atau
    dikosongkan karena "tidak berlaku"
  □ Interface Go idealnya 1–3 method per interface

DEPENDENCY INVERSION:
  □ Business logic bergantung pada interface, bukan pada tipe konkret
    database, HTTP client, atau service eksternal
  □ Dependency diinjeksikan melalui constructor, bukan di-instantiate
    di dalam fungsi
  □ Unit test tidak butuh koneksi database, Redis, atau layanan eksternal
  □ Tidak ada global variable yang menyimpan implementasi konkret

Ringkasan #

  • SRP — satu module, satu alasan untuk berubah: pisahkan business logic, database, notifikasi, dan reporting ke komponen yang berbeda. Tes kebijakan: deskripsi tanggung jawab tidak boleh mengandung kata “dan”.
  • OCP — terbuka untuk ekstensi, tertutup untuk modifikasi: gunakan interface agar behavior baru bisa ditambah tanpa mengubah kode yang sudah ada dan diuji. Switch-case yang terus bertambah adalah sinyal OCP dilanggar.
  • LSP — implementasi harus memenuhi kontrak interface: jangan buat implementasi yang panic atau return error di luar yang dijanjikan. Kode yang menggunakan interface tidak boleh butuh type assertion untuk berfungsi.
  • ISP — interface kecil dan fokus: di Go, interface dengan 1–3 method adalah idiom yang benar, lebih mudah di-implement, dan lebih mudah di-mock. Jangan paksa struct mengimplementasikan method yang tidak relevan.
  • DIP — bergantung pada abstraksi, bukan implementasi: inject dependency sebagai interface melalui constructor sehingga business logic bisa ditest tanpa database atau infrastruktur nyata.
  • SOLID saling memperkuat: SRP mendorong komponen kecil → OCP mendorong interface → ISP membuat interface tetap kecil → DIP menjadikan semuanya testable → LSP memastikan substitusi aman.
  • Bukan dogma: jangan over-engineer dengan abstraksi yang tidak dibutuhkan. Interface dengan satu implementasi yang tidak pernah diganti tidak memberikan nilai tambah. Terapkan SOLID di tempat yang memberikan manfaat nyata: sistem yang berkembang, multi-engineer, dan butuh testability.

← Sebelumnya: System Integration   Berikutnya: DRY →

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