SRP — Single Responsibility Principle #

Ada satu file di hampir setiap codebase yang semua engineer takut untuk diubah. Namanya sering berakhiran Service atau Manager, isinya ribuan baris, dan setiap kali ada bug baru, investigasinya selalu berakhir di sana. File itu bukan hasil keputusan yang disengaja — ia tumbuh secara organik karena setiap fitur baru ditambahkan ke tempat yang paling mudah dijangkau, dan tempat yang paling mudah dijangkau selalu adalah file yang sudah ada. Inilah yang terjadi ketika Single Responsibility Principle dilanggar secara konsisten. SRP adalah prinsip pertama dari SOLID yang paling langsung memengaruhi kualitas kode sehari-hari: sebuah modul hanya boleh memiliki satu alasan untuk berubah. Bukan satu method, bukan satu file — tapi satu alasan, satu aktor, satu domain perubahan. Artikel ini membahas apa yang benar-benar dimaksud definisi itu, bagaimana mengenali pelanggaran SRP sebelum ia menjadi masalah besar, cara refactor yang bertahap dan aman, dan kapan pemisahan tanggung jawab justru terlalu jauh.

Makna Sebenarnya “Satu Alasan untuk Berubah” #

Definisi klasik SRP dari Robert C. Martin:

A module should have one, and only one, reason to change.

Banyak yang menginterpretasikan ini sebagai “satu class harus kecil” atau “satu function hanya boleh melakukan satu hal”. Keduanya bukan definisi yang tepat. Yang dimaksud alasan untuk berubah adalah aktor atau stakeholder — pihak yang kebutuhannya bisa memaksa modul itu dimodifikasi.

Jika sebuah UserService bisa dipaksa berubah oleh:

  • Tim produk yang mengubah aturan bisnis registrasi
  • Tim infrastruktur yang mengganti database engine
  • Tim marketing yang mengubah template email selamat datang
  • Tim DevOps yang mengubah format logging

Maka UserService memiliki empat alasan untuk berubah — dan itu berarti empat tanggung jawab yang seharusnya dipisahkan.

Tes SRP yang paling praktis:

Tulis kalimat: "Modul X harus diubah jika..."

  Contoh yang melanggar SRP:
  "UserService harus diubah jika:
   - aturan validasi email berubah (tim produk)
   - schema database berubah (tim infrastruktur)
   - template email selamat datang berubah (tim marketing)
   - format log berubah (tim DevOps)"
  → Empat aktor = empat tanggung jawab = SRP dilanggar

  Contoh yang mengikuti SRP:
  "UserValidator harus diubah jika aturan validasi user berubah"
  "UserRepository harus diubah jika schema database user berubah"
  "WelcomeEmailSender harus diubah jika template email selamat datang berubah"
  → Satu aktor per modul = SRP terpenuhi

SRP juga berlaku di level yang lebih kecil dari struct — ia berlaku untuk fungsi, package, bahkan file. Sebuah fungsi yang melakukan validasi, transformasi, dan side effect sekaligus dalam satu blok linear adalah pelanggaran SRP di skala mikro.


Empat Dampak Nyata Pelanggaran SRP #

Pelanggaran SRP tidak hanya masalah estetika kode. Ia menghasilkan biaya nyata yang terasa semakin berat seiring waktu:

1. Perubahan yang tidak terduga. Ketika semua tanggung jawab ada di satu tempat, perubahan kecil di satu area bisa berdampak ke area lain secara tidak sengaja. Mengganti format log menyebabkan bug di business logic karena keduanya ada di file yang sama dan berbagi state. Unit test untuk validasi tiba-tiba gagal karena ada perubahan di fungsi penyimpanan database.

2. Test yang membutuhkan setup kompleks. Jika sebuah struct melakukan validasi, database access, dan email sending sekaligus, unit test untuk logika validasinya harus menyiapkan mock untuk database dan email server — meski keduanya tidak relevan untuk test yang sedang ditulis. Setiap test menjadi integration test secara tidak sengaja.

3. Merge conflict yang terus berulang. Ketika dua engineer mengerjakan fitur yang berbeda tetapi keduanya perlu memodifikasi file yang sama — misalnya satu mengubah validasi dan yang lain mengubah format email — merge conflict muncul bukan karena mereka mengerjakan hal yang sama, tapi karena tanggung jawab yang berbeda tersimpan di tempat yang sama.

4. Onboarding yang lambat. Engineer baru harus memahami seluruh kompleksitas sebuah GodService hanya untuk membuat perubahan kecil di salah satu tanggung jawabnya. Tidak ada batas yang jelas antara “ini bagian yang relevan” dan “ini detail yang tidak perlu dipahami sekarang”.


Mengenali Pelanggaran SRP #

Sebelum bisa memperbaiki, perlu bisa mengenali. Ini adalah sinyal yang paling sering muncul:

RED FLAG DI LEVEL STRUCT:
  ✗ Nama yang terlalu generik: UserService, DataManager, CoreProcessor
  ✗ Constructor dengan lebih dari 4–5 dependency
  ✗ Field yang tidak digunakan oleh semua method
  ✗ Method yang hanya relevan dalam konteks tertentu

RED FLAG DI LEVEL METHOD:
  ✗ Nama method mengandung "And": validateAndSave(), parseAndSend()
  ✗ Satu method lebih dari 30–40 baris dengan logika yang berbeda-beda
  ✗ Banyak komentar "// bagian validasi", "// bagian database",
    "// bagian notifikasi" — tanda seharusnya dipisah jadi method berbeda

RED FLAG DI LEVEL FILE:
  ✗ Satu file yang diubah dalam commit untuk alasan yang berbeda-beda
  ✗ Satu file yang muncul di hampir setiap PR karena "semua perlu melaluinya"
  ✗ Import list yang panjang dan tidak kohesif: database, smtp, http, json, pdf

RED FLAG DI LEVEL PACKAGE:
  ✗ Package bernama "util", "helper", atau "common" yang menampung segalanya
  ✗ Package yang di-import oleh hampir semua package lain
    (sinyal package itu terlalu banyak tahu)

Dari GodService ke Komponen Terfokus — Refactor Bertahap #

Contoh paling representatif: UserService yang melakukan terlalu banyak hal.

// ANTI-PATTERN: UserService dengan empat tanggung jawab berbeda
// Empat aktor berbeda bisa memaksanya berubah
package user

import (
    "database/sql"
    "fmt"
    "net/smtp"
    "regexp"
)

type UserService struct {
    db *sql.DB
}

func (s *UserService) Register(name, email, password string) error {
    // === TANGGUNG JAWAB 1: Validasi ===
    // Jika aturan validasi berubah → UserService harus diubah
    if name == "" {
        return fmt.Errorf("name is required")
    }
    emailRegex := regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
    if !emailRegex.MatchString(email) {
        return fmt.Errorf("invalid email format")
    }
    if len(password) < 8 {
        return fmt.Errorf("password must be at least 8 characters")
    }

    // === TANGGUNG JAWAB 2: Hash password ===
    // Jika algoritma hashing berubah → UserService harus diubah
    hashedPassword := fmt.Sprintf("hashed_%s", password) // simplified

    // === TANGGUNG JAWAB 3: Akses database ===
    // Jika schema atau database engine berubah → UserService harus diubah
    var exists bool
    s.db.QueryRow("SELECT EXISTS(SELECT 1 FROM users WHERE email = ?)", email).Scan(&exists)
    if exists {
        return fmt.Errorf("email already registered")
    }
    _, err := s.db.Exec(
        "INSERT INTO users (name, email, password_hash) VALUES (?, ?, ?)",
        name, email, hashedPassword,
    )
    if err != nil {
        return fmt.Errorf("failed to save user: %w", err)
    }

    // === TANGGUNG JAWAB 4: Email notification ===
    // Jika template atau provider email berubah → UserService harus diubah
    auth := smtp.PlainAuth("", "[email protected]", "password", "smtp.example.com")
    msg := []byte("Subject: Welcome!\r\n\r\nWelcome to our platform, " + name + "!")
    smtp.SendMail("smtp.example.com:587", auth, "[email protected]", []string{email}, msg)

    // === TANGGUNG JAWAB 5: Logging ===
    // Jika format atau sistem logging berubah → UserService harus diubah
    fmt.Printf("[INFO] %s: user registered successfully: %s\n",
        time.Now().Format(time.RFC3339), email)

    return nil
}

Untuk memecahnya, lakukan secara bertahap — satu tanggung jawab per langkah:

Langkah 1: Ekstrak validasi ke UserValidator

// internal/user/validator.go
package user

import (
    "errors"
    "regexp"
)

var emailRegex = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)

type UserValidator struct{}

func (v UserValidator) ValidateRegistration(name, email, password string) error {
    if name == "" {
        return errors.New("name is required")
    }
    if !emailRegex.MatchString(email) {
        return errors.New("invalid email format")
    }
    if len(password) < 8 {
        return errors.New("password must be at least 8 characters")
    }
    return nil
}
// Satu-satunya alasan untuk berubah: aturan validasi user berubah

Langkah 2: Ekstrak akses data ke UserRepository

// internal/user/repository.go
package user

import (
    "context"
    "database/sql"
    "errors"
    "fmt"
)

var ErrEmailExists = errors.New("email already registered")

type UserRepository struct {
    db *sql.DB
}

func NewUserRepository(db *sql.DB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) ExistsByEmail(ctx context.Context, email string) (bool, error) {
    var exists bool
    err := r.db.QueryRowContext(ctx,
        "SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)", email,
    ).Scan(&exists)
    return exists, err
}

func (r *UserRepository) Save(ctx context.Context, u NewUser) error {
    _, err := r.db.ExecContext(ctx,
        "INSERT INTO users (name, email, password_hash) VALUES ($1, $2, $3)",
        u.Name, u.Email, u.PasswordHash,
    )
    if err != nil {
        return fmt.Errorf("save user: %w", err)
    }
    return nil
}
// Satu-satunya alasan untuk berubah: schema database atau database engine berubah

Langkah 3: Ekstrak notifikasi ke WelcomeNotifier

// internal/notification/welcome.go
package notification

import "context"

type WelcomeNotifier interface {
    SendWelcome(ctx context.Context, name, email string) error
}

// SMTPWelcomeNotifier: implementasi dengan SMTP
type SMTPWelcomeNotifier struct {
    host     string
    port     int
    username string
    password string
    from     string
}

func (n *SMTPWelcomeNotifier) SendWelcome(ctx context.Context, name, email string) error {
    subject := "Selamat datang di platform kami!"
    body := fmt.Sprintf("Halo %s, akun kamu sudah berhasil dibuat.", name)
    return n.sendEmail(email, subject, body)
}
// Satu-satunya alasan untuk berubah: cara atau template email selamat datang berubah

Langkah 4: Gunakan slog standar untuk logging — tidak perlu wrapper custom

// Go 1.21+ sudah punya structured logging bawaan
// Tidak perlu Logger struct sendiri untuk kasus sederhana
import "log/slog"

slog.Info("user registered", "email", email, "name", name)

Langkah 5: UserService hanya sebagai orkestrator

// internal/user/service.go
package user

import (
    "context"
    "fmt"
    "log/slog"

    "github.com/example/app/internal/notification"
)

type PasswordHasher interface {
    Hash(password string) (string, error)
}

type UserService struct {
    validator UserValidator
    repo      *UserRepository
    notifier  notification.WelcomeNotifier
    hasher    PasswordHasher
}

func NewUserService(
    repo *UserRepository,
    notifier notification.WelcomeNotifier,
    hasher PasswordHasher,
) *UserService {
    return &UserService{
        validator: UserValidator{},
        repo:      repo,
        notifier:  notifier,
        hasher:    hasher,
    }
}

func (s *UserService) Register(ctx context.Context, name, email, password string) error {
    // Validasi — delegasi ke UserValidator
    if err := s.validator.ValidateRegistration(name, email, password); err != nil {
        return fmt.Errorf("register: validation: %w", err)
    }

    // Cek duplikat — delegasi ke UserRepository
    exists, err := s.repo.ExistsByEmail(ctx, email)
    if err != nil {
        return fmt.Errorf("register: check email: %w", err)
    }
    if exists {
        return ErrEmailExists
    }

    // Hash password — delegasi ke PasswordHasher
    hash, err := s.hasher.Hash(password)
    if err != nil {
        return fmt.Errorf("register: hash password: %w", err)
    }

    // Simpan — delegasi ke UserRepository
    if err := s.repo.Save(ctx, NewUser{Name: name, Email: email, PasswordHash: hash}); err != nil {
        return fmt.Errorf("register: save: %w", err)
    }

    // Notifikasi — delegasi ke WelcomeNotifier (async agar tidak memblok)
    go func() {
        if err := s.notifier.SendWelcome(context.Background(), name, email); err != nil {
            slog.Error("failed to send welcome email", "email", email, "error", err)
        }
    }()

    slog.Info("user registered", "email", email)
    return nil
}
// Satu-satunya alasan untuk berubah: alur bisnis registrasi user berubah
flowchart TD
    REQ["HTTP Handler\n(menerima request)"]
    SVC["UserService\n(orkestrator alur bisnis)"]
    VAL["UserValidator\n(aturan validasi)"]
    REPO["UserRepository\n(akses database)"]
    HASH["PasswordHasher\n(algoritma hashing)"]
    NOTIF["WelcomeNotifier\n(template + provider email)"]

    REQ -->|"Register(ctx, name, email, pass)"| SVC
    SVC -->|"ValidateRegistration(...)"| VAL
    SVC -->|"ExistsByEmail(...)"| REPO
    SVC -->|"Hash(password)"| HASH
    SVC -->|"Save(...)"| REPO
    SVC -->|"SendWelcome(...) async"| NOTIF

    style SVC fill:#4C9BE8,color:#fff
    style VAL fill:#5CB85C,color:#fff
    style REPO fill:#5CB85C,color:#fff
    style HASH fill:#5CB85C,color:#fff
    style NOTIF fill:#5CB85C,color:#fff

Hasilnya: perubahan template email hanya menyentuh SMTPWelcomeNotifier. Perubahan schema database hanya menyentuh UserRepository. Perubahan aturan validasi hanya menyentuh UserValidator. UserService hanya diubah jika alur bisnis registrasi itu sendiri berubah — itulah satu-satunya alasan perubahan yang sah untuknya.


SRP di Level Fungsi #

SRP tidak hanya berlaku untuk struct — ia juga berlaku untuk fungsi. Sebuah fungsi yang melakukan beberapa hal sekaligus adalah pelanggaran SRP di skala mikro, dan dampaknya sama: sulit ditest, sulit dibaca, mudah rusak.

// ANTI-PATTERN: satu fungsi melakukan parsing, validasi, dan transformasi sekaligus
func processOrderRequest(body []byte) (*Order, error) {
    // Parsing
    var req struct {
        UserID string      `json:"user_id"`
        Items  []OrderItem `json:"items"`
        Note   string      `json:"note"`
    }
    if err := json.Unmarshal(body, &req); err != nil {
        return nil, fmt.Errorf("invalid JSON: %w", err)
    }

    // Validasi
    if req.UserID == "" {
        return nil, errors.New("user_id is required")
    }
    if len(req.Items) == 0 {
        return nil, errors.New("at least one item is required")
    }
    for _, item := range req.Items {
        if item.Quantity <= 0 {
            return nil, fmt.Errorf("invalid quantity for item %s", item.ProductID)
        }
    }

    // Transformasi ke domain model
    items := make([]DomainItem, len(req.Items))
    for i, item := range req.Items {
        items[i] = DomainItem{
            ProductID: item.ProductID,
            Quantity:  item.Quantity,
            UnitPrice: item.UnitPrice,
        }
    }

    return &Order{UserID: req.UserID, Items: items, Note: req.Note}, nil
}

// BENAR: setiap fungsi satu tanggung jawab, mudah ditest secara independen
type CreateOrderRequest struct {
    UserID string      `json:"user_id"`
    Items  []OrderItem `json:"items"`
    Note   string      `json:"note"`
}

func parseCreateOrderRequest(body []byte) (CreateOrderRequest, error) {
    var req CreateOrderRequest
    if err := json.Unmarshal(body, &req); err != nil {
        return req, fmt.Errorf("parse order request: %w", err)
    }
    return req, nil
}

func validateCreateOrderRequest(req CreateOrderRequest) error {
    if req.UserID == "" {
        return errors.New("user_id is required")
    }
    if len(req.Items) == 0 {
        return errors.New("at least one item is required")
    }
    for _, item := range req.Items {
        if item.Quantity <= 0 {
            return fmt.Errorf("invalid quantity for item %s", item.ProductID)
        }
    }
    return nil
}

func toOrderDomain(req CreateOrderRequest) Order {
    items := make([]DomainItem, len(req.Items))
    for i, item := range req.Items {
        items[i] = DomainItem{
            ProductID: item.ProductID,
            Quantity:  item.Quantity,
            UnitPrice: item.UnitPrice,
        }
    }
    return Order{UserID: req.UserID, Items: items, Note: req.Note}
}

// Handler menggunakan ketiganya secara berurutan — alur jelas
func CreateOrderHandler(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)

    req, err := parseCreateOrderRequest(body)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    if err := validateCreateOrderRequest(req); err != nil {
        http.Error(w, err.Error(), http.StatusUnprocessableEntity)
        return
    }

    order := toOrderDomain(req)
    // lanjut ke service...
}

Memisahkan ketiga fungsi ini memberikan keuntungan konkret di testing: test untuk validateCreateOrderRequest bisa dijalankan tanpa menyentuh JSON parsing sama sekali — dan sebaliknya.


SRP di Level Package #

Di Go, package adalah unit organisasi kode yang paling penting. SRP di level package berarti setiap package punya fokus yang jelas — dan bisa dideskripsikan dalam satu kalimat tanpa kata “dan”.

ANTI-PATTERN: package "utils" yang menampung segalanya

internal/utils/
  ├── email.go        // kirim email
  ├── pdf.go          // generate PDF
  ├── hash.go         // bcrypt
  ├── jwt.go          // token generation
  ├── pagination.go   // pagination helper
  ├── validation.go   // semua validasi
  ├── formatter.go    // format currency, date
  └── http.go         // HTTP helpers

Masalah:
  - Package ini di-import oleh hampir semua package lain
  - Perubahan di email.go bisa memaksa recompile seluruh dependency tree
  - Tidak ada boundary yang jelas — "utils" artinya apa saja
  - Onboarding sulit: engineer baru tidak tahu harus mencari apa di mana

BENAR: setiap package punya tanggung jawab tunggal yang jelas

internal/
  ├── notification/
  │   ├── email.go         // ← "package yang mengirim email"
  │   └── sms.go
  ├── document/
  │   └── pdf.go           // ← "package yang generate dokumen PDF"
  ├── auth/
  │   ├── password.go      // ← "package yang menangani auth"
  │   └── token.go
  ├── pagination/
  │   └── pagination.go    // ← "package yang menangani paginasi"
  └── money/
      └── formatter.go     // ← "package yang memformat nilai moneter"

Package yang namanya util, helper, atau common adalah sinyal kuat SRP dilanggar di level package. Package yang baik bisa dideskripsikan dengan kata benda domain: notification, auth, document, pricing, inventory.


SRP dan Testability #

Salah satu cara paling cepat untuk mengukur apakah SRP terpenuhi adalah melihat seberapa mudah komponen bisa ditest secara independen.

// Ketika SRP terpenuhi, setiap komponen mudah ditest sendiri:

// Test UserValidator — tidak butuh database, tidak butuh email server
func TestUserValidator_ValidateRegistration(t *testing.T) {
    v := UserValidator{}

    tests := []struct {
        name     string
        input    [3]string // name, email, password
        wantErr  bool
    }{
        {"valid input", [3]string{"Budi", "[email protected]", "password123"}, false},
        {"empty name", [3]string{"", "[email protected]", "password123"}, true},
        {"invalid email", [3]string{"Budi", "not-an-email", "password123"}, true},
        {"short password", [3]string{"Budi", "[email protected]", "short"}, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := v.ValidateRegistration(tt.input[0], tt.input[1], tt.input[2])
            if (err != nil) != tt.wantErr {
                t.Errorf("ValidateRegistration() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

// Test UserService — inject fake untuk semua dependency
func TestUserService_Register(t *testing.T) {
    fakeRepo := &fakeUserRepository{}
    fakeNotifier := &fakeWelcomeNotifier{}
    fakeHasher := &fakePasswordHasher{hash: "hashed_password"}

    svc := NewUserService(fakeRepo, fakeNotifier, fakeHasher)

    err := svc.Register(context.Background(), "Budi", "[email protected]", "password123")
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if len(fakeRepo.saved) != 1 {
        t.Error("expected one user to be saved")
    }
    // Tidak butuh database nyata, tidak butuh SMTP server
}

Ketika menulis test dan kamu menemukan diri sedang menyiapkan mock untuk dependency yang tidak relevan dengan aspek yang sedang ditest — itu sinyal kuat SRP dilanggar. Test yang bersih adalah bukti bahwa tanggung jawab sudah terpisah dengan benar.


SRP dan Merge Conflict #

Salah satu dampak SRP yang jarang disebut tapi sangat nyata dirasakan tim adalah pengurangan merge conflict. Ketika tanggung jawab terpisah menjadi file yang berbeda, dua engineer yang mengerjakan hal yang berbeda hampir tidak pernah perlu menyentuh file yang sama.

flowchart LR
    subgraph ANTI["Tanpa SRP — Satu File"]
        US["user_service.go\n(validasi + DB + email + log)"]
        E1["Engineer A\n(ubah template email)"] -->|"edit"| US
        E2["Engineer B\n(ubah validasi password)"] -->|"edit"| US
        US -->|"MERGE CONFLICT\nbukan karena\nkerjaan sama"| MC["💥 Conflict"]
    end

    subgraph SRP_GOOD["Dengan SRP — File Terpisah"]
        VAL2["validator.go"]
        NOTIF2["welcome_notifier.go"]
        E3["Engineer A\n(ubah template email)"] -->|"edit"| NOTIF2
        E4["Engineer B\n(ubah validasi password)"] -->|"edit"| VAL2
        NOTIF2 & VAL2 -->|"No conflict —\nbeda file"| OK["✓ Clean merge"]
    end

    style MC fill:#D9534F,color:#fff
    style OK fill:#5CB85C,color:#fff

Ini bukan kebetulan — ini adalah konsekuensi langsung dari SRP. Ketika setiap komponen punya satu alasan untuk berubah, dua perubahan dengan alasan yang berbeda tidak pernah perlu menyentuh file yang sama.


Kapan Pemisahan Justru Berlebihan #

SRP bukan berarti setiap fungsi harus jadi struct, setiap struct harus jadi package, atau setiap package harus jadi service terpisah. Ada titik di mana over-decomposition justru merusak keterbacaan.

PEMISAHAN YANG BERLEBIHAN:

// Tidak perlu struct untuk sesuatu yang cukup jadi fungsi
type AgeValidator struct{}
func (v AgeValidator) IsAdult(age int) bool { return age >= 18 }
// → Cukup jadi fungsi: func isAdult(age int) bool { return age >= 18 }

// Tidak perlu interface untuk sesuatu yang tidak akan di-swap atau di-mock
type FileReader interface { Read(path string) ([]byte, error) }
type DefaultFileReader struct{}
func (r DefaultFileReader) Read(path string) ([]byte, error) { return os.ReadFile(path) }
// → Cukup panggil os.ReadFile langsung

// Tidak perlu layer UseCase yang hanya meneruskan panggilan ke Repository
type GetUserUseCase struct{ repo UserRepository }
func (u *GetUserUseCase) Execute(id string) (*User, error) {
    return u.repo.FindByID(id) // tidak ada logic tambahan
}
// → Ini pass-through layer, melanggar KISS bukan memenuhi SRP

PANDUAN PRAKTIS:
  Pisahkan ketika ada alasan untuk berubah yang berbeda — bukan hanya karena
  "seharusnya dipisah". Jika satu tanggung jawab diubah tidak pernah memaksa
  yang lain berubah, dan keduanya sudah di satu tempat, tidak ada urgensi
  untuk memisahnya.
Over-decomposition sama buruknya dengan GodObject. Ketika sebuah alur sederhana tersebar di 10 file kecil yang saling memanggil, pembaca harus melompat ke sana-sini hanya untuk memahami satu flow. SRP adalah tentang kohesi yang tepat — bukan jumlah file yang maksimal.

Anti-Pattern dalam Satu Pandangan #

// ✗ GodService — terlalu banyak tanggung jawab dalam satu struct
type UserService struct {
    db   *sql.DB
    smtp *smtp.Client
    s3   *s3.Client
    pdf  *pdf.Generator
    // 8 dependency lagi yang tidak semua method butuhkan
}

// ✗ Method "And" — nama yang mengungkapkan lebih dari satu tanggung jawab
func (s *UserService) ValidateAndSaveAndNotify(user User) error { ... }

// ✗ Package "util" yang menampung semua yang tidak ada tempat lain
package util
// email, pdf, hash, pagination, validation, formatter — semua di sini

// ✗ Satu fungsi yang melakukan parsing + validasi + transformasi
func processRequest(body []byte) (*DomainModel, error) {
    // 50 baris yang melakukan tiga hal sekaligus
}

// ✗ Logging, business logic, dan database access bercampur
func (s *Service) DoSomething() error {
    log.Info("starting")
    result := s.db.Query("SELECT ...")
    if result.Error != nil {
        log.Error("db error")
        sendAlert() // side effect di tengah business logic
    }
    log.Info("done")
    return nil
}

Checklist Review SRP #

STRUCT DAN SERVICE:
  □ Nama struct mencerminkan satu domain atau tanggung jawab spesifik
  □ Constructor tidak memiliki lebih dari 4–5 dependency
  □ Semua field digunakan oleh lebih dari satu method
  □ Bisa menjawab "satu-satunya alasan untuk mengubah struct ini adalah..."
    tanpa kata "atau"

METHOD DAN FUNGSI:
  □ Tidak ada nama method yang mengandung "And"
  □ Fungsi tidak lebih dari ~30 baris kecuali ada justifikasi kuat
  □ Tidak ada komentar blok "// bagian X" di tengah fungsi —
    setiap bagian seharusnya jadi fungsi terpisah

PACKAGE:
  □ Package bisa dideskripsikan dalam satu kalimat tanpa kata "dan"
  □ Package tidak bernama "util", "helper", atau "common"
  □ Package tidak di-import oleh hampir semua package lain
  □ Setiap file dalam package relevan dengan satu domain

TESTABILITY:
  □ Unit test tidak butuh mock untuk dependency yang tidak relevan
  □ Test bisa ditulis tanpa setup database atau external service
    kecuali yang memang sedang ditest (integration test)
  □ Setiap komponen bisa ditest secara independen

Ringkasan #

  • SRP bukan tentang ukuran — bukan “satu method satu baris” atau “file harus kecil”. SRP tentang alasan untuk berubah: sebuah modul hanya boleh dipaksa berubah oleh satu aktor atau satu domain perubahan.
  • Tes SRP yang paling praktis: tulis “modul X harus diubah jika…” — jika kalimatnya butuh kata “atau”, SRP dilanggar.
  • Empat dampak nyata: perubahan yang tidak terduga karena shared state, test yang butuh setup kompleks untuk dependency yang tidak relevan, merge conflict yang terjadi bukan karena kerjaan sama, dan onboarding yang lambat.
  • Dari GodService ke komponen terfokus: pecah secara bertahap — UserValidator untuk validasi, UserRepository untuk akses data, WelcomeNotifier untuk notifikasi, PasswordHasher untuk keamanan. UserService hanya sebagai orkestrator alur bisnis.
  • SRP di level fungsi: pisahkan parsing, validasi, dan transformasi menjadi fungsi terpisah. Nama yang mengandung “And” adalah tanda yang perlu dipisah.
  • SRP di level package: hindari package util, helper, common. Setiap package bisa dideskripsikan dengan kata benda domain yang jelas.
  • SRP dan testability: komponen yang memenuhi SRP bisa ditest secara independen tanpa menyiapkan infrastruktur yang tidak relevan.
  • SRP dan merge conflict: ketika setiap komponen punya satu alasan untuk berubah, dua perubahan dengan alasan berbeda hampir tidak pernah menyentuh file yang sama.
  • Hindari over-decomposition: pass-through layer tanpa logic adalah pelanggaran KISS, bukan penerapan SRP. Pisahkan ketika ada perbedaan nyata dalam alasan perubahan — bukan hanya karena “seharusnya dipisah”.

← Sebelumnya: YAGNI   Berikutnya: SSOT →

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