Refactoring Series — Part 3: Code Smells Change Preventers
Tiga code smells yang membuat codebase Anda kaku dan sulit diubah: Divergent Change, Shotgun Surgery, dan Parallel Inheritance Hierarchies — lengkap dengan contoh nyata dalam Go dan strategi refactoring.
Kamu pernah melakukan perubahan kecil pada satu baris kode, lalu tiba-tiba compiler atau IDE milikmu menunjukkan error di lima file yang berbeda? Atau saat kamu ingin mengubah cara sistem melakukan otentikasi user, kamu juga terpaksa harus menyentuh kode untuk template email dan reporting di file yang sama? Jika skenario ini terasa familiar, selamat — kamu sedang berhadapan dengan Change Preventers (Pencegah Perubahan).
Change Preventers adalah salah satu kategori code smell yang diidentifikasi oleh Martin Fowler. Kode jenis ini tidak hanya terlihat kotor, tetapi secara aktif “melawan balik” setiap kali kamu mencoba mengembangkan sistem. Dalam codebase Go di dunia nyata, Change Preventers adalah salah satu bentuk technical debt yang paling mahal karena mereka memperbesar dampak (blast radius) dari setiap perubahan kecil, sehingga penambahan fitur sederhana bisa berubah menjadi petualangan refactoring berhari-hari.
Di post ini, kita akan membedah tiga jenis code smell yang termasuk dalam Change Preventers — Divergent Change, Shotgun Surgery, dan Parallel Inheritance Hierarchies — memahami mengapa mereka bisa terbentuk, dan mempelajari cara membasminya dengan contoh kode Go yang konkret.
🎯 Takeaway
Setelah membaca artikel ini, kamu akan:
- 🔍 Mengenali ketiga jenis smell Change Preventers di dalam codebase Go milikmu
- 💡 Memahami akar masalah di balik setiap smell (petunjuk: ini selalu tentang pembagian tanggung jawab)
- 🛠️ Menerapkan strategi refactoring yang tepat: Pemisahan Struct (Split Class), Konsolidasi (Consolidation), dan Komposisi (Composition)
- ✍️ Menulis kode Go yang terstruktur sehingga satu perubahan logis hanya perlu menyentuh satu tempat saja
- 📊 Menggambarkan arsitektur sebelum/sesudah refactoring menggunakan diagram mental yang tepat
Apa Itu Change Preventers?
Change Preventers adalah kelompok code smell di mana struktur kodemu membuat perubahan menjadi sangat sulit dilakukan tanpa alasan yang masuk akal. Berbeda dengan bloater smells (yang berkaitan dengan ukuran file/fungsi), Change Preventers berfokus pada masalah kohesi dan kopling (cohesion and coupling) — alias meletakkan tanggung jawab (responsibility) di tempat yang salah.
Ada tiga jenis utama:
| Smell | Gejala | Akar Masalah |
|---|---|---|
| Divergent Change | Satu class/struct diubah untuk banyak alasan yang berbeda | Terlalu banyak tanggung jawab di satu tempat |
| Shotgun Surgery | Satu perubahan logis mengharuskan kita mengedit banyak class/struct berbeda | Satu tanggung jawab tersebar di terlalu banyak tempat |
| Parallel Inheritance | Setiap kali membuat subclass baru, kita terpaksa harus membuat subclass lain di tempat berbeda | Dua hierarki kelas yang terikat secara implisit |
Perhatikan bahwa Divergent Change dan Shotgun Surgery adalah dua hal yang saling berlawanan — yang satu menumpuk terlalu banyak hal di satu tempat, sedangkan yang lainnya menyebarkan satu hal ke terlalu banyak tempat. Keduanya sama-sama melanggar Single Responsibility Principle (SRP), hanya saja dari arah yang berbeda.
graph TD
subgraph "Change Preventers"
A["🔴 Divergent Change\nSatu class, banyak alasan berubah"]
B["🔴 Shotgun Surgery\nSatu alasan berubah, banyak class terdampak"]
C["🔴 Parallel Inheritance\nSatu class baru memaksa class baru lainnya"]
end
subgraph "Akar Masalah"
D["Terlalu banyak kohesi\ndi satu tempat (overloaded)"]
E["Terlalu sedikit kohesi\ntersebar terlalu tipis"]
F["Kopling implisit\nantar-hierarki"]
end
A --> D
B --> E
C --> F
1. Divergent Change
Apa Itu?
Sebuah class atau struct mengalami Divergent Change (Perubahan Bercabang) jika kamu mendapati dirimu harus memodifikasinya untuk berbagai alasan yang sama sekali tidak saling berhubungan. Dinamakan “divergent” karena class tersebut berkembang ke berbagai arah yang berbeda secara bersamaan.
Uji sederhana dari Martin Fowler: “Jika kamu melihat sebuah class dan berkata ‘Saya mengubah class ini setiap kali menambahkan database baru’, dan juga ‘Saya mengubah class ini setiap kali menambahkan instrumen keuangan baru’, maka itu adalah Divergent Change.”
Mengapa Bisa Terjadi?
Smell ini biasanya muncul ketika developer terus-menerus menambahkan fitur baru ke struct yang “paling mudah diakses” tanpa memikirkan apakah struct tersebut adalah pemilik tanggung jawab yang tepat. Lama-kelamaan, sebuah struct seperti UserService berubah menjadi “dewa” yang tahu segalanya (God Service).
Visualisasi Masalah
graph LR
subgraph "Sebelum: Divergent Change"
US["UserService\n───────────────\n• AuthenticateUser\n• RefreshToken\n• SendWelcomeEmail\n• SendPasswordReset\n• GenerateActivityReport\n• GenerateLoginReport"]
end
R1["Perubahan spek Auth"] --> US
R2["Perubahan template Email"] --> US
R3["Perubahan format Report"] --> US
subgraph "Sesudah: Dipisah berdasarkan Tanggung Jawab"
AS["AuthService\n───────────────\n• Authenticate\n• RefreshToken"]
NS["NotificationService\n───────────────\n• SendWelcomeEmail\n• SendPasswordReset"]
RS["ReportService\n───────────────\n• GenerateActivity\n• GenerateLogin"]
end
R1 --> AS
R2 --> NS
R3 --> RS
Contoh Bad Code (❌)
Di bawah ini adalah contoh UserService yang melanggar SRP karena menangani otentikasi, pengiriman email, sekaligus pembuatan laporan.
// ❌ BAD: UserService menangani otentikasi, notifikasi email,
// DAN pelaporan sekaligus. Tiga alasan berbeda untuk melakukan perubahan.
package user
import (
"fmt"
"log"
"time"
)
type User struct {
ID int
Name string
Email string
Password string // hash
}
// UserService: sebuah "god service" dengan tanggung jawab yang bercabang
type UserService struct {
db Database
mailer Mailer
}
// --- Domain 1: Otentikasi ---
func (s *UserService) AuthenticateUser(email, password string) (*User, error) {
user, err := s.db.FindByEmail(email)
if err != nil {
return nil, fmt.Errorf("user tidak ditemukan: %w", err)
}
if !checkPasswordHash(password, user.Password) {
return nil, fmt.Errorf("kredensial tidak valid")
}
log.Printf("User %s berhasil login", user.Email)
return user, nil
}
func (s *UserService) RefreshToken(userID int) (string, error) {
user, err := s.db.FindByID(userID)
if err != nil {
return "", err
}
token := generateToken(user)
return token, nil
}
// --- Domain 2: Notifikasi ---
func (s *UserService) SendWelcomeEmail(user *User) error {
subject := "Selamat datang di platform kami!"
body := fmt.Sprintf("Halo %s, terima kasih telah mendaftar.", user.Name)
return s.mailer.Send(user.Email, subject, body)
}
func (s *UserService) SendPasswordResetEmail(user *User, token string) error {
subject := "Reset password Anda"
body := fmt.Sprintf("Klik tautan berikut untuk reset: https://app.example.com/reset?token=%s", token)
return s.mailer.Send(user.Email, subject, body)
}
// --- Domain 3: Pelaporan (Reporting) ---
func (s *UserService) GenerateActivityReport(from, to time.Time) ([]byte, error) {
users, err := s.db.FindActiveUsers(from, to)
if err != nil {
return nil, err
}
return formatReport(users, "Laporan Aktivitas"), nil
}
func (s *UserService) GenerateLoginReport(from, to time.Time) ([]byte, error) {
logins, err := s.db.FindLogins(from, to)
if err != nil {
return nil, err
}
return formatReport(logins, "Laporan Login"), nil
}
Mengapa kode di atas bermasalah:
- Perubahan pada template email memaksamu mengubah
UserService— padahal secara logika logika bisnis otentikasi tidak perlu tahu-menahu tentang format teks email. - Format laporan/report baru mengharuskan perubahan di file yang sama dengan logika kritis keamanan login.
- Unit testing untuk otentikasi memerlukan mock email sender. Testing email memerlukan mock database. Ini membuat pembuatan unit test menjadi sangat menyebalkan.
UserServicemenjadi dependency dari hampir semua package di aplikasimu, menjadikannya rentan menimbulkan efek domino jika diubah.
Perbaikan (✅)
Untuk menyembuhkan Divergent Change, kita memecah class tersebut berdasarkan domain tanggung jawabnya menggunakan package yang terpisah dan terfokus.
// ✅ GOOD: Setiap service memegang tepat satu tanggung jawab saja.
// Ubah satu hal -> cukup edit satu file yang relevan.
// --- package auth ---
// AuthService hanya mengurus hal-hal terkait otentikasi.
type AuthService struct {
db UserRepository
tokenGen TokenGenerator
}
func (s *AuthService) Authenticate(email, password string) (*User, error) {
user, err := s.db.FindByEmail(email)
if err != nil {
return nil, fmt.Errorf("user tidak ditemukan: %w", err)
}
if !checkPasswordHash(password, user.Password) {
return nil, fmt.Errorf("kredensial tidak valid")
}
return user, nil
}
func (s *AuthService) RefreshToken(userID int) (string, error) {
user, err := s.db.FindByID(userID)
if err != nil {
return "", fmt.Errorf("user %d tidak ditemukan: %w", userID, err)
}
return s.tokenGen.Generate(user), nil
}
// --- package notification ---
// NotificationService hanya mengurus komunikasi dan template email.
type NotificationService struct {
mailer Mailer
tmpl TemplateEngine
}
func (s *NotificationService) SendWelcome(user User) error {
body, err := s.tmpl.Render("welcome", user)
if err != nil {
return fmt.Errorf("gagal render template welcome: %w", err)
}
return s.mailer.Send(user.Email, "Selamat datang di platform kami!", body)
}
func (s *NotificationService) SendPasswordReset(user User, token string) error {
body, err := s.tmpl.Render("password_reset", map[string]string{
"name": user.Name,
"token": token,
})
if err != nil {
return fmt.Errorf("gagal render template reset: %w", err)
}
return s.mailer.Send(user.Email, "Reset password Anda", body)
}
// --- package report ---
// ReportService hanya mengurus pembuatan analitik dan laporan.
type ReportService struct {
db ReportRepository
format ReportFormatter
}
func (s *ReportService) GenerateActivity(from, to time.Time) ([]byte, error) {
data, err := s.db.FetchActiveUsers(from, to)
if err != nil {
return nil, fmt.Errorf("gagal mengambil user aktif: %w", err)
}
return s.format.Render("activity", data)
}
func (s *ReportService) GenerateLogin(from, to time.Time) ([]byte, error) {
data, err := s.db.FetchLogins(from, to)
if err != nil {
return nil, fmt.Errorf("gagal mengambil data login: %w", err)
}
return s.format.Render("login", data)
}
Hasil refactoring: Jika ingin mengganti template email, kamu cukup menyentuh package notification. Jika ingin memodifikasi algoritma token otentikasi, kamu cukup fokus pada package auth. Setiap service kini dapat ditest secara mandiri dengan mock yang ringkas dan terfokus.
2. Shotgun Surgery
Apa Itu?
Shotgun Surgery (Operasi Senapan Sebar) adalah kebalikan dari Divergent Change. Jika pada Divergent Change kita menaruh terlalu banyak tanggung jawab di satu file, pada Shotgun Surgery, satu konsep logis tersebar secara acak di banyak file atau package.
Akibatnya, setiap kali kamu ingin membuat satu perubahan logis (misalnya menambah metode pembayaran baru), kamu terpaksa melakukan perubahan kecil di banyak sekali file yang tersebar — mirip seperti luka tembakan senapan shotgun yang menyebar ke seluruh tubuh.
Mengapa Bisa Terjadi?
Smell ini sering kali lahir dari kebiasaan copy-paste kode (duplication), feature envy (saat logika suatu objek ditarik keluar dan dieksekusi di tempat lain), atau evolusi fitur bertahap tanpa pernah meluangkan waktu untuk melakukan konsolidasi desain.
Visualisasi Masalah
graph TD
subgraph "Sebelum: Shotgun Surgery"
CH["Metode pembayaran baru\n(misal: tambah 'crypto')"]
CH --> F1["payment_router.go\ntambah case 'crypto'"]
CH --> F2["payment_validator.go\ntambah validasi crypto"]
CH --> F3["payment_logger.go\ntambah format log crypto"]
CH --> F4["payment_receipt.go\ntambah kuitansi crypto"]
CH --> F5["payment_fee.go\ntambah kalkulasi biaya crypto"]
end
subgraph "Sesudah: Dikonsolidasikan"
CH2["Metode pembayaran baru\n(misal: tambah 'crypto')"]
CH2 --> P["payments/crypto.go\nimplementasikan interface PaymentProvider"]
end
Contoh Bad Code (❌)
Di bawah ini, menambahkan metode pembayaran baru bernama "crypto" memaksa kita mengubah lima file yang berbeda.
// ❌ BAD: Menambahkan metode pembayaran baru (contoh: "crypto")
// mengharuskan perubahan di 5 file berbeda di seluruh codebase.
// --- file: payment_router.go ---
func RoutePayment(method string, amount float64) error {
switch method {
case "credit_card":
return processCreditCard(amount)
case "bank_transfer":
return processBankTransfer(amount)
// ❌ Untuk menambahkan crypto: kamu wajib mengubah file INI
case "crypto":
return processCrypto(amount)
default:
return fmt.Errorf("metode pembayaran tidak dikenal: %s", method)
}
}
// --- file: payment_validator.go ---
func ValidatePayment(method string, amount float64) error {
switch method {
case "credit_card":
if amount > 50_000_000 {
return fmt.Errorf("limit kartu kredit terlampaui")
}
case "bank_transfer":
if amount < 10_000 {
return fmt.Errorf("minimum transfer bank tidak terpenuhi")
}
// ❌ Kamu juga wajib mengubah file INI
case "crypto":
if amount < 100_000 {
return fmt.Errorf("minimum crypto tidak terpenuhi")
}
}
return nil
}
// --- file: payment_fee.go ---
func CalculateFee(method string, amount float64) float64 {
switch method {
case "credit_card":
return amount * 0.029
case "bank_transfer":
return 5_000
// ❌ Dan file INI
case "crypto":
return amount * 0.01
default:
return 0
}
}
// --- file: payment_logger.go ---
func LogPayment(method string, amount float64, status string) {
switch method {
case "credit_card":
log.Printf("[CC] nominal=%.0f status=%s", amount, status)
case "bank_transfer":
log.Printf("[BT] nominal=%.0f status=%s", amount, status)
// ❌ Dan file INI juga
case "crypto":
log.Printf("[CX] nominal=%.0f status=%s", amount, status)
}
}
// --- file: payment_receipt.go ---
func GenerateReceipt(method string, amount float64) string {
switch method {
case "credit_card":
return fmt.Sprintf("Pembayaran Kartu Kredit: Rp %.0f", amount)
case "bank_transfer":
return fmt.Sprintf("Transfer Bank: Rp %.0f", amount)
// ❌ Total ada 5 file yang harus diubah hanya untuk satu metode pembayaran baru!
case "crypto":
return fmt.Sprintf("Pembayaran Crypto: %.8f BTC", amount/1_000_000_000)
}
return ""
}
Mengapa kode di atas bermasalah:
- Menambahkan satu cara pembayaran mengharuskan kita memodifikasi 5 file terpisah.
- Sangat mudah lupa mengupdate salah satu file, sehingga menghasilkan implementasi yang setengah matang dan rusak di production.
- Tidak ada jaminan dari compiler — compiler Go tidak akan memprotes jika kamu lupa menambahkan case baru di file
payment_fee.go. - Definisi sebuah metode pembayaran tidak punya tempat tunggal (Single Source of Truth).
Perbaikan (✅)
Untuk mengatasi Shotgun Surgery, kita mendefinisikan sebuah interface PaymentProvider yang membungkus semua perilaku yang harus dimiliki oleh sebuah metode pembayaran. Menambahkan metode pembayaran baru berarti membuat satu file baru yang mengimplementasikan interface tersebut.
// ✅ GOOD: Definisikan interface PaymentProvider.
// Menambah metode pembayaran baru = membuat 1 file baru yang mengimplementasi interface tersebut.
package payment
import (
"fmt"
"log"
)
// PaymentProvider adalah interface tunggal yang memuat seluruh
// kebutuhan sebuah metode pembayaran. Konsep ini sekarang menyatu di SATU tempat.
type PaymentProvider interface {
Validate(amount float64) error
CalculateFee(amount float64) float64
Process(amount float64) error
Receipt(amount float64) string
LogTag() string
}
// --- credit_card.go ---
type CreditCardProvider struct{}
func (p CreditCardProvider) Validate(amount float64) error {
if amount > 50_000_000 {
return fmt.Errorf("limit kartu kredit terlampaui")
}
return nil
}
func (p CreditCardProvider) CalculateFee(amount float64) float64 { return amount * 0.029 }
func (p CreditCardProvider) Process(amount float64) error { return processCreditCardGateway(amount) }
func (p CreditCardProvider) Receipt(amount float64) string {
return fmt.Sprintf("Pembayaran Kartu Kredit: Rp %.0f", amount)
}
func (p CreditCardProvider) LogTag() string { return "CC" }
// --- bank_transfer.go ---
type BankTransferProvider struct{}
func (p BankTransferProvider) Validate(amount float64) error {
if amount < 10_000 {
return fmt.Errorf("minimum transfer bank tidak terpenuhi")
}
return nil
}
func (p BankTransferProvider) CalculateFee(amount float64) float64 { return 5_000 }
func (p BankTransferProvider) Process(amount float64) error { return processBankGateway(amount) }
func (p BankTransferProvider) Receipt(amount float64) string {
return fmt.Sprintf("Transfer Bank: Rp %.0f", amount)
}
func (p BankTransferProvider) LogTag() string { return "BT" }
// ✅ Menambahkan "crypto"? Cukup buat SATU file baru: crypto.go
// Tidak ada file lama yang perlu kita ubah isinya.
// --- crypto.go ---
type CryptoProvider struct{}
func (p CryptoProvider) Validate(amount float64) error {
if amount < 100_000 {
return fmt.Errorf("minimum crypto tidak terpenuhi")
}
return nil
}
func (p CryptoProvider) CalculateFee(amount float64) float64 { return amount * 0.01 }
func (p CryptoProvider) Process(amount float64) error { return processCryptoGateway(amount) }
func (p CryptoProvider) Receipt(amount float64) string {
return fmt.Sprintf("Pembayaran Crypto: %.8f BTC", amount/1_000_000_000)
}
func (p CryptoProvider) LogTag() string { return "CX" }
// --- payment_service.go ---
// Service ini hanya berinteraksi dengan interface — bebas dari switch statement.
type PaymentService struct {
providers map[string]PaymentProvider
}
func NewPaymentService() *PaymentService {
return &PaymentService{
providers: map[string]PaymentProvider{
"credit_card": CreditCardProvider{},
"bank_transfer": BankTransferProvider{},
"crypto": CryptoProvider{}, // daftarkan sekali di sini
},
}
}
func (s *PaymentService) Execute(method string, amount float64) error {
provider, ok := s.providers[method]
if !ok {
return fmt.Errorf("metode pembayaran tidak dikenal: %s", method)
}
if err := provider.Validate(amount); err != nil {
return fmt.Errorf("validasi gagal: %w", err)
}
fee := provider.CalculateFee(amount)
total := amount + fee
if err := provider.Process(total); err != nil {
return fmt.Errorf("proses pembayaran gagal: %w", err)
}
log.Printf("[%s] nominal=%.0f biaya=%.0f status=success receipt=%s",
provider.LogTag(), amount, fee, provider.Receipt(amount))
return nil
}
Hasil refactoring: Menambah metode pembayaran baru kini hanya membutuhkan dua langkah sederhana:
- Buat file
crypto.godan implementasikan interfacePaymentProvider. - Daftarkan di registry
NewPaymentService().
Selesai. Kamu tidak menyentuh kode router, validator, logger, ataupun billing fee bawaan. Selain itu, compiler Go akan langsung memprotes di awal jika kamu tidak mengimplementasikan salah satu fungsi wajib dari interface PaymentProvider (compile-time safety!).
3. Parallel Inheritance Hierarchies
Apa Itu?
Parallel Inheritance Hierarchies (Hierarki Pewarisan Paralel) sebenarnya adalah bentuk khusus dari Shotgun Surgery. Smell ini terjadi ketika setiap kali kamu membuat subclass di sebuah hierarki kelas, kamu secara paksa harus membuat subclass baru di hierarki kelas lain yang sejajar. Kedua pohon kelas ini tumbuh secara beriringan dan saling bergantung secara implisit.
Dalam bahasa pemrograman Go yang tidak mendukung pewarisan (inheritance) tradisional berbasis kelas, smell ini sering kali bermanifestasi dalam bentuk interface atau struct yang saling berpasangan di package berbeda (contoh: struct ShapeCircle pasangannya RendererCircle, AnimalDog pasangannya SoundDog).
Mengapa Bisa Terjadi?
Biasanya ini berawal dari niat baik untuk memisahkan tanggung jawab (misalnya memisahkan model geometri dengan cara penggambarannya). Namun, karena desainnya kurang matang dan tidak menggunakan polimorfisme/komposisi yang baik, kedua hierarki tersebut justru menjadi terikat mati.
Visualisasi Masalah
classDiagram
direction LR
class Shape {
+Area() float64
+Perimeter() float64
}
class Circle {
+Radius float64
}
class Rectangle {
+Width float64
+Height float64
}
class Triangle {
+Base float64
+Height float64
}
class ShapeRenderer {
+Render(shape Shape)
}
class CircleRenderer {
+Render(shape Shape)
}
class RectangleRenderer {
+Render(shape Shape)
}
class TriangleRenderer {
+Render(shape Shape)
}
Shape <|-- Circle
Shape <|-- Rectangle
Shape <|-- Triangle
ShapeRenderer <|-- CircleRenderer
ShapeRenderer <|-- RectangleRenderer
ShapeRenderer <|-- TriangleRenderer
Circle ..> CircleRenderer : membutuhkan
Rectangle ..> RectangleRenderer : membutuhkan
Triangle ..> TriangleRenderer : membutuhkan
Setiap pembuatan bentuk bangun datar baru memaksa pembuatan renderer baru. Kedua pohon struktur ini terus dipaksa berkembang sejajar.
Contoh Bad Code (❌)
Di bawah ini, menambahkan tipe bangun datar baru seperti Triangle mengharuskan kita membuat struct Triangle sekaligus TriangleRenderer.
// ❌ BAD: Dua hierarki yang berjalan paralel.
// Menambahkan Triangle mengharuskan kita membuat Triangle shape DAN TriangleRenderer.
package shape
import "fmt"
// --- Hierarki 1: Bentuk Bangun Datar ---
type Shape interface {
Area() float64
Perimeter() float64
ShapeType() string // ← dibutuhkan agar renderer tahu tipe bangun datar apa ini
}
type Circle struct {
Radius float64
}
func (c Circle) Area() float64 { return 3.14159 * c.Radius * c.Radius }
func (c Circle) Perimeter() float64 { return 2 * 3.14159 * c.Radius }
func (c Circle) ShapeType() string { return "circle" }
type Rectangle struct {
Width, Height float64
}
func (r Rectangle) Area() float64 { return r.Width * r.Height }
func (r Rectangle) Perimeter() float64 { return 2 * (r.Width + r.Height) }
func (r Rectangle) ShapeType() string { return "rectangle" }
// ❌ Menambahkan Triangle ke Hierarki 1...
type Triangle struct {
Base, Height, SideA, SideB, SideC float64
}
func (t Triangle) Area() float64 { return 0.5 * t.Base * t.Height }
func (t Triangle) Perimeter() float64 { return t.SideA + t.SideB + t.SideC }
func (t Triangle) ShapeType() string { return "triangle" }
// --- Hierarki 2: Renderer (paralel terhadap Shape) ---
type ShapeRenderer interface {
Render(s Shape) string
}
type CircleRenderer struct{}
func (r CircleRenderer) Render(s Shape) string {
c := s.(Circle) // type assertion — sangat rapuh!
return fmt.Sprintf("Menggambar lingkaran dengan radius=%.2f", c.Radius)
}
type RectangleRenderer struct{}
func (r RectangleRenderer) Render(s Shape) string {
rect := s.(Rectangle)
return fmt.Sprintf("Menggambar persegi panjang %.2f x %.2f", rect.Width, rect.Height)
}
// ❌ ...membuat kita WAJIB menambahkan TriangleRenderer ke Hierarki 2.
type TriangleRenderer struct{}
func (r TriangleRenderer) Render(s Shape) string {
t := s.(Triangle)
return fmt.Sprintf("Menggambar segitiga base=%.2f height=%.2f", t.Base, t.Height)
}
// Map registry untuk memetakan bentuk ke renderernya — code smell tambahan:
// setiap bentuk baru wajib mendaftarkan diri di sini.
func GetRenderer(s Shape) ShapeRenderer {
switch s.ShapeType() {
case "circle":
return CircleRenderer{}
case "rectangle":
return RectangleRenderer{}
case "triangle":
return TriangleRenderer{}
default:
panic(fmt.Sprintf("tidak ada renderer untuk tipe: %s", s.ShapeType()))
}
}
Mengapa kode di atas bermasalah:
- Kita harus membuat dua file/struct baru sekaligus untuk setiap modifikasi bisnis.
- Type assertion
s.(Circle)rawan memicu panic di run-time jika salah kirim objek. - Kedua hierarki terikat mati; jika lupa membuat renderer untuk salah satu bangun, aplikasi akan crash saat dijalankan.
Perbaikan (✅)
Pendekatan terbaik untuk mengatasi ini adalah menyembunyikan/menggabungkan perilakunya ke dalam struct utama (collapse hierarchy), atau membalikkan dependensi menggunakan komposisi.
// ✅ GOOD: Menggabungkan kedua hierarki.
// Bangun datar memiliki fungsi render-nya sendiri — tidak perlu hierarki terpisah.
package shape
import "fmt"
// Shape adalah satu-satunya interface. Ia tahu cara menghitung geometri
// sekaligus cara menggambarkan dirinya sendiri.
type Shape interface {
Area() float64
Perimeter() float64
Render() string
}
type Circle struct {
Radius float64
}
func (c Circle) Area() float64 { return 3.14159 * c.Radius * c.Radius }
func (c Circle) Perimeter() float64 { return 2 * 3.14159 * c.Radius }
func (c Circle) Render() string {
return fmt.Sprintf("○ Lingkaran — radius=%.2f, luas=%.2f", c.Radius, c.Area())
}
type Rectangle struct {
Width, Height float64
}
func (r Rectangle) Area() float64 { return r.Width * r.Height }
func (r Rectangle) Perimeter() float64 { return 2 * (r.Width + r.Height) }
func (r Rectangle) Render() string {
return fmt.Sprintf("▭ Persegi Panjang — %.2fx%.2f, luas=%.2f", r.Width, r.Height, r.Area())
}
// ✅ Menambahkan Triangle: cukup 1 tipe data baru yang mandiri.
// Tidak perlu class/struct renderer paralel lagi.
type Triangle struct {
Base, Height float64
SideA, SideB, SideC float64
}
func (t Triangle) Area() float64 { return 0.5 * t.Base * t.Height }
func (t Triangle) Perimeter() float64 { return t.SideA + t.SideB + t.SideC }
func (t Triangle) Render() string {
return fmt.Sprintf("△ Segitiga — base=%.2f height=%.2f, luas=%.2f", t.Base, t.Height, t.Area())
}
// Canvas hanya bekerja dengan interface Shape. Bebas switch case.
type Canvas struct {
shapes []Shape
}
func (c *Canvas) Add(s Shape) {
c.shapes = append(c.shapes, s)
}
func (c *Canvas) RenderAll() {
for _, s := range c.shapes {
fmt.Println(s.Render())
}
}
// Cara penggunaan yang aman, modular, dan bersih:
func ExampleCanvas() {
canvas := &Canvas{}
canvas.Add(Circle{Radius: 5})
canvas.Add(Rectangle{Width: 4, Height: 6})
canvas.Add(Triangle{Base: 3, Height: 4, SideA: 3, SideB: 4, SideC: 5})
canvas.RenderAll()
// Output:
// ○ Lingkaran — radius=5.00, luas=78.54
// ▭ Persegi Panjang — 4.00x6.00, luas=24.00
// △ Segitiga — base=3.00 height=4.00, luas=6.00
}
Jika kamu memang benar-benar membutuhkan rendering yang dinamis (misalnya render ke output PNG, SVG, atau Terminal teks), kamu tidak perlu membuat subclass renderer baru untuk setiap shape. Cukup buat satu renderer generic yang memproses data bawaan dari shape tersebut:
// ✅ Solusi Alternatif jika membutuhkan multi-target rendering:
// Gunakan satu Renderer tanpa melahirkan hierarki paralel.
type ShapeData struct {
Type string
Params map[string]float64
}
type Shape interface {
Area() float64
Perimeter() float64
Data() ShapeData // menyuplai deskripsi data terstruktur untuk dikonsumsi renderer
}
func (c Circle) Data() ShapeData {
return ShapeData{Type: "circle", Params: map[string]float64{"radius": c.Radius}}
}
func (r Rectangle) Data() ShapeData {
return ShapeData{Type: "rectangle", Params: map[string]float64{"width": r.Width, "height": r.Height}}
}
// Cukup buat SATU SVGRenderer untuk menangani seluruh jenis bangun datar.
type SVGRenderer struct{}
func (r SVGRenderer) Render(d ShapeData) string {
switch d.Type {
case "circle":
return fmt.Sprintf(`<circle r="%.2f"/>`, d.Params["radius"])
case "rectangle":
return fmt.Sprintf(`<rect width="%.2f" height="%.2f"/>`,
d.Params["width"], d.Params["height"])
default:
return fmt.Sprintf(`<!-- shape tidak dikenal: %s -->`, d.Type)
}
}
Perbedaannya sangat krusial: sekarang kita hanya memiliki satu SVGRenderer, bukan satu renderer per bangun datar. Jika kita menambah bangun datar baru, kita hanya perlu mengupdate logic case di dalam SVGRenderer, tanpa harus melahirkan class/struct renderer baru.
Cara Mendeteksi Change Preventers Saat Code Review
Gunakan alur panduan berikut saat kamu meninjau Pull Request (PR) rekan timmu:
flowchart TD
A["Review PR Baru"] --> B{"Apakah satu file berubah\nuntuk banyak alasan berbeda?"}
B -- Ya --> C["🔴 Divergent Change\nSolusi: Pecah menjadi beberapa service/package"]
B -- No --> D{"Apakah satu perubahan logis\nmengharuskan edit di 3+ file berbeda?"}
D -- Yes --> E["🔴 Shotgun Surgery\nSolusi: Satukan perilaku di balik interface/struct"]
D -- No --> F{"Apakah menambah tipe baru\nmemaksa buat tipe baru lainnya\ndi hierarki berbeda?"}
F -- Yes --> G["🔴 Parallel Inheritance\nSolusi: Satukan dengan komposisi/interface"]
F -- No --> H["✅ Bersih dari Change Preventers\nLanjutkan review"]
Ringkasan Strategi Refactoring
| Code Smell | Refactoring Utama | Teknik di Go |
|---|---|---|
| Divergent Change | Split Class / Extract Service | Pisahkan package, buat struct yang lebih spesifik |
| Shotgun Surgery | Move Method / Inline Class | Definisikan interface tunggal; sembunyikan switch case |
| Parallel Inheritance | Collapse Hierarchy | Satukan behavior ke dalam tipe asli; gunakan komposisi |
Langkah aman melakukan Refactoring:
- Tulis characterization tests untuk memastikan perilaku fitur yang ada saat ini tidak berubah.
- Definisikan interface baru atau pecah struct secara perlahan.
- Pindahkan logika (fungsi/method) satu per satu.
- Jalankan pengujian unit test setelah setiap kali memindahkan sebaris kode.
- Hapus kode lama hanya ketika semua rangkaian pengujian berjalan sukses (green).
📝 Ringkasan
Change Preventers adalah musuh utama dari agilitas sebuah codebase. Mereka membuat para developer takut untuk menyentuh kode karena takut merusak bagian sistem yang lain. Mari kita rekap poin pentingnya:
- 🔀 Divergent Change — Satu struct memiliki terlalu banyak alasan untuk berubah. Obatnya: Pecah menjadi struct-struct kecil yang memiliki satu tugas terfokus.
- 💥 Shotgun Surgery — Satu tugas tersebar luas di berbagai file. Obatnya: Satukan kembali seluruh varian fungsi di balik sebuah interface yang solid, hilangkan switch case yang redundan.
- 🪞 Parallel Inheritance — Struktur class ganda yang harus berkembang berdampingan secara paksa. Obatnya: Hancurkan paralelisme tersebut dengan menggabungkan perilakunya ke struct asli atau gunakan komposisi data yang modular.
- 📐 Prinsip Utama: Usahakan agar satu alasan perubahan hanya berdampak pada satu tempat di kode. Itulah inti dari Single Responsibility Principle (SRP) yang sesungguhnya.
| 🇮🇩 Versi Indonesia | 🇬🇧 English Version |