DEV Community

Timevolt
Timevolt

Posted on

The One Ring: Forging SRP in Python

The Quest Begins (The "Why")

I still remember the first time I opened a legacy codebase and saw a class called UserManager. It was roughly 300 lines long, juggling user validation, password hashing, database persistence, email sending, and even a bit of logging. Every time I needed to tweak the password policy, I had to dig through a maze of unrelated methods, and the test suite felt like trying to solve a Rubik’s cube blindfolded—change one side and three others would scramble.

Honestly, I felt like Frodo lugging the One Ring through Mordor: the weight was crushing, and I kept wondering if there was a simpler way to carry the burden. That “aha!” moment came when a senior dev pointed out that the class violated the Single Responsibility Principle (SRP)—the idea that a class should have one reason to change. Once I saw it, the dragon of the God Object started to look a lot less intimidating.

The Revelation (The Insight)

SRP isn’t just a lofty academic rule; it’s a practical tool that makes code easier to read, test, and evolve. When a class does only one thing, you can:

  • Swap out its dependencies without rewriting the whole thing.
  • Unit‑test it in isolation—no need to mock half the system.
  • Reason about changes locally; a tweak to validation won’t accidentally break email logic.

In Python, this often translates to pulling out small, focused classes or functions and wiring them together via composition. The payoff? A codebase that feels less like a tangled web and more like a well‑organized toolbox.

Wielding the Power (Code & Examples)

🎯 The Trap: A “God Object”

# before.py – a classic SRP violation
import smtplib
import hashlib
import sqlite3

class UserManager:
    def __init__(self, db_path: str = "users.db"):
        self.conn = sqlite3.connect(db_path)

    # 1️⃣ Validation (should be its own concern)
    def validate_email(self, email: str) -> bool:
        return "@" in email and "." in email.split("@")[-1]

    # 2️⃣ Password hashing (crypto concern)
    def hash_password(self, plain: str) -> str:
        return hashlib.sha256(plain.encode()).hexdigest()

    # 3️⃣ Persistence (data‑access concern)
    def save(self, email: str, password: str) -> None:
        if not self.validate_email(email):
            raise ValueError("Invalid email")
        hashed = self.hash_password(password)
        self.conn.execute(
            "INSERT INTO users (email, pwd) VALUES (?, ?)",
            (email, hashed),
        )
        self.conn.commit()

    # 4️⃣ Notification (side‑effect concern)
    def welcome_email(self, email: str) -> None:
        # Imagine real SMTP logic here
        print(f"Sending welcome email to {email}")
        # smtplib.SMTP(...).sendmail(...)
Enter fullscreen mode Exit fullscreen mode

What’s wrong?

  • Validation, hashing, storage, and notification are all tangled together.
  • If we want to switch from SHA‑256 to Argon2, we have to edit UserManager—even though the change is purely about hashing.
  • Testing save requires a real database and mocking the email function, making tests slow and brittle.

🛡️ The Victory: Applying SRP

Let’s break the monolith into four tiny, single‑purpose actors and compose them.

# after.py – SRP‑friendly design
import hashlib
import sqlite3
from abc import ABC, abstractmethod

# 1️⃣ Validation – pure function, easy to test
def is_valid_email(email: str) -> bool:
    return "@" in email and "." in email.split("@")[-1]

# 2️⃣ Hashing – isolated crypto concern
def hash_password(plain: str) -> str:
    return hashlib.sha256(plain.encode()).hexdigest()

# 3️⃣ Persistence – a thin repository
class UserRepository:
    def __init__(self, db_path: str = "users.db"):
        self.conn = sqlite3.connect(db_path)

    def add(self, email: str, hashed_pwd: str) -> None:
        self.conn.execute(
            "INSERT INTO users (email, pwd) VALUES (?, ?)",
            (email, hashed_pwd),
        )
        self.conn.commit()

# 4️⃣ Notification – pluggable via an interface
class Notifier(ABC):
    @abstractmethod
    def send(self, to: str, message: str) -> None: ...

class ConsoleNotifier(Notifier):
    def send(self, to: str, message: str) -> None:
        print(f"[MAIL] To: {to}\n{message}")

# 5️⃣ Service orchestrator – now only orchestrates, does no work itself
class UserService:
    def __init__(
        self,
        repo: UserRepository,
        notifier: Notifier,
    ):
        self.repo = repo
        self.notifier = notifier

    def register(self, email: str, plain_password: str) -> None:
        if not is_valid_email(email):
            raise ValueError("Invalid email")
        hashed = hash_password(plain_password)
        self.repo.add(email, hashed)
        self.notifier.send(
            email,
            f"Welcome! Your account {email} has been created.",
        )
Enter fullscreen mode Exit fullscreen mode

Why this feels like leveling up:

  • Each piece can be unit‑tested in isolation—mock the repo, stub the notifier, validate the pure functions.
  • Swapping the hashing algorithm is a one‑line change in hash_password; nothing else needs to know.
  • If we later want to send real emails via SMTP, we just provide a new SmtpNotifier that implements Notifier. The UserService stays untouched.

⚠️ Common Pitfalls to Avoid

Trap What it looks like How to dodge it
“Utility class” creep Throwing unrelated static methods into a Helpers bucket. Keep utilities truly cohesive; if a method doesn’t share a clear purpose with others, give it its own module.
Passing too many flags A function with do_this=True, do_that=False, use_cache=None… Split the function into separate calls or use a strategy object.
Hidden side effects A validator that also logs to a file. Separate validation (pure) from logging (effect).

Why This New Power Matters

When you start treating SRP as a compass, every class you write becomes a building block you can trust. You’ll spend less time debugging “why did changing X break Y?” and more time shipping features. Your test suite will run fast because each unit is tiny and independent. And future you (or a teammate) will thank you when the codebase grows—because the structure stays clean, not because you prayed for luck.

Think of it like upgrading from a rusty sword to a lightsaber: the same swing, but now you cut through problems with precision and grace.

Your Turn

Pick a class in your current project that feels a bit… overloaded. Try extracting one responsibility into its own function or class. Write a tiny test for the new piece, and notice how the rest of the code becomes easier to reason about.

What’s the first “God Object” you’ll refactor? Drop a comment below—I’d love to hear about your quest! 🚀

Top comments (0)