DEV Community

Timevolt
Timevolt

Posted on

The Matrix of SOLID: Mastering SRP in Python

The Quest Begins (The “Why”)

I still remember the first time I opened a file called user_service.py and felt like I’d walked into a dragon’s lair. The class was a beast: it validated input, hashed passwords, sent welcome emails, wrote audit logs, and even talked to a third‑party payment gateway. Every time I needed to tweak the email template, I had to dig through a wall of validation logic. Unit tests were a nightmare — mocking the email server just to test a password rule felt like trying to solve a Rubik’s cube blindfolded.

After a particularly painful bug where a change to the logging format broke password validation, I stopped and asked myself: Why does this one class have so many reasons to change? That question led me straight to the Single Responsibility Principle (SRP), the first letter in SOLID, and it completely reshaped how I think about designing code.

The Revelation (The Insight)

SRP is simple in wording but profound in practice: a class should have only one reason to change. In other words, each class should own a single, well‑defined responsibility. When you obey this rule, you gain:

  • Isolated testing – you can test the responsibility in isolation without lugging along unrelated dependencies.
  • Easier refactoring – changing how emails are sent doesn’t risk breaking password logic.
  • Clearer naming – the class name becomes a honest description of what it does.

Ignore SRP, and you end up with “god classes” that are tightly coupled, hard to read, and terrifying to modify. The cost shows up in bug counts, slower onboarding, and that dreaded feeling of “I’m afraid to touch this file.”

Wielding the Power (Code & Examples)

🐉 The Before: A Class Doing Too Much

# user_service.py – before SRP
import smtplib
import logging
from email.message import EmailMessage

class UserService:
    def __init__(self, smtp_host: str, smtp_port: int):
        self.smtp_host = smtp_host
        self.smtp_port = smtp_port

    def register_user(self, username: str, email: str, plain_password: str) -> None:
        # 1️⃣ Validate input
        if not username or not email or not plain_password:
            raise ValueError("Fields cannot be empty")
        if len(password) < 8:
            raise ValueError("Password too weak")

        # 2️⃣ Hash password (pretend we have a hash function)
        hashed_password = self._hash_password(plain_password)

        # 3️⃣ Persist user (fake DB call)
        self._save_to_db(username, email, hashed_password)

        # 4️⃣ Send welcome email
        self._send_welcome_email(email, username)

        # 5️⃣ Write audit log
        logging.info(f"User {username} registered.")

    def _hash_password(self, pwd: str) -> str:
        # Simplified hashing
        return pwd[::-1]   # just for demo

    def _save_to_db(self, username: str, email: str, hashed: str) -> None:
        # Pretend this talks to a real database
        pass

    def _send_welcome_email(self, to_addr: str, username: str) -> None:
        msg = EmailMessage()
        msg["Subject"] = "Welcome!"
        msg["From"] = "no-reply@example.com"
        msg["To"] = to_addr
        msg.set_content(f"Hi {username}, thanks for joining!")

        with smtplib.SMTP(self.smtp_host, self.smtp_port) as server:
            server.send_message(msg)
Enter fullscreen mode Exit fullscreen mode

What’s wrong here?

  • The class worries about validation, hashing, persistence, emailing, and logging.
  • Change the email provider? You must edit UserService.
  • Want to unit‑test the password rule? You have to spin up an SMTP server or mock it out — unnecessary coupling.

🌟 The After: SRP‑Driven Split

# validators.py
class UserValidator:
    @staticmethod
    def validate(username: str, email: str, password: str) -> None:
        if not username or not email or not password:
            raise ValueError("Fields cannot be empty")
        if len(password) < 8:
            raise ValueError("Password too weak")


# hasher.py
class PasswordHasher:
    def hash(self, plain_password: str) -> str:
        # In real life: use bcrypt, argon2, etc.
        return plain_password[::-1]   # placeholder


# repository.py
class UserRepository:
    def save(self, username: str, email: str, hashed_password: str) -> None:
        # DB logic lives here
        pass


# notifier.py
import smtplib
from email.message import EmailMessage

class EmailNotifier:
    def __init__(self, smtp_host: str, smtp_port: int):
        self.smtp_host = smtp_host
        self.smtp_port = smtp_port

    def send_welcome(self, to_addr: str, username: str) -> None:
        msg = EmailMessage()
        msg["Subject"] = "Welcome!"
        msg["From"] = "no-reply@example.com"
        msg["To"] = to_addr
        msg.set_content(f"Hi {username}, thanks for joining!")

        with smtplib.SMTP(self.smtp_host, self.smtp_port) as server:
            server.send_message(msg)


# user_service.py – after SRP
import logging
from validators import UserValidator
from hasher import PasswordHasher
from repository import UserRepository
from notifier import EmailNotifier

class UserService:
    def __init__(
        self,
        validator: UserValidator,
        hasher: PasswordHasher,
        repo: UserRepository,
        notifier: EmailNotifier,
    ):
        self.validator = validator
        self.hasher = hasher
        self.repo = repo
        self.notifier = notifier

    def register_user(self, username: str, email: str, plain_password: str) -> None:
        # 1️⃣ Validation – single responsibility
        self.validator.validate(username, email, plain_password)

        # 2️⃣ Hashing – single responsibility
        hashed = self.hasher.hash(plain_password)

        # 3️⃣ Persistence – single responsibility
        self.repo.save(username, email, hashed)

        # 4️⃣ Notification – single responsibility
        self.notifier.send_welcome(email, username)

        # 5️⃣ Logging – could be its own service, but here it's fine as a cross‑cutting concern
        logging.info(f"User {username} registered.")
Enter fullscreen mode Exit fullscreen mode

Why this feels like a victory:

  • Each class now does one thing and does it well.
  • Want to swap the email provider? Just inject a different EmailNotifier implementation — no touching validation or hashing logic.
  • Unit testing UserValidator.validate is a pure function test; no SMTP server, no database, no logging setup.
  • The UserService becomes a simple orchestrator — a controller that tells the specialists what to do, following the Dependency Inversion Principle (the “D” in SOLID) as a nice bonus.

⚠️ Common Traps to Avoid

  • Over‑splitting: Don’t create a class for every single line of code. SRP isn’t about absurd granularity; it’s about cohesive responsibilities.
  • Ignoring context: Sometimes a small utility class is fine; the goal is to keep related changes together, not to dogmatically avoid any coupling.

Why This New Power Matters

Adopting SRP turned my codebase from a tangled mess into a set of clear, interchangeable bricks. I could refactor the email sending logic in an hour without fear of breaking the login flow. New teammates could jump in, read a validator class, and instantly grasp its purpose. Bugs dropped because changes were localized — when the logging format changed, only the logging service needed a tweak.

In short, SRP gave me the confidence to move fast without breaking things. It’s the foundation that lets the other SOLID principles shine: Open/Closed (you can extend behavior without modifying existing code), Liskov Substitution (substitutes work because responsibilities are clear), Interface Segregation (small, focused interfaces), and Dependency Inversion (you depend on abstractions, not concrete implementations).

If you’ve ever felt like you’re wrestling with a class that does too much, give SRP a try. Pull out one responsibility, give it its own home, and watch the rest of the system breathe easier.


Your Turn

Pick a class in your current project that feels like a “do‑everything” monster. Identify its distinct responsibilities, extract them into separate collaborators, and wire them back together with simple constructor injection. Share your before/after snippets in the comments — let’s see how many dragons we can slay together! 🚀

Top comments (0)