Why I Stopped Writing “God” Classes and Started Thinking About Responsibility
Quick context (why you're writing this)
Honestly, I used to think a class that did a little bit of everything was just efficient. One file, one import, and I could juggle validation, persistence, and business rules all in the same place. It felt like I was saving time—until I spent three hours debugging a weird edge case where a change to the database layer broke a completely unrelated validation rule. The stack trace was a mess, the test suite was flaky, and I realized I’d been paying a hidden tax on every tiny tweak. That’s when I remembered a talk about the Single Responsibility Principle (SRP) and thought, “Maybe there’s a better way.”
The Insight
The SRP says a class should have one reason to change. Not one method, not one line—one reason. When you lump together data access, validation, and business logic, you create multiple reasons for that class to mutate: a schema change, a new rule, a performance tweak. Each change ripples outward, making the code harder to reason about and more prone to bugs.
If you respect SRP, you end up with smaller, focused classes that are easier to test, easier to replace, and easier to reason about when something goes wrong. The trade‑off is a few more files and a bit more indirection, but the payoff in maintainability is massive—especially as the codebase grows.
How (with code)
Let’s look at a typical “user service” I’ve seen in a few projects. It does validation, hashes passwords, and talks directly to the ORM. Here’s the before version:
# before.py
from werkzeug.security import generate_password_hash
from myapp.models import User, db
class UserService:
def register(self, email, password, full_name):
# 1️⃣ Validation (mixed in)
if not email or "@" not in email:
raise ValueError("Invalid email")
if len(password) < 8:
raise ValueError("Password too short")
if User.query.filter_by(email=email).first():
raise ValueError("Email already taken")
# 2️⃣ Business logic (hashing)
hashed_pw = generate_password_hash(password)
# 3️⃣ Persistence (direct ORM use)
user = User(email=email, password=hashed_pw, full_name=full_name)
db.session.add(user)
db.session.commit()
return user
What’s wrong?
- Changing the validation rules means touching this class.
- Switching to a different hashing algorithm? Same class.
- Moving to a new data store (say, a microservice)? You guessed it—edit this class.
Now, let’s split responsibilities. First, a validator that knows nothing about the database:
# validator.py
import re
class UserValidator:
EMAIL_REGEX = re.compile(r"[^@]+@[^@]+\.[^@]+")
def validate(self, email, password):
if not email or not self.EMAIL_REGEX.match(email):
raise ValueError("Invalid email")
if len(password) < 8:
raise ValueError("Password too short")
# Uniqueness check would live elsewhere—see below
Next, a repository that hides the ORM details:
# repository.py
from myapp.models import User, db
class UserRepository:
def get_by_email(self, email):
return User.query.filter_by(email=email).first()
def create(self, email, hashed_password, full_name):
user = User(email=email, password=hashed_pw, full_name=full_name)
db.session.add(user)
db.session.commit()
return user
Finally, a service that orchestrates the three concerns without owning any of them:
# service.py
from werkzeug.security import generate_password_hash
from .validator import UserValidator
from .repository import UserRepository
class UserRegistrationService:
def __init__(self, validator=None, repository=None):
self.validator = validator or UserValidator()
self.repository = repository or UserRepository()
def register(self, email, password, full_name):
# 1️⃣ Delegate validation
self.validator.validate(email, password)
# 2️⃣ Delegate hashing (still a tiny bit of logic, but it’s isolated)
hashed_pw = generate_password_hash(password)
# 3️⃣ Delegate persistence
if self.repository.get_by_email(email):
raise ValueError("Email already taken")
return self.repository.create(email, hashed_pw, full_name)
What changed?
- If the validation rule evolves, I only touch
UserValidator. - If I switch from Werkzeug’s hash to bcrypt, I edit the hashing line in the service (or extract it further).
- If I replace SQLAlchemy with a NoSQL client, I swap out
UserRepository—the service stays oblivious.
The code is a bit longer, but each piece now has a single, clear reason to change.
Why This Matters
When I first applied SRP to a legacy project, the test suite went from flaky and slow to fast and deterministic. I could mock the validator or repository in isolation, meaning unit tests ran in milliseconds instead of waiting for a real database. More importantly, when a junior developer added a new field to the User model, they didn’t have to wade through a tangled register method to figure out where to put the logic—they just added a column and updated the repository if needed.
The real cost of ignoring SRP isn’t just “more files.” It’s the cognitive load of trying to hold multiple concerns in your head while you debug, the fear that a tiny tweak will break something unrelated, and the slowdown that comes from constantly rerunning integration tests because you can’t trust a unit test to be truly unit.
Of course, there’s a balance. Over‑splitting can lead to “classitis” where you have a dozen one‑line classes that add noise. I’ve seen that too, and it feels just as frustrating as a god class. The trick is to ask: Does this class have more than one reason to change? If the answer is yes, split it. If the answer is no, you’re probably good.
A Challenge for You
Take a look at the last class you touched that felt “big.” Identify the distinct reasons it might need to change (validation, persistence, external API calls, etc.). Pick one of those reasons and extract it into its own collaborator. See how the original class shrinks and how your tests become easier to write.
What was the hardest part of separating concerns for you? Did you notice any immediate bugs disappear, or did you uncover new design questions? I’d love to hear your experiences in the comments—let’s learn from each other’s refactor wars.
Happy coding!
Top comments (0)