The Quest Begins (The "Why")
I still remember the first time I opened a legacy Python module that was supposed to “handle user registration.” It was a 300‑line beast that validated email, hashed passwords, wrote to a database, sent a welcome email, logged the event, and even updated a cached leaderboard. I stared at it, scratched my head, and thought, “Why does this feel like I’m trying to parse XML with regex — a nightmare worthy of a Lovecraftian horror?”
Every time I needed to tweak the password policy, I had to wade through unrelated email‑sending code. When the SMTP server changed, I risked breaking the validation logic. The file was a tangled ball of yarn, and pulling one thread threatened to unravel the whole thing. I spent hours debugging a simple typo in the logger only to discover that the bug was actually in the password‑hashing section I hadn’t even touched.
That frustration sparked a question: Is there a way to write code so that each piece has one clear reason to change? The answer, I discovered, lay in the S of SOLID — the Single Responsibility Principle (SRP).
The Revelation (The Insight)
SRP is simple in theory: A class or module should have only one reason to change. In practice, it means each piece of code owns a single job. When you obey SRP, you get:
- Isolated changes – edit validation without touching email logic.
- Easier testing – you can mock dependencies and test a single behavior in isolation.
- Better readability – a newcomer can glance at a class and instantly know its purpose.
When I first applied SRP to that registration module, it felt like unlocking a new ability in a game. Suddenly, the codebase was navigable, and I could add features without fearing a cascade of bugs.
Wielding the Power (Code & Examples)
Before⚠️ The “before” code is intentionally messy. Don’t try this at work — unless you enjoy late‑night firefighting.
# before.py – a single class doing too much
import re
import smtplib
import logging
from hashlib import sha256
class UserRegistration:
def __init__(self, db_connection):
self.db = db_connection
self.logger = logging.getLogger(__name__)
def register(self, email, password):
# 1️⃣ Validate email (should be its own concern)
if not re.match(r"[^@]+@[^@]+\.[^@]+", email):
raise ValueError("Invalid email")
# 2️⃣ Hash password (another concern)
hashed = sha256(password.encode()).hexdigest()
# 3️⃣ Persist user (data access concern)
self.db.execute(
"INSERT INTO users (email, password_hash) VALUES (?, ?)",
(email, hashed)
)
# 4️⃣ Send welcome email (communication concern)
try:
with smtplib.SMTP('smtp.example.com') as server:
server.sendmail(
'no-reply@example.com',
email,
f"Welcome {email}! Your account is ready."
)
except Exception as e:
self.logger.error(f"Failed to send email: {e}")
# 5️⃣ Log the registration (logging concern)
self.logger.info(f"Registered new user: {email}")
# 6️⃣ Update leaderboard (unrelated business concern)
self.db.execute(
"UPDATE stats SET user_count = user_count + 1 WHERE id = 1"
)
Look at that mess! The UserRegistration class has six reasons to change: email regex, hashing algorithm, SQL schema, SMTP settings, logging format, and leaderboard logic. Change any one of those, and you risk breaking the others.
After: Applying SRP
We split the responsibilities into small, focused classes. Each class has a single job, and they collaborate through simple interfaces.
# after.py – SRP‑friendly design
import re
import smtplib
import logging
from hashlib import sha256
# 1️⃣ Email validation – only validates
class EmailValidator:
EMAIL_PATTERN = re.compile(r"[^@]+@[^@]+\.[^@]+")
def is_valid(self, email: str) -> bool:
return bool(self.EMAIL_PATTERN.match(email))
# 2️⃣ Password hashing – only hashes
class PasswordHasher:
def hash(self, password: str) -> str:
return sha256(password.encode()).hexdigest()
# 3️⃣ Data access – only talks to the DB
class UserRepository:
def __init__(self, db_connection):
self.db = db_connection
def save(self, email: str, hashed_pw: str):
self.db.execute(
"INSERT INTO users (email, password_hash) VALUES (?, ?)",
(email, hashed_pw)
)
def increment_user_count(self):
self.db.execute(
"UPDATE stats SET user_count = user_count + 1 WHERE id = 1"
)
# 4️⃣ Email service – only sends mail
class EmailService:
def __init__(self, smtp_host: str = 'smtp.example.com'):
self.smtp_host = smtp_host
def send_welcome(self, to_addr: str):
try:
with smtplib.SMTP(self.smtp_host) as server:
server.sendmail(
'no-reply@example.com',
to_addr,
f"Welcome {to_addr}! Your account is ready."
)
except Exception as e:
logging.getLogger(__name__).error(f"Failed to send email: {e}")
# 5️⃣ Registration orchestrator – coordinates the specialists
class UserRegistrationService:
def __init__(
self,
validator: EmailValidator,
hasher: PasswordHasher,
repo: UserRepository,
mailer: EmailService
):
self.validator = validator
self.hasher = hasher
self.repo = repo
self.mailer = mailer
def register(self, email: str, password: str):
if not self.validator.is_valid(email):
raise ValueError("Invalid email")
hashed = self.hasher.hash(password)
self.repo.save(email, hashed)
self.mailer.send_welcome(email)
self.repo.increment_user_count()
logging.getLogger(__name__).info(f"Registered new user: {email}")
Now each class answers the question “What does this do?” in a single sentence. If the email regex needs to support plus‑addressing, I only touch EmailValidator. If we switch from SMTP to SendGrid, I edit EmailService. The core registration flow stays untouched, and unit tests become a breeze — just mock the collaborators and verify the right method was called.
The Traps to Avoid
-
God objects – a class that knows too much (like the original
UserRegistration). - Hidden dependencies – burying SMTP details inside a method makes swapping implementations painful.
- Over‑engineering – SRP isn’t an excuse to create a class for every line of code; group related responsibilities sensibly.
Why This New Power Matters
Embracing SRP turned my codebase from a dreaded dungeon crawl into a series of well‑lit rooms. I can now:
-
Add features fast – want SMS verification? Just inject an
SMSSenderwithout touching validation or persistence. - Refactor with confidence – changing the hashing algorithm to bcrypt is a one‑file edit, and I know nothing else will break.
-
Onboard newcomers – a teammate can read
EmailValidatorand instantly grasp its purpose, no need to trace through a 300‑line monster.
The principle also nudges you toward better design habits: dependency injection, clear interfaces, and thoughtful separation of concerns. It’s the foundation that makes the other SOLID principles (Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) far easier to apply later.
Your Turn – The Challenge
Grab a piece of code you’ve been avoiding because it feels “too big to fix.” Identify the distinct responsibilities hiding inside it. Extract each one into its own class or function, giving each a single, clear reason to change. Write a tiny test for one of the new pieces, and notice how much lighter your mind feels.
When you’ve done it, drop a comment below with a before/after snippet (or just a description) and tell us what changed. Let’s celebrate the small victories that make our codebases — and our lives — a little more heroic.
Happy coding, and may your classes always have a single, noble purpose! 🚀
Top comments (0)