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 konteks kapan SOLID harus diterapkan dan kapan tidak perlu.
Mengapa SOLID Penting? #
Tanpa prinsip desain yang konsisten, codebase yang berkembang cenderung menuju satu arah: semakin sulit diubah. Perubahan kecil berdampak besar di tempat yang tidak terduga. Unit test membutuhkan setup database nyata. Menambahkan fitur baru berarti memodifikasi kode lama yang sudah berjalan. Setiap engineer baru butuh waktu lama untuk memahami alurnya.
SOLID menyerang masalah-masalah ini dari sisi desain:
Masalah tanpa SOLID → Solusi dengan SOLID
────────────────────────── ─────────────────────────────
Satu class melakukan SRP: satu tanggung jawab,
segalanya satu alasan untuk berubah
Tambah fitur = ubah OCP: extend dengan code baru,
kode lama jangan modifikasi yang sudah ada
Subtype merusak LSP: implementasi bisa
behavior parent disubstitusi tanpa kejutan
Interface besar memaksa ISP: interface kecil dan fokus,
implementasi yang tidak client hanya bergantung pada
dibutuhkan yang mereka pakai
Modul tingkat tinggi DIP: bergantung pada abstraksi,
bergantung pada detail bukan implementasi konkret
implementasi
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.
// 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
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
type OrderService struct {
repo OrderRepository // tanggung jawab: persisten 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
}
s.notifier.NotifyOrderCreated(order) // async, tidak block
s.reporter.GenerateReceipt(order) // async, tidak block
return nil
}
Tes kebijakan SRP yang sederhana: tulis kalimat “Class X bertanggung jawab untuk…”. Jika kalimat itu memerlukan kata “dan” untuk menyambung dua hal yang berbeda — SRP kemungkinan dilanggar.
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 mengubah kode yang sudah ada dan sudah diuji. Ini dicapai dengan mendefinisikan abstraksi (interface) dan menambahkan implementasi baru, bukan mengubah yang lama.
// ANTI-PATTERN: setiap tipe diskon baru memaksa modifikasi fungsi ini
// Menambahkan tipe "Premium" berarti mengubah kode yang sudah berjalan
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
default:
return price
}
}
// BENAR: menambahkan diskon baru = tambah struct baru saja, tanpa ubah yang lama
type DiscountStrategy interface {
Apply(price float64) float64
Name() string
}
type VIPDiscount struct{}
func (d VIPDiscount) Apply(price float64) float64 { return price * 0.8 }
func (d VIPDiscount) Name() string { return "VIP (20% off)" }
type MemberDiscount struct{}
func (d MemberDiscount) Apply(price float64) float64 { return price * 0.9 }
func (d MemberDiscount) Name() string { return "Member (10% off)" }
type PremiumDiscount struct{}
func (d PremiumDiscount) Apply(price float64) float64 { return price * 0.85 }
func (d PremiumDiscount) Name() string { return "Premium (15% off)" }
// Calculator tidak perlu berubah saat ada tipe diskon baru
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 code yang menggunakannya (closed for modification).
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 null di luar kontrak, atau mengubah semantic dari method yang diwarisi.
// ANTI-PATTERN: Penguin mengimplementasikan Bird tapi melanggar kontraknya
// Kode yang memanggil bird.Fly() harus defensive programming terhadap panic
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 Bird interface tidak mengharapkan ini
}
// Kode yang menggunakan interface terpaksa defensive:
for _, bird := range birds {
if err := bird.Fly(); err != nil {
// "Mungkin ini penguin" — ini adalah tanda LSP dilanggar
}
}
// BENAR: pisahkan interface berdasarkan kemampuan
type Animal interface {
Eat()
Move()
}
type FlyingAnimal interface {
Animal
Fly() // hanya untuk yang benar-benar bisa terbang
}
type SwimmingAnimal interface {
Animal
Swim() // hanya untuk yang benar-benar bisa berenang
}
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
func makeAllFly(flyers []FlyingAnimal) {
for _, f := range flyers {
f.Fly() // dijamin berhasil — semua implementor benar-benar bisa terbang
}
}
Tes LSP yang praktis: jika kamu perlu menambahkan type assertion atau switch-case untuk mengecek tipe konkret di dalam kode yang seharusnya bekerja dengan interface — LSP mungkin dilanggar.
I — Interface Segregation Principle #
Jangan memaksa client bergantung pada interface yang tidak mereka gunakan.
ISP mendorong interface yang kecil dan fokus. Di Go, ini sangat idiomatis karena Go mendukung implicit interface implementation — kamu bisa mendefinisikan interface di sisi consumer, tepat sebesar yang dibutuhkan.
// 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
}
// ReadOnlyService hanya butuh Load, tapi terpaksa depend pada interface dengan 7 method
// Jika interface ini berubah (misalnya tambah Archive()), ReadOnlyService terkena dampak
type ReadOnlyService struct {
storage Storage
}
// BENAR: interface kecil, sesuai kebutuhan consumer
type DataLoader interface {
Load(id string) ([]byte, error)
}
type DataSaver interface {
Save(data []byte) error
}
type DataDeleter interface {
Delete(id string) error
}
// Compose interface yang lebih besar dari yang kecil jika perlu
type ReadWriteStorage interface {
DataLoader
DataSaver
}
// ReadOnlyService hanya bergantung pada yang dibutuhkan
type ReadOnlyService struct {
loader DataLoader // hanya interface ini — minimal dan fokus
}
// WriteService bergantung pada yang dibutuhkannya
type WriteService struct {
saver DataSaver
}
Di Go, interface yang ideal sering berisi hanya 1–3 method. Ini bukan kebetulan — standard library Go penuh dengan contoh ini: io.Reader (satu method), io.Writer (satu method), fmt.Stringer (satu method). Interface kecil lebih mudah diimplementasikan, lebih mudah di-mock, dan lebih fleksibel untuk di-compose.
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), kamu tidak bisa menguji business logic tanpa menyiapkan infrastruktur nyata.
// ANTI-PATTERN: UserService bergantung langsung pada PostgresRepository
// Tidak ada cara untuk unit test tanpa database nyata
type PostgresUserRepository struct {
db *sql.DB
}
type UserService struct {
repo PostgresUserRepository // ← concrete type, bukan interface
}
func (s *UserService) GetActiveUsers() ([]*User, error) {
return s.repo.FindActive() // terikat pada Postgres
}
// BENAR: UserService bergantung pada interface
type UserRepository interface {
FindActive(ctx context.Context) ([]*User, error)
Save(ctx context.Context, user *User) error
}
type UserService struct {
repo UserRepository // ← interface, bukan concrete type
}
func (s *UserService) GetActiveUsers(ctx context.Context) ([]*User, error) {
return s.repo.FindActive(ctx) // tidak tahu implementasi apa di baliknya
}
// Untuk production: inject PostgresUserRepository
// Untuk test: inject FakeUserRepository
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
// Dart — DIP dengan constructor injection
abstract class NotificationRepository {
Future<void> send(String userId, String message);
}
// High-level: NotificationService bergantung pada abstraksi
class NotificationService {
final NotificationRepository _repository;
NotificationService(this._repository); // injection
Future<void> notifyUser(String userId, String event) async {
final message = buildMessage(event);
await _repository.send(userId, message);
}
}
// Low-level: berbagai implementasi konkret
class FirebaseNotificationRepository implements NotificationRepository {
@override
Future<void> send(String userId, String message) async {
// Firebase implementation
}
}
class FakeNotificationRepository implements NotificationRepository {
final List<String> sent = [];
@override
Future<void> send(String userId, String message) async {
sent.add('$userId: $message'); // untuk testing
}
}
SOLID Bekerja Bersama #
Kelima prinsip tidak berdiri sendiri — mereka saling memperkuat satu sama lain.
SRP: UserService hanya bertanggung jawab pada business logic user
↓ mendorong
OCP: penambahan tipe user baru menggunakan interface, bukan switch-case
↓ memerlukan
ISP: interface UserRepository kecil, hanya method yang dibutuhkan service
↓ memungkinkan
DIP: UserService bergantung pada UserRepository interface, bukan Postgres
↓ menghasilkan
LSP: semua implementasi UserRepository berperilaku konsisten
Efek akhir:
- Unit test UserService tidak butuh database
- Menambahkan PostgresRepository baru tidak ubah UserService
- Menambahkan tipe pengguna baru tidak ubah service yang sudah ada
- Setiap komponen bisa dikembangkan dan ditest secara independen
Kapan Tidak Perlu Memaksakan SOLID #
SOLID adalah panduan, bukan dogma. Ada situasi di mana over-application justru merusak simplicity.
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 (misalnya storage, notification)
JANGAN memaksakan SOLID ketika:
✗ Script sekali pakai atau prototype cepat
✗ Aplikasi kecil yang tidak akan berkembang
✗ Abstraksi yang dibuat tidak dibutuhkan saat ini (melanggar YAGNI)
✗ Interface untuk sesuatu yang hanya ada satu implementasi dan tidak akan berubah
Tanda over-application SOLID: interface yang hanya punya satu implementasi dan tidak pernah di-mock, abstraksi layer yang tidak menambah nilai, nama yang terlalu generik karena dipaksa abstract padahal tidak perlu.
Anti-Pattern dalam Satu Pandangan #
// ✗ SRP: satu struct terlalu banyak tanggung jawab
type GodService struct{} // handle user, order, payment, notif, report
// ✗ OCP: perlu modifikasi setiap ada tambahan behavior
func process(type string) { if type=="a" {...} else if type=="b" {...} }
// ✗ LSP: implementasi tidak memenuhi kontrak interface
func (s *SpecialCase) Execute() { panic("not supported for this type") }
// ✗ ISP: interface besar memaksa implementasi yang tidak relevan
type MegaInterface interface { Read(); Write(); Delete(); Archive(); Export(); Import() }
// ✗ DIP: bergantung langsung pada implementasi konkret
type Service struct { db *MySQLDatabase } // tidak bisa di-swap atau di-mock
Ringkasan #
- SRP — satu module, satu alasan untuk berubah: pisahkan business logic, database, dan notification ke komponen yang berbeda.
- OCP — terbuka untuk ekstensi, tertutup untuk modifikasi: gunakan interface agar behavior baru bisa ditambah tanpa mengubah kode yang ada.
- LSP — implementasi harus bisa menggantikan interface tanpa merusak perilaku: jangan buat implementasi yang melempar exception di luar kontrak.
- ISP — interface kecil dan fokus: di Go, interface dengan 1–3 method adalah idiom yang benar dan lebih mudah di-mock.
- DIP — high-level bergantung pada abstraksi, bukan implementasi: inject dependency sebagai interface untuk membuat unit test tidak butuh infrastruktur nyata.
- SOLID saling memperkuat: SRP mendorong komponen kecil, OCP mendorong interface, ISP membuat interface kecil, DIP menjadikan semuanya testable, LSP memastikan substitusi aman.
- Bukan dogma: jangan over-engineer dengan abstraksi yang tidak dibutuhkan — terapkan SOLID di tempat yang akan memberikan manfaat nyata.