What is Single Responsibility Principle (SRP)?
Single Responsibility Principle (SRP) - a class should have only one reason to change, meaning each class handles exactly one concern.
Theory
TL;DR
- Think of a restaurant kitchen: the chef cooks, the waiter serves, the dishwasher cleans. Mix those roles and a change in one breaks the others.
- SRP splits code by why it changes, not by how many methods it has.
- A class changes for two unrelated reasons? Split it into two classes.
- Decision rule: if a DB outage and an email outage both force you to open the same file, that class violates SRP.
Quick example
// BAD: User changes for DB updates AND email outages - two reasons
class User {
constructor(name, email) { this.name = name; this.email = email; }
save() { db.insert(this); } // DB reason
sendEmail() { mailer.send(this); } // Email reason
}
// GOOD: each class changes for exactly one reason
class User { constructor(name, email) { this.name = name; this.email = email; } }
class UserRepository { save(user) { db.insert(user); } }
class EmailService { send(user) { mailer.send(user.email); } }After the split, a broken email service doesn't touch UserRepository. Tests stay isolated. That's the whole point.
Key difference
SRP is about why a class changes, not how many methods it has. A User class with 10 getters for name, email, and address follows SRP perfectly - it changes only when user data rules change. Adding save() breaks that. Now a DB schema change forces you to open a user-data file, which has nothing to do with data structure.
When to use
- A class grows past 50 lines and keeps expanding. Extract by change reason, not by line count.
- Two separate teams (API team, reporting team) both edit the same class. Split at that boundary.
- Your test suite for one class covers unrelated behaviors. Each test group points to a separate class.
- Someone on the team says "this class does everything." Check git blame, find the top two change reasons, split there.
How it works
No runtime magic. SRP is a design rule enforced during code review and refactoring. That said, splitting large classes has a measurable side effect: V8's inline cache works better on smaller, focused allocation units. A "god class" with mixed responsibilities can cause megamorphic inline cache misses in hot paths, which slows execution. So splitting isn't only about readability.
Common mistakes
Mistake: "few methods means SRP."
class User {
getName() { return this.name; }
getEmail() { return this.email; }
save() { db.insert(this); } // breaks SRP - second reason to change
}Three methods, two reasons to change. Extract save() to UserRepository. Problem gone.
Mistake: god object for convenience.
From what I've seen, this almost always starts with "I'll clean this up later." Hidden dependencies between concerns grow until a DB migration breaks an email test and no one knows why. Check your git log - if one class appears in unrelated commits, split it.
Mistake: TypeScript interface with mixed concerns.
// Looks clean, violates SRP
interface UserService extends UserRepository, Emailer {}TypeScript's structural typing lets you do this. Lint won't catch it. But when your DB changes, you're editing an interface that also describes email behavior.
Mistake: over-splitting into nano-classes.
SRP doesn't mean one method per class. If two methods always change together for the same reason, they belong in the same class. Over-splitting adds indirection without benefit.
Real-world usage
- Express.js: thin route handlers delegate to
UserRepositoryandEmailServiceseparately. Controllers don't touch DB logic directly. - React: components render only. Data fetching goes into a custom hook or HOC. Analytics tracking goes into
useTrack. Each piece changes independently. - NestJS:
@Controller,@Injectableservice, and repository are three separate classes by convention. The framework guides you toward SRP. - Prisma: Prisma client handles queries. Business logic lives in a separate service layer. Never mix them.
Follow-up questions
Q: How is SRP different from "one method per class"?
A: SRP groups by change reason, not method count. A class with 10 getters for user data follows SRP. Adding save() breaks it because DB changes are now a second reason to edit the same file.
Q: How would you refactor a legacy class that violates SRP without rewriting everything?
A: Strangler pattern. Extract one responsibility at a time behind a feature flag. Prioritize by change frequency: grep git log for the class name, find which types of changes appear most, start there.
Q: What are the tradeoffs of SRP in a microservices architecture?
A: More classes, more files, more dependencies to wire up. But each service scales and deploys independently. The measure: do unrelated changes collide in the same file? If they don't, SRP is working.
Q: How do you test SRP compliance?
A: One test suite per class. If your tests for User mock a DB client and also mock an email client, the class does too much.
Examples
Basic: splitting a User class
// Three responsibilities in one class: data, persistence, email
class BadUser {
constructor(name, email) {
this.name = name;
this.email = email;
}
save() { db.insert(this); } // changes when DB changes
sendWelcome() { mailer.send(this.email); } // changes when email logic changes
}
// After SRP split
class User {
constructor(name, email) { this.name = name; this.email = email; }
}
class UserRepository {
save(user) { db.insert(user); } // only changes for DB reasons
}
class WelcomeEmailService {
send(user) { mailer.send(user.email); } // only changes for email reasons
}
const user = new User('Alice', 'alice@example.com');
new UserRepository().save(user); // "Saved to DB"
new WelcomeEmailService().send(user); // "Email sent"A DB outage now changes only UserRepository. Email tests keep passing. That's the practical gain from splitting here.
Intermediate: Express route handler (production pattern)
// BAD: one handler mixes validation, DB, and email
app.post('/users', (req, res) => {
const user = req.body; // no validation
db.users.insert(user); // DB logic
email.sendWelcome(user.email); // Email logic
res.json({ success: true });
});
// GOOD: handler delegates to focused classes
class UserValidator {
static validate(data) {
if (!data.email) throw new Error('Email required');
return data;
}
}
class UserRepository {
async save(data) { return db.users.create(data); }
}
class WelcomeEmailService {
async send(email) { /* Nodemailer logic */ }
}
app.post('/users', async (req, res) => {
const validated = UserValidator.validate(req.body); // throws on bad input
await new UserRepository().save(validated);
await new WelcomeEmailService().send(validated.email);
res.json({ success: true });
});When your DB switches from Postgres to MongoDB, only UserRepository changes. Validation and email tests keep passing. Each class has exactly one reason to open it.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.