SDE Path

SOLID I: SRP & OCP with Refactors

13 min read

The first two letters of SOLID are about keeping classes focused and resilient to change. Interviewers use these principles to probe whether you'll write code that's easy to maintain under iteration.

Single Responsibility Principle (SRP)

Definition: A class should have one reason to change. If two stakeholders could ask you to change a class, it has two responsibilities.

Why it matters: When User changes to add fields, and Emailer changes to add a template engine, a UserEmailer class that does both gets hit twice. Decoupled, each changes independently.

Example: The God Class

class User {
  private String name;
  private String email;
  private String password;

  public void validateEmail() {
    if (!email.contains("@")) throw new Exception();
  }

  public void hashPassword() {
    // bcrypt logic
  }

  public void sendWelcomeEmail() {
    // SMTP connection, template rendering
  }

  public void logLoginAttempt() {
    // write to file/database
  }

  public void persistToDatabase() {
    // SQL insert
  }
}

This class has 5 reasons to change:

  1. Email validation logic changes (new TLD rules)
  2. Password hashing changes (bcrypt → Argon2)
  3. Email template changes
  4. Logging format changes
  5. Database schema changes

Each reason is a different concern. The class is tightly coupled to all of them.

Refactored: Split by Responsibility

class User {
  private String name;
  private String email;
  private String password;

  public User(String name, String email, String password) {
    this.name = name;
    this.email = email;
    this.password = password;
  }

  public String getName() { return name; }
  public String getEmail() { return email; }
  public String getPassword() { return password; }
}

class EmailValidator {
  public boolean isValid(String email) {
    return email.matches("^[A-Za-z0-9+_.-]+@(.+)$");
  }
}

class PasswordHasher {
  public String hash(String password) {
    // bcrypt implementation
  }

  public boolean verify(String password, String hash) {
    // comparison logic
  }
}

class WelcomeEmailSender {
  private final EmailService emailService;
  private final TemplateEngine templateEngine;

  public WelcomeEmailSender(EmailService emailService, TemplateEngine templateEngine) {
    this.emailService = emailService;
    this.templateEngine = templateEngine;
  }

  public void send(User user) {
    String body = templateEngine.render("welcome", user);
    emailService.send(user.getEmail(), "Welcome!", body);
  }
}

class UserRepository {
  public void save(User user) {
    // SQL insert
  }
}

class LoginLogger {
  public void logAttempt(String userId, boolean success) {
    // write to log
  }
}

Now each class has one reason to change:

  • User: data structure (fields, names)
  • EmailValidator: validation rules
  • PasswordHasher: hashing algorithm
  • WelcomeEmailSender: welcome email logic
  • UserRepository: persistence
  • LoginLogger: logging format

Cost: More files, more dependencies to wire up. Benefit: Each class is simpler, testable in isolation, and changes are localized.


Open/Closed Principle (OCP)

Definition: Software should be open for extension, closed for modification. Add behavior without changing existing code.

Why it matters: Every time you modify a class, you risk breaking callers. If you can add features via extension (new classes, new implementations), old code stays untouched.

Example: The Closed System (Violates OCP)

class Report {
  public void export(String format) {
    if (format.equals("PDF")) {
      // 50 lines of PDF rendering
    } else if (format.equals("CSV")) {
      // 30 lines of CSV formatting
    } else if (format.equals("JSON")) {
      // 20 lines of JSON serialization
    } else {
      throw new UnsupportedOperationException();
    }
  }
}

Problems:

  • Adding a new format (XML, Parquet) means editing this class.
  • Each edit risks breaking PDF/CSV/JSON exports.
  • The class violates SRP too (knows about every export format).

Refactored: Open for Extension

interface ReportExporter {
  String export(Report report);
}

class PDFExporter implements ReportExporter {
  @Override
  public String export(Report report) {
    // 50 lines of PDF rendering
  }
}

class CSVExporter implements ReportExporter {
  @Override
  public String export(Report report) {
    // 30 lines of CSV formatting
  }
}

class JSONExporter implements ReportExporter {
  @Override
  public String export(Report report) {
    // 20 lines of JSON serialization
  }
}

class Report {
  private final ReportExporter exporter;

  public Report(ReportExporter exporter) {
    this.exporter = exporter;
  }

  public String export() {
    return exporter.export(this);
  }
}

// Later: adding XML exporter
class XMLExporter implements ReportExporter {
  @Override
  public String export(Report report) {
    // new logic
  }
}

// Report class unchanged. We extended (new XMLExporter) without modifying Report.

Cost: More classes (boilerplate). Benefit: New formats don't touch old code; tests for PDF stay valid.


A Refactoring Walkthrough: E-Commerce Order

Starting point: Violates both SRP and OCP

class Order {
  private List<Item> items;
  private String customerEmail;

  public void placeOrder() {
    // validate stock
    // compute tax
    // apply discount
    // charge card
    // send confirmation email
    // log to analytics
  }
}

Issues:

  • Touches inventory, payment gateway, email service, analytics—way too many reasons to change.
  • To add a new discount strategy (tiered, seasonal), you edit this class.

Step 1: Extract by responsibility (SRP)

class Order {
  private List<Item> items;
  private String customerEmail;

  public void placeOrder(
      InventoryService inv,
      PaymentGateway payment,
      EmailService email,
      AnalyticsLogger logger) {
    inv.reserveItems(items);
    double total = computeTotal();
    payment.charge(total);
    email.sendConfirmation(customerEmail);
    logger.logOrder(this);
  }

  private double computeTotal() {
    // tax + items + discounts
  }
}

Better, but Order still orchestrates everything and knows about discounts. Let's go further.

Step 2: Open for extension (OCP)

interface DiscountStrategy {
  double apply(List<Item> items);
}

class NoDiscount implements DiscountStrategy {
  public double apply(List<Item> items) { return 0; }
}

class VolumeDiscount implements DiscountStrategy {
  public double apply(List<Item> items) {
    if (items.size() >= 10) return items.stream().mapToDouble(Item::price).sum() * 0.1;
    return 0;
  }
}

interface OrderProcessor {
  void process(Order order);
}

class InventoryProcessor implements OrderProcessor {
  public void process(Order order) {
    // reserve items
  }
}

class PaymentProcessor implements OrderProcessor {
  public void process(Order order) {
    // charge card
  }
}

class NotificationProcessor implements OrderProcessor {
  public void process(Order order) {
    // send email
  }
}

class Order {
  private List<Item> items;
  private String customerEmail;
  private DiscountStrategy discount;

  public Order(List<Item> items, String customerEmail, DiscountStrategy discount) {
    this.items = items;
    this.customerEmail = customerEmail;
    this.discount = discount;
  }

  public void placeOrder(List<OrderProcessor> processors) {
    for (OrderProcessor p : processors) {
      p.process(this);
    }
  }

  public double getTotal() {
    double subtotal = items.stream().mapToDouble(Item::price).sum();
    return subtotal - discount.apply(items);
  }

  // getters for processors to use
  public List<Item> getItems() { return items; }
  public String getCustomerEmail() { return customerEmail; }
}

// Usage:
Order order = new Order(items, "user@example.com", new VolumeDiscount());
order.placeOrder(Arrays.asList(
  new InventoryProcessor(),
  new PaymentProcessor(),
  new NotificationProcessor()
));

// Adding a seasonal discount? Just write a class:
class SeasonalDiscount implements DiscountStrategy {
  public double apply(List<Item> items) { /* logic */ }
}

// No changes to Order or existing processors.

In the interview

  1. Listen for reasons to change. When the interviewer describes a feature ("users need email, SMS, and push"), ask: "Will the channel list grow?" If yes, reach for an interface and open/closed.

  2. Extract cohesive units. If a class reads like a sentence with "and" in it ("User validates emails and persists and sends notifications"), it probably violates SRP. Split it.

  3. Use dependency injection. Pass dependencies in, don't construct them. This lets you swap strategies (DiscountStrategy, OrderProcessor) without touching the class that uses them.

  4. Show the refactoring path. Start with the violation, name the reasons to change, then extract. Interviewers love seeing you identify the smell, then fix it systematically.

Check yourself

5 questions — graded when you submit.

Question 1

A class has logic for user authentication, email sending, and logging to a file. Why does this violate Single Responsibility Principle?

Question 2

You have a Report class with an export() method. Currently it handles PDF, CSV, and JSON via a big if-else. Adding XML export requires editing the Report class. What principle is violated?

Question 3

In the refactored Order example, why are discount strategies and order processors passed in as interfaces rather than constructed inside the Order class?

Question 4

When you split a god class into smaller classes following SRP, what's the main downside?

Question 5

A colleague argues: 'Why create a DiscountStrategy interface if there's only one discount type today?' How would you respond?

Finished reading?

Sign in to save your progress across lessons.