SRP — Single Responsibility Principle #
There’s one file in almost every codebase that every engineer is afraid to change. Its name often ends in Service or Manager, it’s thousands of lines long, and every time there’s a new bug, the investigation always ends there. That file isn’t the result of a deliberate decision — it grew organically because every new feature was added to the easiest place to reach, and the easiest place to reach is always an existing file. This is what happens when the Single Responsibility Principle is consistently violated. SRP is the first principle of SOLID and the one that most directly affects day-to-day code quality: a module should have one, and only one, reason to change. Not one method, not one file — but one reason, one actor, one domain of change. This article covers what that definition really means, how to recognize SRP violations before they become a big problem, how to refactor gradually and safely, and when separating responsibilities goes too far.
The Real Meaning of “One Reason to Change” #
The classic SRP definition from Robert C. Martin:
A module should have one, and only one, reason to change.
Many interpret this as “one class should be small” or “one function should only do one thing”. Neither is the precise definition. What reason to change means is the actor or stakeholder — the party whose needs could force the module to be modified.
If a UserService can be forced to change by:
- The product team changing registration business rules
- The infrastructure team switching database engines
- The marketing team changing the welcome email template
- The DevOps team changing the logging format
Then UserService has four reasons to change — which means four responsibilities that should be separated.
The most practical SRP test:
Write the sentence: "Module X must change if..."
Example violating SRP:
"UserService must change if:
- the email validation rule changes (product team)
- the database schema changes (infrastructure team)
- the welcome email template changes (marketing team)
- the log format changes (DevOps team)"
→ Four actors = four responsibilities = SRP violated
Example following SRP:
"UserValidator must change if the user validation rules change"
"UserRepository must change if the user database schema changes"
"WelcomeEmailSender must change if the welcome email template changes"
→ One actor per module = SRP satisfied
SRP also applies below the struct level — it applies to functions, packages, even files. A function doing validation, transformation, and side effects all in one linear block is an SRP violation at micro scale.
Four Real Impacts of SRP Violations #
SRP violations aren’t just a code aesthetics problem. They produce real costs that grow heavier over time:
1. Unexpected changes. When all responsibilities live in one place, a small change in one area can unintentionally affect another. Changing the log format causes bugs in business logic because both share the same file and state. Validation unit tests suddenly fail because of a change in the database persistence function.
2. Tests needing complex setups. If a struct does validation, database access, and email sending at once, unit tests for its validation logic must set up mocks for the database and email server — even though neither is relevant to the test being written. Every test accidentally becomes an integration test.
3. Recurring merge conflicts. When two engineers work on different features but both need to modify the same file — one changing validation and the other changing the email format — merge conflicts appear not because they’re doing the same work, but because different responsibilities are stored in the same place.
4. Slow onboarding. New engineers must understand the entire complexity of a GodService just to make a small change in one of its responsibilities. There’s no clear boundary between “this is the relevant part” and “this is detail I don’t need to understand right now”.
Recognizing SRP Violations #
Before you can fix it, you need to recognize it. These are the signals that appear most often:
RED FLAGS AT THE STRUCT LEVEL:
✗ Overly generic names: UserService, DataManager, CoreProcessor
✗ Constructors with more than 4–5 dependencies
✗ Fields not used by all methods
✗ Methods only relevant in certain contexts
RED FLAGS AT THE METHOD LEVEL:
✗ Method names containing "And": validateAndSave(), parseAndSend()
✗ One method over 30–40 lines with mixed logic
✗ Many "// validation section", "// database section",
"// notification section" comments — a sign they should be
separate methods
RED FLAGS AT THE FILE LEVEL:
✗ One file changed in commits for different reasons
✗ One file appearing in almost every PR because "everything goes through it"
✗ Long, non-cohesive import lists: database, smtp, http, json, pdf
RED FLAGS AT THE PACKAGE LEVEL:
✗ Packages named "util", "helper", or "common" holding everything
✗ A package imported by almost every other package
(a signal the package knows too much)
From GodService to Focused Components — Step-by-Step Refactoring #
The most representative example: a UserService doing too many things.
// ANTI-PATTERN: UserService with four different responsibilities
// Four different actors can force it to change
package user
import (
"database/sql"
"fmt"
"net/smtp"
"regexp"
)
type UserService struct {
db *sql.DB
}
func (s *UserService) Register(name, email, password string) error {
// === RESPONSIBILITY 1: Validation ===
// If the validation rules change → UserService must change
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")
}
// === RESPONSIBILITY 2: Password hashing ===
// If the hashing algorithm changes → UserService must change
hashedPassword := fmt.Sprintf("hashed_%s", password) // simplified
// === RESPONSIBILITY 3: Database access ===
// If the schema or database engine changes → UserService must change
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)
}
// === RESPONSIBILITY 4: Email notification ===
// If the email template or provider changes → UserService must change
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)
// === RESPONSIBILITY 5: Logging ===
// If the logging format or system changes → UserService must change
fmt.Printf("[INFO] %s: user registered successfully: %s\n",
time.Now().Format(time.RFC3339), email)
return nil
}
To break it apart, do it gradually — one responsibility per step:
Step 1: Extract validation into 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
}
// The only reason to change: user validation rules change
Step 2: Extract data access into 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
}
// The only reason to change: the database schema or database engine changes
Step 3: Extract notifications into WelcomeNotifier
// internal/notification/welcome.go
package notification
import "context"
type WelcomeNotifier interface {
SendWelcome(ctx context.Context, name, email string) error
}
// SMTPWelcomeNotifier: the SMTP implementation
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 := "Welcome to our platform!"
body := fmt.Sprintf("Hi %s, your account has been created successfully.", name)
return n.sendEmail(email, subject, body)
}
// The only reason to change: the welcome email method or template changes
Step 4: Use the standard slog for logging — no custom wrapper needed
// Go 1.21+ has built-in structured logging
// No need for a custom Logger struct for simple cases
import "log/slog"
slog.Info("user registered", "email", email, "name", name)
Step 5: UserService is only an orchestrator
// 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 {
// Validation — delegate to UserValidator
if err := s.validator.ValidateRegistration(name, email, password); err != nil {
return fmt.Errorf("register: validation: %w", err)
}
// Duplicate check — delegate to UserRepository
exists, err := s.repo.ExistsByEmail(ctx, email)
if err != nil {
return fmt.Errorf("register: check email: %w", err)
}
if exists {
return ErrEmailExists
}
// Password hashing — delegate to PasswordHasher
hash, err := s.hasher.Hash(password)
if err != nil {
return fmt.Errorf("register: hash password: %w", err)
}
// Persistence — delegate to UserRepository
if err := s.repo.Save(ctx, NewUser{Name: name, Email: email, PasswordHash: hash}); err != nil {
return fmt.Errorf("register: save: %w", err)
}
// Notification — delegate to WelcomeNotifier (async so it doesn't block)
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
}
// The only reason to change: the user registration business flow changes
flowchart TD
REQ["HTTP Handler\n(receives the request)"]
SVC["UserService\n(business flow orchestrator)"]
VAL["UserValidator\n(validation rules)"]
REPO["UserRepository\n(database access)"]
HASH["PasswordHasher\n(hashing algorithm)"]
NOTIF["WelcomeNotifier\n(email template + provider)"]
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:#fffThe result: changing the email template only touches SMTPWelcomeNotifier. Changing the database schema only touches UserRepository. Changing validation rules only touches UserValidator. UserService only changes if the registration business flow itself changes — that’s its only legitimate reason to change.
SRP at the Function Level #
SRP doesn’t only apply to structs — it applies to functions too. A function doing several things at once is an SRP violation at micro scale, with the same impact: hard to test, hard to read, easy to break.
// ANTI-PATTERN: one function doing parsing, validation, and transformation at once
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)
}
// Validation
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)
}
}
// Transformation to the 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
}
// CORRECT: each function has one responsibility, easily tested independently
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}
}
// The handler uses all three sequentially — the flow is clear
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)
// continue to the service...
}
Separating these three functions gives concrete testing benefits: tests for validateCreateOrderRequest can run without touching JSON parsing at all — and vice versa.
SRP at the Package Level #
In Go, the package is the most important unit of code organization. SRP at the package level means every package has a clear focus — describable in one sentence without the word “and”.
ANTI-PATTERN: a "utils" package holding everything
internal/utils/
├── email.go // send email
├── pdf.go // generate PDF
├── hash.go // bcrypt
├── jwt.go // token generation
├── pagination.go // pagination helpers
├── validation.go // all validations
├── formatter.go // currency, date formatting
└── http.go // HTTP helpers
Problems:
- Imported by almost every other package
- A change in email.go can force recompiling the whole dependency tree
- No clear boundary — "utils" means anything
- Hard onboarding: new engineers don't know what to look for where
CORRECT: every package has a clear single responsibility
internal/
├── notification/
│ ├── email.go // ← "the package that sends email"
│ └── sms.go
├── document/
│ └── pdf.go // ← "the package that generates PDF documents"
├── auth/
│ ├── password.go // ← "the package that handles auth"
│ └── token.go
├── pagination/
│ └── pagination.go // ← "the package that handles pagination"
└── money/
└── formatter.go // ← "the package that formats monetary values"
Packages named util, helper, or common are a strong signal SRP is violated at the package level. Good packages can be described with a domain noun: notification, auth, document, pricing, inventory.
SRP and Testability #
One of the fastest ways to measure whether SRP is satisfied is to look at how easily a component can be tested independently.
// When SRP is satisfied, every component is easy to test on its own:
// Test UserValidator — no database, no email server needed
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 fakes for all dependencies
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")
}
// No real database, no SMTP server needed
}
When writing a test and you find yourself setting up mocks for dependencies irrelevant to the aspect under test — that’s a strong signal SRP is violated. Clean tests are proof that responsibilities have been properly separated.
SRP and Merge Conflicts #
One SRP benefit rarely mentioned but very real for teams is fewer merge conflicts. When responsibilities are separated into different files, two engineers working on different things almost never need to touch the same file.
flowchart LR
subgraph ANTI["Without SRP — One File"]
US["user_service.go\n(validation + DB + email + log)"]
E1["Engineer A\n(changes email template)"] -->|"edits"| US
E2["Engineer B\n(changes password validation)"] -->|"edits"| US
US -->|"MERGE CONFLICT\nnot because of\nsame work"| MC["💥 Conflict"]
end
subgraph SRP_GOOD["With SRP — Separate Files"]
VAL2["validator.go"]
NOTIF2["welcome_notifier.go"]
E3["Engineer A\n(changes email template)"] -->|"edits"| NOTIF2
E4["Engineer B\n(changes password validation)"] -->|"edits"| VAL2
NOTIF2 & VAL2 -->|"No conflict —\ndifferent files"| OK["✓ Clean merge"]
end
style MC fill:#D9534F,color:#fff
style OK fill:#5CB85C,color:#fffThis isn’t a coincidence — it’s a direct consequence of SRP. When every component has one reason to change, two changes with different reasons never need to touch the same file.
When Separation Goes Too Far #
SRP doesn’t mean every function must become a struct, every struct a package, or every package a separate service. There’s a point where over-decomposition actually destroys readability.
OVER-DECOMPOSITION:
// No need for a struct when a function suffices
type AgeValidator struct{}
func (v AgeValidator) IsAdult(age int) bool { return age >= 18 }
// → Just a function: func isAdult(age int) bool { return age >= 18 }
// No need for an interface for something that won't be swapped or mocked
type FileReader interface { Read(path string) ([]byte, error) }
type DefaultFileReader struct{}
func (r DefaultFileReader) Read(path string) ([]byte, error) { return os.ReadFile(path) }
// → Just call os.ReadFile directly
// No need for a UseCase layer that only forwards calls to the Repository
type GetUserUseCase struct{ repo UserRepository }
func (u *GetUserUseCase) Execute(id string) (*User, error) {
return u.repo.FindByID(id) // no additional logic
}
// → This is a pass-through layer, violating KISS, not fulfilling SRP
PRACTICAL GUIDANCE:
Separate when there are different reasons to change — not just because
"it should be separated". If changing one responsibility never forces
the other to change, and both already live together, there's no urgency
to separate them.
Over-decomposition is as bad as a GodObject. When a simple flow is scattered across 10 small files calling each other, readers must jump around just to understand one flow. SRP is about the right cohesion — not the maximum number of files.
Anti-Patterns at a Glance #
// ✗ GodService — too many responsibilities in one struct
type UserService struct {
db *sql.DB
smtp *smtp.Client
s3 *s3.Client
pdf *pdf.Generator
// 8 more dependencies not needed by every method
}
// ✗ "And" methods — names revealing more than one responsibility
func (s *UserService) ValidateAndSaveAndNotify(user User) error { ... }
// ✗ A "util" package holding everything with nowhere else to go
package util
// email, pdf, hash, pagination, validation, formatter — all here
// ✗ One function doing parsing + validation + transformation
func processRequest(body []byte) (*DomainModel, error) {
// 50 lines doing three things at once
}
// ✗ Logging, business logic, and database access mixed together
func (s *Service) DoSomething() error {
log.Info("starting")
result := s.db.Query("SELECT ...")
if result.Error != nil {
log.Error("db error")
sendAlert() // side effect in the middle of business logic
}
log.Info("done")
return nil
}
SRP Review Checklist #
STRUCTS AND SERVICES:
□ Struct names reflect one specific domain or responsibility
□ Constructors don't have more than 4–5 dependencies
□ All fields used by more than one method
□ Can answer "the only reason to change this struct is..."
without the word "or"
METHODS AND FUNCTIONS:
□ No method names containing "And"
□ Functions under ~30 lines unless there's strong justification
□ No "// section X" block comments in the middle of a function —
each section should be a separate function
PACKAGES:
□ Packages describable in one sentence without the word "and"
□ Packages not named "util", "helper", or "common"
□ Packages not imported by almost every other package
□ Every file in the package relevant to one domain
TESTABILITY:
□ Unit tests don't need mocks for irrelevant dependencies
□ Tests writable without database or external service setups
except for what's actually under test (integration tests)
□ Every component testable independently
Summary #
- SRP isn’t about size — not “one method one line” or “files must be small”. SRP is about reasons to change: a module may only be forced to change by one actor or one domain of change.
- The most practical SRP test: write “module X must change if…” — if the sentence needs the word “or”, SRP is violated.
- Four real impacts: unexpected changes from shared state, tests needing complex setups for irrelevant dependencies, merge conflicts not caused by the same work, and slow onboarding.
- From GodService to focused components: break it up gradually —
UserValidatorfor validation,UserRepositoryfor data access,WelcomeNotifierfor notifications,PasswordHasherfor security.UserServiceis only the business flow orchestrator.- SRP at the function level: separate parsing, validation, and transformation into separate functions. Names containing “And” are a sign to split.
- SRP at the package level: avoid
util,helper,commonpackages. Every package describable with a clear domain noun.- SRP and testability: components satisfying SRP can be tested independently without setting up irrelevant infrastructure.
- SRP and merge conflicts: when every component has one reason to change, two changes with different reasons almost never touch the same file.
- Avoid over-decomposition: pass-through layers without logic violate KISS, not fulfill SRP. Separate when there’s a real difference in reasons to change — not just because “it should be separated”.