Skip to content

SRP - The Single Responsibility Principle

A class should have one reason to change, which really means one group of people who would ask you to change it.

Builds on Coupling and Cohesion

Stage 2core11 min read

What it says

A class should have only one reason to change.

In simple terms, code for one job should not have to change because of a different job. A tax-rule edit should not make you open email or database code.

People often shorten this to "a class should do one thing." That is a fair start, but it can mislead. A class can have several methods and still have one responsibility. An OrderService with placeOrder, cancelOrder, and getStatus is fine, because all three methods serve the same order workflow.

Robert C. Martin, who collected these five principles (Michael Feathers later coined the SOLID acronym), gave the idea a sharper wording in Clean Architecture:

A module should be responsible to one, and only one, actor.

An actor is a group of people who ask for the same kind of change. Finance is one actor, operations is another, and marketing may be a third. An actor is not one person. Two finance analysts who both ask for tax changes are still one actor.

Here is a question you can ask yourself:

If I list everyone who could walk up to my desk and ask me to change this class, is it one group or several?

Several groups means several reasons to change. If their rules sit in one class, a change for one group can break what another group relies on.

Take an invoice. Three groups might ask you to change it:

Who asks for the changeWhat they ask for
Finance"GST is now 18% on this item, fix the total"
Platform / DBA"We are moving invoices from MySQL to Postgres"
Marketing / CRM"Change the invoice email subject and template"

All three requests mention invoices, but they are not one responsibility. They come from three actors and have three different reasons. If calculation, storage, and email live in one Invoice class, all three groups must edit the same file.

An everyday analogy

Think of a small restaurant. The chef cooks, the cashier does the billing, the delivery partner delivers.

Now imagine the chef also does billing. When the GST rule changes, someone has to interrupt the chef in the middle of a biryani to update the bill format. A billing mistake can now cost you a burnt biryani. The two jobs were never related. They just happened to sit with the same person.

That is what SRP stops: unrelated changes landing on the same piece of code.

How to spot a violation

You rarely see "SRP violation" written on a class. You see these warning signs instead:

  • The name has "And", "Manager", "Helper", "Util" or "Processor" next to a vague noun, like OrderAndPaymentManager, UserHelper or CommonUtils. If you can't name a class clearly, it probably does too much.
  • You can't describe it in one sentence without "and". For example: "It calculates the total and saves it and emails the customer."
  • One file imports from very different areas. java.sql.*, jakarta.mail.* and a PDF library sit at the top of the same class.
  • Some fields are used by only some methods. List which methods touch which fields. If you see two or three separate groups, each group is a hidden class.
  • A small test needs a lot of setup. You must mock the DB, an SMTP server and a PDF library just to test a tax calculation.
  • Unrelated changes break each other. Someone changed the email template and the invoice total started rounding differently.
  • Two teams keep getting merge conflicts. People working on unrelated tickets keep editing the same file.
  • The git history shows it. Run git log --oneline -- Invoice.java and you see "fix GST rounding", "switch to SES", "add index on invoice_no", "new email footer". That is four reasons in one file.

Example 1: the Invoice in this repo

Before

BadCode/Invoice.java does three jobs: it generates the invoice, saves it, and emails it.

public class Invoice {
    private double amount;

    public Invoice(double amount) {
        this.amount = amount;
    }

    public void generateInvoice() {
        System.out.println("Invoice generated & printed for amount " + amount);
    }

    public void saveToDatabase() {
        System.out.println("Saving invoice to database");
    }

    public void sendEmailNotification() {
        System.out.println("Sending email notification for invoice");
    }
}

It looks small and harmless because each body is just a println. In a real project the bodies would be a tax calculation, a JDBC call (database access) and an SMTP call (sending mail). Then the class needs a DB connection and mail credentials just to exist, and every change from finance, the DBA or marketing lands in this one file.

Loading diagram…

After

GoodCode splits it by who would ask for the change:

public class Invoice {
    private double amount;
    public Invoice(double amount) { this.amount = amount; }
    public void generateInvoice() { /* ... */ }
}

public class InvoiceRepository {
    public void saveToDatabase() { /* ... */ }
}

public class EmailService {
    public void sendEmailNotification() { /* ... */ }
}
Loading diagram…

Each class now has one owner. Here is what each change request touches before and after:

Change requestBeforeAfter
"GST rounding is wrong"edit Invoiceedit Invoice only
"Move invoices to Postgres"edit Invoiceedit InvoiceRepository only
"Send via SES instead of SMTP"edit Invoiceedit EmailService only
Unit test the invoice rulesneeds DB and mailnew Invoice(499.0), done

What the repo version leaves out

The repo keeps it minimal so the idea is easy to see. Its saveToDatabase() and sendEmailNotification() take no arguments. In real code you would pass the invoice into each method. You would also add one class that runs the steps in order. This sketch uses new method names (save, send) that are not in the repo:

public class InvoiceRepository {
    public void save(Invoice invoice) { /* INSERT INTO invoices ... */ }
}

public class EmailService {
    public void send(Invoice invoice, String email) { /* ... */ }
}

public class BillingService {
    private final InvoiceRepository repository;
    private final EmailService emailService;

    public BillingService(InvoiceRepository repository, EmailService emailService) {
        this.repository = repository;
        this.emailService = emailService;
    }

    public void bill(Invoice invoice, String email) {
        invoice.generateInvoice();
        repository.save(invoice);
        emailService.send(invoice, email);
    }
}

The constructor receives the two helpers, and bill calls them one after another.

You may ask: "Isn't BillingService now doing three things?" No. It doesn't calculate, store or send anything itself. Its only job is the order of the steps, so it changes only when the billing workflow changes. That is one reason to change.

Example 2: placing a food order

Here is a Swiggy or Zomato style OrderService that grew one feature at a time. These snippets are sketches: Cart, Customer, Order, Msg91Client, DB_URL, API_KEY, the constructor and the // ... parts are not defined. One method now prices the cart, saves the order, and sends an SMS:

public class OrderService {

    public long placeOrder(Cart cart, Customer customer) throws SQLException {
        // pricing
        double itemTotal = cart.items().stream()
                .mapToDouble(i -> i.price() * i.quantity())
                .sum();
        double deliveryFee = itemTotal >= 199 ? 0 : 40;
        double gst = itemTotal * 0.05;
        double total = itemTotal + deliveryFee + gst;

        // persistence
        try (Connection con = DriverManager.getConnection(DB_URL)) {
            PreparedStatement ps = con.prepareStatement(
                    "INSERT INTO orders (customer_id, total) VALUES (?, ?)",
                    Statement.RETURN_GENERATED_KEYS);
            // ...
        }

        // notification
        String sms = "Order placed. Pay Rs " + total + " on delivery.";
        new Msg91Client(API_KEY).send(customer.phone(), sms);
        // ...
    }
}

The pricing rule is: delivery is free when the items cost Rs 199 or more, otherwise Rs 40, and GST is 5% of the item total. (Money is double here only to keep the snippet short. Use BigDecimal or paise as long in real code.)

Count the actors. The pricing team changes delivery fees every other week. The platform team owns the database. The CRM team owns the SMS text and provider. One method, three bosses.

Here is the classic bug. The CRM team asks: "show whole rupees in the SMS, no paise". A developer adds total = Math.round(total); right after the pricing block, because total is right there. Now ₹523.95 is saved to the database as ₹524. The pricing team changed nothing, but their numbers changed.

Split it:

public record Bill(double itemTotal, double deliveryFee, double gst) {
    public double total() {
        return itemTotal + deliveryFee + gst;
    }
}

public class OrderPricer {
    private static final double GST_RATE = 0.05;
    private static final double FREE_DELIVERY_ABOVE = 199;
    private static final double DELIVERY_FEE = 40;

    public Bill price(Cart cart) {
        double itemTotal = cart.items().stream()
                .mapToDouble(i -> i.price() * i.quantity())
                .sum();
        double fee = itemTotal >= FREE_DELIVERY_ABOVE ? 0 : DELIVERY_FEE;
        return new Bill(itemTotal, fee, itemTotal * GST_RATE);
    }
}

public class OrderRepository {
    public long save(Order order) throws SQLException { /* JDBC lives here */ }
}

public class OrderNotifier {
    public void orderPlaced(Customer customer, Bill bill) {
        String text = "Order placed. Pay Rs %d on delivery.".formatted(Math.round(bill.total()));
        // SMS provider call lives here
    }
}

public class OrderService {
    private final OrderPricer pricer;
    private final OrderRepository repository;
    private final OrderNotifier notifier;

    // constructor ...

    public long placeOrder(Cart cart, Customer customer) throws SQLException {
        Bill bill = pricer.price(cart);
        long orderId = repository.save(new Order(customer.id(), cart, bill));
        notifier.orderPlaced(customer, bill);
        return orderId;
    }
}

Bill is a record, a small read-only holder for the three amounts. The rounding now happens only inside OrderNotifier, on a copy of the number used in the text. OrderService just runs the steps in order.

Loading diagram…

What you get:

  • A ₹499 cart test is new OrderPricer().price(cart) and an assert. No DB, no network, and it runs in milliseconds.
  • Formatting the SMS can't change the saved total, because the notifier only gets a read-only Bill.
  • When the pricing team adds "free delivery for Gold members", only OrderPricer changes, and only its tests need to run.

Example 3: same code, two different bosses

This example is subtle, and it shows best what "actor" means.

An Uber style app has a Trip class. Finance uses it to pay drivers. Support uses it to show a trip summary when a rider complains. Both need "billable minutes", so a helpful developer shares one private method:

public class Trip {
    private int rideMinutes;
    private int waitingMinutes;

    public double driverPayout() {                  // used by Finance
        return billableMinutes() * 2.5;
    }

    public String supportSummary() {                // used by Support
        return "Trip took " + billableMinutes() + " min";
    }

    private int billableMinutes() {
        return rideMinutes + waitingMinutes;
    }
}

Now finance says: "the first 3 minutes of waiting are free, don't pay for them". The developer updates billableMinutes():

private int billableMinutes() {
    return rideMinutes + Math.max(0, waitingMinutes - 3);
}

Math.max(0, waitingMinutes - 3) drops the first 3 waiting minutes, and never goes below 0 when the wait is shorter than 3.

Driver payouts are now correct. But look at what support sees. A trip with 19 minutes of riding and 6 of waiting really took 25 minutes. Support agents now tell the rider "your trip took 22 minutes". Support never asked for any change. Their numbers moved because they shared code with finance.

The shared method looked like good clean-up, but the match was an accident. The two numbers were the same by coincidence, not because they mean the same thing.

Fix it by giving each actor its own home:

public record Trip(int rideMinutes, int waitingMinutes) { }

public class DriverPayoutCalculator {                  // Finance owns this
    private static final int FREE_WAITING_MINUTES = 3;
    private static final double RATE_PER_MINUTE = 2.5;

    public double payout(Trip trip) {
        int paidWaiting = Math.max(0, trip.waitingMinutes() - FREE_WAITING_MINUTES);
        return (trip.rideMinutes() + paidWaiting) * RATE_PER_MINUTE;
    }
}

public class TripSummaryFormatter {                    // Support owns this
    public String summary(Trip trip) {
        return "Trip took " + (trip.rideMinutes() + trip.waitingMinutes()) + " min";
    }
}

Trip changed from a class into a record, a small read-only data holder, so it is now plain data. Each rule lives in the class of the team that owns it.

Yes, the addition is repeated a little. That is fine. DRY means do not repeat the same knowledge. Here there are two different rules, owned by two different teams, that only look alike today.

When not to apply it

SRP is the principle people overdo the most. A common mistake: someone reads "one responsibility" as "one method". A simple feature then ends up spread over fifteen classes with names like OrderValidatorHelperFactory. Here are the limits:

  • Don't split by method count. Turning a clean 80 line class into six 12 line classes makes it harder to read. You now jump across six files to follow one flow.
  • Things that always change together should stay together. If every change to GstCalculator also needs a change to GstRates, they are one responsibility in two files. Merge them.
  • Wait for the second actor. If a class has only had one kind of change request so far, leave it. When a second team starts asking for unrelated changes, split it then.
  • Small scripts and prototypes don't need it. A 50 line batch job that runs once a month can read, compute and write in one place.
  • Records and DTOs are fine as they are. A record Bill(...) with a total() method is not "doing two things". (A DTO is a plain object that only carries data.)
Split it whenKeep it together when
Different teams ask for changes to different partsOne person or team owns every change
Testing one part needs mocks for the other partsThe parts are always tested together
The parts use different libraries (DB, mail, PDF)It is pure logic with no I/O
A change in one part keeps breaking the otherSplitting would mean passing 6 params around

In an interview, show the split and name the actors out loud: "finance owns this, the platform team owns that". That is what the interviewer is listening for. In real code I would usually start with the simpler version and split on the day the second team's change request lands, because by then I know where the split should go.

How it connects to the rest

  • OCP: once pricing lives in OrderPricer, you can add new pricing rules without touching storage or notifications. See OCP-Notes.md.
  • ISP: ISP is basically SRP applied to interfaces. One fat interface used by several kinds of callers has several reasons to change. See ISP-Notes.md.
  • DIP: after you split OrderNotifier out, the next step is to make OrderService depend on an interface, so you can swap the SMS provider. See DIP-Notes.md.
  • Cohesion: SRP is high cohesion in practice. The "fields only some methods use" warning sign comes straight from Coupling and Cohesion.
  • DRY and YAGNI: Example 3 is where DRY goes too far, and "wait for the second actor" is YAGNI. Both are covered in Beyond SOLID.

Design patterns that are really SRP in action:

  • Strategy pulls an algorithm (like a pricing rule) out of the class that uses it.
  • Observer pulls "who needs to know about this" out of the main flow. OrderService publishes "order placed", and SMS, email and analytics listen.
  • Facade is what OrderService and BillingService look like: one simple entry point that runs focused helpers in order.
  • Decorator lets you add logging, caching or retries around a class instead of stuffing them inside it.

Before you commit, ask

  • Can I describe this class in one sentence without "and"?
  • Who would ask me to change this class? Is it one team or several?
  • Does testing the core logic need a DB, network or file system?
  • Are there fields that only half the methods use?
  • If I change the SMS text, could the saved order total change too?
  • Am I splitting because there are really two actors, or just because the file feels long?

Interview questions

"What does Single Responsibility actually mean? Isn't it just 'do one thing'?"

Not quite. "Do one thing" is advice for functions. SRP says a class should have one reason to change, meaning it answers to one actor, one group of people who would ask for changes. A class can have many methods and still follow SRP if they all serve the same actor.

"How do you find an SRP violation in an existing codebase?"

Look for vague names like Manager or Helper, classes that import DB, mail and PDF libraries together, fields used by only some methods, and tests that need many mocks for a small check. The git log helps too: if one file keeps changing for unrelated tickets from different teams, it has more than one reason to change.

"Your OrderService calls a pricer, a repository and a notifier. Doesn't that break SRP?"

No. It doesn't price, store or notify by itself. It only decides the order of those steps. Its single reason to change is the order placement workflow. Coordinators like this are normal and look a lot like a facade.

"Can following DRY ever break SRP?"

Yes. If two actors share a helper just because the code looks the same today, a change for one actor silently changes the other. The classic example is finance and support sharing a billableMinutes() method. Repeating a few lines is better than coupling two unrelated rules.

"What is the benefit of SRP for testing?"

When business logic is separated from I/O, you can test it with plain objects. A pricing test becomes new OrderPricer().price(cart) with no database or network, so it is fast and never flaky. Each class also has fewer reasons to break, so a failing test points to the right place.

"When would you not split a class?"

When one person or team owns every change to it, when the parts always change together, or when splitting would just scatter one simple flow across many tiny files. I usually wait until a second, unrelated kind of change request shows up before splitting.

In this topic

Done with this topic?

How well do you know it?

Practise 6 questions