Single Responsibility Principle #

Ketika sebuah class berubah karena format email berubah, karena schema database berubah, karena aturan validasi berubah, dan karena format log berubah — itu tanda jelas ada yang salah. Class tersebut menanggung terlalu banyak tanggung jawab, dan setiap perubahan di satu tanggung jawab berpotensi merusak yang lain. Single Responsibility Principle (SRP) adalah solusi untuk masalah ini: setiap modul hanya boleh memiliki satu alasan untuk berubah. Ini adalah huruf S pertama dari SOLID, dan sering menjadi fondasi yang memungkinkan prinsip-prinsip lain diterapkan. Panduan ini membahas SRP dari definisi yang sering disalahpahami, cara mendeteksi pelanggaran dengan tes “reason to change”, refactoring God Class menjadi komponen terpisah di Go dan Dart, SRP pada level package dan arsitektur, dampaknya pada testability, hingga kapan SRP perlu diseimbangkan dengan pragmatisme.

Apa Itu Single Responsibility Principle? #

SRP didefinisikan oleh Robert C. Martin:

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

Kata kunci yang sering dilewatkan: reason to change, bukan “satu method” atau “kode yang kecil”. Sebuah class bisa memiliki 20 method dan masih mengikuti SRP jika semua method tersebut melayani satu tanggung jawab yang sama. Sebaliknya, class dengan 3 method bisa melanggar SRP jika ketiga method tersebut melayani tiga concern yang berbeda.

Cara termudah menerapkan tes SRP: tulis kalimat “Komponen ini berubah jika…”. Jika kalimat itu perlu disambung dengan “atau” untuk mendeskripsikan kondisi lain yang tidak berkaitan — SRP dilanggar.

// Tes reason-to-change:

// "UserService berubah jika..."
// "...aturan validasi email berubah"     ← reason 1
// "...schema tabel users berubah"        ← reason 2
// "...format welcome email berubah"      ← reason 3
// "...format log message berubah"        ← reason 4
// "...library SMTP berganti"             ← reason 5

// → 5 alasan untuk berubah = SRP dilanggar

// "UserValidator berubah jika..."
// "...aturan validasi input user berubah"

// → 1 alasan = SRP terpenuhi

SRP bukan tentang kesempurnaan dekomposisi — ini tentang mengelompokkan hal yang berubah karena alasan yang sama, dan memisahkan hal yang berubah karena alasan yang berbeda.


Mendeteksi Pelanggaran SRP — God Class #

Pelanggaran SRP paling umum adalah God Class: satu class yang tahu terlalu banyak dan melakukan terlalu banyak.

// ANTI-PATTERN: UserService yang menanggung 5 tanggung jawab berbeda
type UserService struct {
    db *sql.DB
}

func (s *UserService) Register(name, email, password string) error {
    // Tanggung jawab 1: Validasi input
    if name == "" || email == "" {
        return errors.New("name and email are required")
    }
    if !strings.Contains(email, "@") {
        return errors.New("invalid email format")
    }
    if len(password) < 8 {
        return errors.New("password must be at least 8 characters")
    }

    // Tanggung jawab 2: Hashing password (infrastruktur/security)
    hashedPwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
    if err != nil {
        return fmt.Errorf("hash password: %w", err)
    }

    // Tanggung jawab 3: Akses database (infrastruktur)
    _, err = s.db.Exec(
        "INSERT INTO users (name, email, password_hash) VALUES (?, ?, ?)",
        name, email, string(hashedPwd),
    )
    if err != nil {
        return fmt.Errorf("save user: %w", err)
    }

    // Tanggung jawab 4: Mengirim email (side effect eksternal)
    smtpClient := smtp.NewClient("smtp.gmail.com:587")
    smtpClient.Send(email, "Welcome!", "Thanks for registering, "+name)

    // Tanggung jawab 5: Logging (cross-cutting concern)
    log.Printf("[INFO] User registered: %s (%s)", name, email)

    return nil
}

Konsekuensinya: untuk unit test Register, kamu butuh database nyata, SMTP server nyata, dan tidak bisa isolasi kegagalan. Jika format welcome email berubah, UserService harus diubah — padahal perubahan itu tidak ada hubungannya dengan business logic registrasi.


Refactoring God Class — Pisahkan Tanggung Jawab #

// Setiap komponen punya satu tanggung jawab yang jelas

// Tanggung jawab 1: Validasi input user
type UserValidator struct{}

func (v *UserValidator) ValidateRegistration(name, email, password string) error {
    if name == "" {
        return errors.New("name is required")
    }
    if !isValidEmail(email) {
        return errors.New("invalid email format")
    }
    if len(password) < 8 {
        return errors.New("password must be at least 8 characters")
    }
    return nil
}

func isValidEmail(email string) bool {
    return strings.Contains(email, "@") && strings.Contains(email, ".")
}

// Tanggung jawab 2: Hashing dan keamanan password
type PasswordHasher struct{}

func (h *PasswordHasher) Hash(password string) (string, error) {
    hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
    if err != nil {
        return "", fmt.Errorf("hash password: %w", err)
    }
    return string(hashed), nil
}

func (h *PasswordHasher) Verify(password, hash string) bool {
    return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}

// Tanggung jawab 3: Persistensi data user
type UserRepository struct {
    db *sql.DB
}

func (r *UserRepository) Save(ctx context.Context, user User) error {
    _, err := r.db.ExecContext(ctx,
        "INSERT INTO users (id, name, email, password_hash) VALUES (?, ?, ?, ?)",
        user.ID, user.Name, user.Email, user.PasswordHash,
    )
    return err
}

func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*User, error) {
    var u User
    err := r.db.QueryRowContext(ctx,
        "SELECT id, name, email, password_hash FROM users WHERE email = ?", email,
    ).Scan(&u.ID, &u.Name, &u.Email, &u.PasswordHash)
    if err == sql.ErrNoRows {
        return nil, ErrUserNotFound
    }
    return &u, err
}

// Tanggung jawab 4: Notifikasi via email
type UserNotifier interface {
    SendWelcomeEmail(ctx context.Context, name, email string) error
}

type SMTPUserNotifier struct {
    client *smtp.Client
}

func (n *SMTPUserNotifier) SendWelcomeEmail(ctx context.Context, name, email string) error {
    return n.client.Send(email, "Welcome to our platform!",
        fmt.Sprintf("Hi %s, thanks for joining!", name))
}

// Tanggung jawab 5: UserService sebagai orchestrator
// Ia tidak mengerjakan detail, hanya mengorkestrasi alur
type UserService struct {
    validator *UserValidator
    hasher    *PasswordHasher
    repo      *UserRepository
    notifier  UserNotifier
    logger    Logger
}

func NewUserService(
    validator *UserValidator,
    hasher *PasswordHasher,
    repo *UserRepository,
    notifier UserNotifier,
    logger Logger,
) *UserService {
    return &UserService{
        validator: validator,
        hasher:    hasher,
        repo:      repo,
        notifier:  notifier,
        logger:    logger,
    }
}

func (s *UserService) Register(ctx context.Context, req RegisterRequest) error {
    // Orkestrasi — tidak ada business logic detail di sini
    if err := s.validator.ValidateRegistration(req.Name, req.Email, req.Password); err != nil {
        return fmt.Errorf("validation: %w", err)
    }

    hashedPwd, err := s.hasher.Hash(req.Password)
    if err != nil {
        return fmt.Errorf("hash password: %w", err)
    }

    user := User{
        ID:           uuid.New().String(),
        Name:         req.Name,
        Email:        req.Email,
        PasswordHash: hashedPwd,
    }

    if err := s.repo.Save(ctx, user); err != nil {
        return fmt.Errorf("save user: %w", err)
    }

    // Side effects — tidak mempengaruhi return value
    s.notifier.SendWelcomeEmail(ctx, user.Name, user.Email)
    s.logger.Info("user registered", "email", user.Email)

    return nil
}

Sekarang setiap komponen bisa ditest secara independen, bisa di-mock dengan mudah, dan perubahan di satu tanggung jawab tidak mempengaruhi yang lain.


Dampak SRP pada Testability #

SRP dan testability adalah dua hal yang sangat erat kaitannya. Class yang melanggar SRP hampir selalu sulit ditest.

// Test SEBELUM SRP — butuh setup infrastruktur nyata
func TestUserService_Register_WithoutSRP(t *testing.T) {
    // Butuh database nyata
    db, _ := sql.Open("mysql", "root:password@/testdb")
    defer db.Close()

    // Butuh SMTP server nyata atau mock yang kompleks
    // Tidak bisa test validasi saja tanpa semua infrastruktur

    svc := &UserService{db: db}
    err := svc.Register("Budi", "[email protected]", "password123")
    // Apa yang ini test? Validasi? DB? Email? Semua sekaligus?
}

// Test SETELAH SRP — setiap komponen ditest secara independen
func TestUserValidator_ValidateRegistration(t *testing.T) {
    v := &UserValidator{}

    // Test fokus: hanya validasi, tidak butuh DB atau SMTP
    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", "notanemail", "password123"}, true},
        {"short password", [3]string{"Budi", "[email protected]", "pass"}, 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("expected error=%v, got %v", tt.wantErr, err)
            }
        })
    }
}

func TestUserService_Register_SRP(t *testing.T) {
    // Semua dependency bisa di-mock — tidak butuh infrastruktur nyata
    fakeRepo := &FakeUserRepository{}
    fakeNotifier := &FakeUserNotifier{}
    fakeLogger := &FakeLogger{}

    svc := NewUserService(
        &UserValidator{},
        &PasswordHasher{},
        fakeRepo,
        fakeNotifier,
        fakeLogger,
    )

    err := svc.Register(ctx, RegisterRequest{
        Name:     "Budi",
        Email:    "[email protected]",
        Password: "password123",
    })

    assert.NoError(t, err)
    assert.Len(t, fakeRepo.Saved, 1)
    assert.Equal(t, "[email protected]", fakeRepo.Saved[0].Email)
    assert.Len(t, fakeNotifier.SentEmails, 1)
}

SRP dalam Dart/Flutter #

Di Flutter, SRP sering dilanggar pada level Widget yang mengurus terlalu banyak hal sekaligus.

// ANTI-PATTERN: Widget yang mencampur UI, state, dan business logic
class ProductListScreen extends StatefulWidget {
  @override
  _ProductListScreenState createState() => _ProductListScreenState();
}

class _ProductListScreenState extends State<ProductListScreen> {
  List<Product> products = [];
  bool isLoading = false;
  String? errorMessage;

  @override
  void initState() {
    super.initState();
    _loadProducts();
  }

  // Business logic ada di widget
  Future<void> _loadProducts() async {
    setState(() => isLoading = true);
    try {
      // Langsung call HTTP — business logic di UI layer
      final response = await http.get(Uri.parse('https://api.example.com/products'));
      final data = json.decode(response.body) as List;
      setState(() {
        products = data.map((p) => Product.fromJson(p)).toList();
        isLoading = false;
      });
    } catch (e) {
      setState(() {
        errorMessage = e.toString();
        isLoading = false;
      });
    }
  }

  // Filtering logic juga ada di widget
  List<Product> get filteredProducts =>
      products.where((p) => p.price < 100000).toList();

  @override
  Widget build(BuildContext context) { /* ... */ }
}

// BENAR: tanggung jawab terpisah
// 1. Repository: akses data
class ProductRepository {
  final http.Client _client;
  ProductRepository(this._client);

  Future<List<Product>> findAll() async {
    final response = await _client.get(Uri.parse('https://api.example.com/products'));
    final data = json.decode(response.body) as List;
    return data.map((p) => Product.fromJson(p)).toList();
  }
}

// 2. Service/Use case: business logic
class ProductService {
  final ProductRepository _repository;
  ProductService(this._repository);

  Future<List<Product>> getAffordableProducts({int maxPrice = 100000}) async {
    final products = await _repository.findAll();
    return products.where((p) => p.price < maxPrice).toList();
  }
}

// 3. State management: koordinasi antara service dan UI
class ProductListController extends ChangeNotifier {
  final ProductService _service;
  ProductListController(this._service);

  List<Product> products = [];
  bool isLoading = false;
  String? errorMessage;

  Future<void> loadProducts() async {
    isLoading = true;
    notifyListeners();
    try {
      products = await _service.getAffordableProducts();
      errorMessage = null;
    } catch (e) {
      errorMessage = e.toString();
    } finally {
      isLoading = false;
      notifyListeners();
    }
  }
}

// 4. Widget: hanya tampilkan state
class ProductListScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final controller = context.watch<ProductListController>();
    if (controller.isLoading) return CircularProgressIndicator();
    if (controller.errorMessage != null) return Text(controller.errorMessage!);
    return ListView(children: controller.products.map(ProductCard.new).toList());
  }
}

SRP pada Level Package dan Arsitektur #

SRP tidak hanya berlaku pada class — ia berlaku pada package dan seluruh layer arsitektur.

SRP di level package:
  /user
    ├── validator.go     → tanggung jawab: validasi
    ├── repository.go    → tanggung jawab: akses data
    ├── service.go       → tanggung jawab: business logic
    └── handler.go       → tanggung jawab: HTTP request/response

  Package /user berubah hanya karena hal yang berkaitan dengan domain user.
  Jika kita mengubah cara mengirim email, itu bukan tanggung jawab package /user.


SRP di level arsitektur (Layered Architecture):
  HTTP Layer       → menangani request/response, serialisasi
  Service Layer    → business logic, orkestrasi
  Repository Layer → akses database, query
  Domain Layer     → business rules, entities

  Setiap layer punya "reason to change" yang berbeda.
  HTTP layer berubah jika API contract berubah.
  Repository layer berubah jika database schema berubah.
  Service layer berubah jika business rule berubah.

Kapan SRP Perlu Diseimbangkan #

SRP yang diterapkan terlalu agresif bisa menghasilkan over-decomposition — terlalu banyak class kecil yang justru membuat alur sulit diikuti karena terlalu banyak indirection.

Tanda SRP sudah cukup diterapkan:
  ✓ Setiap komponen bisa dijelaskan tanggung jawabnya dalam satu kalimat pendek
  ✓ Perubahan di satu area tidak merusak area lain
  ✓ Unit test bisa ditulis tanpa setup infrastruktur yang kompleks
  ✓ Lebih dari satu engineer bisa mengerjakan domain berbeda secara paralel

Tanda SRP mungkin over-applied:
  ✗ Butuh lompat ke 10 file hanya untuk memahami satu operasi sederhana
  ✗ Class dengan satu method yang hanya meneruskan ke class lain tanpa logic
  ✗ Abstraksi untuk sesuatu yang tidak akan pernah berubah
  ✗ Tim sulit menemukan "di file mana logika ini sebenarnya ada?"

Ringkasan #

  • SRP berarti satu modul hanya memiliki satu reason to change — bukan satu method, bukan ukuran yang kecil.
  • Tes SRP: tulis kalimat “komponen ini berubah jika…” — jika perlu disambung “atau” dengan alasan yang berbeda, SRP dilanggar.
  • God Class adalah pelanggaran SRP yang paling umum — satu class yang menangani validasi, database, email, logging sekaligus.
  • Refactoring memisahkan tanggung jawab: Validator, Repository, Hasher, Notifier, Logger — masing-masing class hanya berubah karena satu alasan.
  • Dampak pada testability: class yang mengikuti SRP bisa ditest secara independen dengan fake/mock tanpa infrastruktur nyata.
  • Di Flutter: pisahkan Repository (akses data), Service (business logic), Controller (state management), dan Widget (tampilan) — masing-masing lapisan punya reason to change yang berbeda.
  • SRP berlaku di semua level: function, class, package, dan layer arsitektur — semuanya seharusnya punya satu reason to change.
  • Jangan over-decompose: terlalu banyak class kecil dengan satu method tanpa logic adalah tanda SRP diterapkan berlebihan; keseimbangan adalah kunci.

← Sebelumnya: YAGNI   Berikutnya: SSOT →

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