Skip to content
QSWEQB
mediumConceptDesignMidSeniorStaff

Give me a real single-responsibility violation you have seen, and how would you refactor it?

A class violates SRP when it has more than one reason to change, not when it has more than one method; the refactor is to split along the axes that have independently churned, such as pricing rules, persistence, and presentation, and to stop splitting once each piece has its own requester.

5 min readUpdated 2026-07-26

What the interviewer is scoring

  • Does the candidate define responsibility as an axis of change rather than reciting "a class should do one thing"
  • Whether the example comes from code they have maintained, with a concrete change that was painful
  • That they can name who requests each kind of change, not just which methods exist
  • Whether the refactored design keeps a coordinator instead of pretending orchestration is free
  • Does the candidate volunteer the cost of over-splitting without being prompted

Answer

One reason to change, not one thing

The formulation that survives contact with real code is Robert C. Martin's: a class should have one reason to change. Later restatements sharpen it into "gather together the things that change for the same reasons, and separate those that change for different reasons", which is the version worth carrying into an interview because it is falsifiable. "Does one thing" is not falsifiable, since any method can be described as one thing at a sufficiently vague altitude. Saving an invoice, emailing an invoice, and pricing an invoice are all "handling invoices" if you squint.

So the diagnostic is not how many methods a class has. It is how many independent forces can arrive and demand an edit to that file.

The class

Here is a shape that shows up in nearly every billing codebase eventually.

class Invoice {
    long id;
    String customerEmail;
    BigDecimal amount;
    LocalDate dueDate;
    boolean paid;

    // Axis 1: finance owns this. Rates and caps change every tax year.
    BigDecimal totalWithLateFee() {
        if (paid || !LocalDate.now().isAfter(dueDate)) return amount;
        long daysLate = ChronoUnit.DAYS.between(dueDate, LocalDate.now());
        BigDecimal periods = BigDecimal.valueOf(daysLate / 30 + 1);
        BigDecimal rate = new BigDecimal("0.015").multiply(periods)
                              .min(new BigDecimal("0.10"));
        return amount.add(amount.multiply(rate));
    }

    // Axis 2: whoever owns the customer-facing emails.
    String toReminderHtml() {
        return "<p>Invoice #" + id + " for " + totalWithLateFee()
             + " was due on " + dueDate + ".</p>";
    }

    // Axis 3: the platform team, when the schema or the driver changes.
    void save(Connection c) throws SQLException {
        var ps = c.prepareStatement(
            "UPDATE invoice SET amount=?, due_date=?, paid=? WHERE id=?");
        ps.setBigDecimal(1, amount);
        ps.setDate(2, Date.valueOf(dueDate));
        ps.setBoolean(3, paid);
        ps.setLong(4, id);
        ps.executeUpdate();
    }
}

Three requesters, one file

The reason this is a genuine violation rather than an aesthetic complaint is visible in version control. Run git log --oneline over the file and you get commits titled "late fee cap raised to 12%", "reminder email restyled for the new brand", and "add invoice.currency column". Three different people, answering to three different stakeholders, editing one file for reasons that have nothing to do with each other.

That is what makes the class expensive. The finance change and the branding change collide in review. A schema migration forces a retest of fee arithmetic because the tests for both live in InvoiceTest. And you cannot compute a late fee in a unit test without either a live Connection on the classpath or an object that is half-constructed, because the persistence concern has pulled infrastructure into the same type as the arithmetic.

The refactor

Split along those three axes and nothing else.

// Domain data plus the rules that are intrinsic to an invoice.
record Invoice(long id, BigDecimal amount, LocalDate dueDate, boolean paid) {
    boolean isOverdueAsOf(LocalDate today) {
        return !paid && today.isAfter(dueDate);
    }
}

// Finance's axis. An interface, because the tiered scheme and the
// flat scheme genuinely coexist across customer contracts.
interface LateFeePolicy {
    BigDecimal feeFor(Invoice invoice, LocalDate asOf);
}

// Platform's axis. The domain no longer knows SQL exists.
interface InvoiceRepository {
    List<Invoice> findOverdue(LocalDate asOf);
    void save(Invoice invoice);
}

// Presentation's axis.
interface ReminderRenderer {
    String render(Invoice invoice, BigDecimal fee);
}

// The coordinator. Its own reason to change is the reminder workflow
// itself: batching, who gets skipped, when it runs.
class OverdueReminderService {
    private final InvoiceRepository repository;
    private final LateFeePolicy feePolicy;
    private final ReminderRenderer renderer;
    private final Mailer mailer;

    void sendReminders(LocalDate asOf) {
        for (Invoice invoice : repository.findOverdue(asOf)) {
            BigDecimal fee = feePolicy.feeFor(invoice, asOf);
            mailer.send(invoice.id(), renderer.render(invoice, fee));
        }
    }
}

Two things improved beyond tidiness. asOf is now a parameter rather than a hidden call to LocalDate.now(), so fee behaviour at a month boundary is testable without freezing the clock. And LateFeePolicy is the piece finance actually asks about, so their change now lands in a file whose entire test suite is about fees.

Why "does one thing" is the wrong test

The candidates who lose points here are the ones who apply the slogan mechanically and split by verb. You get InvoiceValidator, InvoiceCalculator, InvoiceFormatter, InvoiceSaver, InvoiceLoader, each with a single static-ish method, and an InvoiceManager that calls them in order. Every one of those classes "does one thing", and the design is worse than what you started with, because the invoice is now a bag of fields with no behaviour and the real logic has migrated into a procedural script wearing class syntax.

The test that works is the requester test. If you cannot name two distinct people or roles who would ask for two changes to this class, you do not yet have evidence of two responsibilities, and splitting is speculation. Two methods serving the same stakeholder for the same reason belong together, however different they look.

Where SRP taken literally becomes the problem

Be honest that the principle has no natural stopping point. Pushed all the way, it says every reason to change deserves its own type, and the endpoint of that is a codebase where following one request means opening nine files and no single file explains anything. The indirection is not free either: an interface with exactly one implementation and no plausible second one is a layer you pay to read forever in exchange for flexibility you never use.

The counterweight is cohesion. SRP is one half of a pair, and the other half is that a class should be worth existing. In practice that means splitting on churn you have observed rather than churn you can imagine, extracting the second implementation when it arrives rather than in anticipation, and accepting that a 120-line class with two related responsibilities can be the correct trade-off in a service two people maintain. Saying that out loud reads as judgement, not as ignorance of the principle.

The strong version of this answer is not the refactor, which any candidate can produce. It is naming which stakeholder owns each extracted piece, and then naming the point at which you would refuse to split further.

Likely follow-ups

  • How would you decide between an interface and a plain function for the extracted pricing rule?
  • What would make you leave a class that violates SRP exactly as it is?
  • How does this refactor change what you can unit test without a database?
  • Where does the open-closed principle start to conflict with keeping the class count low?

Related questions

solidsingle-responsibilityrefactoringcohesioncoupling