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:#fffSRP 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:#fffPrinsip 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:#fffLSP 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:#fffISP 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:#fffBayangkan sebuah sistem yang menerapkan semua prinsip secara konsisten:
UserServicepunya satu tanggung jawab: orkestrasi business logic user (SRP)- Ketika ada tipe user baru, kamu tambah struct baru yang implement interface
UserType— tidak menyentuhUserService(OCP) - Interface
UserRepositorykecil, hanya punya method yang benar-benar dipakaiUserService(ISP) UserServicebergantung padaUserRepositoryinterface, 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,Handlertanpa 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.