The Single Responsibility Principle is the most quoted and most misread of the five. The misreading is "a class should do one thing", which leads either to absurdly tiny classes or to giving up on the principle. The actual rule is about change: a module should have one reason to change, meaning one group of people whose requests can force you to edit it.

This chapter applies that rule to the most common place it goes wrong in backend code: the class that manages orders.

The problem: one class, three owners

Here is an OrderManager for a small shop. It builds an order, validates it, prices it and sends it to the warehouse's fulfillment API.

type OrderItem = { productId: string; quantity: number; unitPrice: number };

export class OrderManager {
  private order: { customerId: string; createdAt: Date; items: OrderItem[] } | null = null;

  createOrder(customerId: string) {
    this.order = { customerId, createdAt: new Date(), items: [] };
  }

  addItem(productId: string, quantity: number, unitPrice: number) {
    if (!this.order) throw new Error("No order");
    if (quantity <= 0) throw new Error("Quantity must be positive");
    const existing = this.order.items.find((i) => i.productId === productId);
    if (existing) existing.quantity += quantity;
    else this.order.items.push({ productId, quantity, unitPrice });
  }

  total() {
    if (!this.order) return 0;
    const subtotal = this.order.items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0);
    return subtotal >= 500_000 ? subtotal : subtotal + 30_000; // free shipping over 500k
  }

  async sendOrder() {
    if (!this.order || this.order.items.length === 0) throw new Error("Nothing to send");
    const response = await fetch("https://fulfillment.example.com/v2/orders", {
      method: "POST",
      headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.FULFILLMENT_TOKEN}` },
      body: JSON.stringify({
        customer_ref: this.order.customerId,
        lines: this.order.items.map((i) => ({ sku: i.productId, qty: i.quantity })),
        placed_at: this.order.createdAt.toISOString(),
      }),
    });
    if (!response.ok) throw new Error(`Fulfillment rejected order: ${response.status}`);
  }
}

Nothing in it is wrong line by line. The problem is who can ask you to change it:

  • The business changes the rules: a maximum quantity per product, a new free-shipping threshold, bundles.
  • The warehouse integration changes the transport: the fulfillment API moves to v3, field names change, the token becomes an OAuth client, or the warehouse switches vendors entirely.
  • Infrastructure changes the plumbing: retries, timeouts, replacing fetch with a client that has tracing.

Three reasons to change, one class. Every warehouse API change means editing the class that holds your pricing rules, re-testing them, and getting a review from someone who knows both. That is what SRP warns about.

It also shows up in tests: to test that adding the same product twice merges the lines, you construct an object whose other method needs a network and an environment variable.

The refactor: split by reason to change

Separate the three concerns into three units, each owned by one of those groups.

The order itself, with its business rules and no I/O:

export type OrderItem = { productId: string; quantity: number; unitPrice: number };

export class Order {
  readonly items: OrderItem[] = [];
  readonly createdAt = new Date();

  constructor(readonly customerId: string) {}

  addItem(productId: string, quantity: number, unitPrice: number) {
    if (!Number.isInteger(quantity) || quantity <= 0) throw new RangeError("Quantity must be a positive integer");
    const existing = this.items.find((i) => i.productId === productId);
    if (existing) existing.quantity += quantity;
    else this.items.push({ productId, quantity, unitPrice });
  }

  get subtotal() {
    return this.items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0);
  }

  get shippingFee() {
    return this.subtotal >= 500_000 ? 0 : 30_000;
  }

  get total() {
    return this.subtotal + this.shippingFee;
  }

  get isEmpty() {
    return this.items.length === 0;
  }
}

The transport, behind an interface the order code owns:

export interface OrderSender {
  send(order: Order): Promise<{ externalId: string }>;
}

export class HttpFulfillmentSender implements OrderSender {
  constructor(
    private readonly baseUrl: string,
    private readonly token: string,
  ) {}

  async send(order: Order) {
    const response = await fetch(`${this.baseUrl}/v2/orders`, {
      method: "POST",
      headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.token}` },
      body: JSON.stringify({
        customer_ref: order.customerId,
        lines: order.items.map((i) => ({ sku: i.productId, qty: i.quantity })),
        placed_at: order.createdAt.toISOString(),
      }),
      signal: AbortSignal.timeout(5_000),
    });
    if (!response.ok) throw new Error(`Fulfillment rejected order: ${response.status}`);
    const body = (await response.json()) as { id: string };
    return { externalId: body.id };
  }
}

The use case that coordinates them:

export class OrderService {
  constructor(private readonly sender: OrderSender) {}

  async place(order: Order) {
    if (order.isEmpty) throw new Error("Cannot place an empty order");
    const { externalId } = await this.sender.send(order);
    return { externalId, total: order.total };
  }
}

And the wiring, done once at startup:

const orders = new OrderService(
  new HttpFulfillmentSender("https://fulfillment.example.com", process.env.FULFILLMENT_TOKEN ?? ""),
);

Now each change lands in one place:

Change request File you edit Files you do not touch
Free shipping from 400k Order sender, service
Fulfillment API v3 HttpFulfillmentSender order rules, service
Switch warehouse vendor new OtherVendorSender, wiring order rules, service
Add retries sender (or a proxy around it, chapter 11) order rules

Testing gets easy

The business rules need no network at all:

const order = new Order("c-1");
order.addItem("MUG", 2, 120_000);
order.addItem("MUG", 1, 120_000);
console.log(order.items, order.total); // [ { productId: "MUG", quantity: 3, unitPrice: 120000 } ] 390000

And the use case is tested with a fake sender:

const sent: Order[] = [];
const fakeSender: OrderSender = {
  send: async (o) => (sent.push(o), { externalId: "wh-1" }),
};

const service = new OrderService(fakeSender);
const big = new Order("c-2");
big.addItem("JACKET", 1, 650_000);

console.log(await service.place(big), sent.length); // { externalId: "wh-1", total: 650000 } 1
await service.place(new Order("c-3")).catch((e) => console.log(e.message)); // Cannot place an empty order

Notice that OrderService depends on the OrderSender interface, and receives the implementation from outside. That is Dependency Inversion, the D in SOLID, and it comes almost for free once responsibilities are separated.

How far to split

The refactor above has four pieces. It would be easy to keep going: an OrderValidator, a ShippingFeeCalculator, an OrderTotalCalculator, an OrderItemMerger. Stop when the next split does not correspond to a different reason to change.

  • Shipping fee and subtotal change for the same reason (pricing decisions by the business). Keep them together until shipping gets its own rules engine or its own owner.
  • Validation of quantities is part of what an order is. It stays in Order.
  • Transport is a different owner with a different change rhythm. It gets its own class.

If you ever split something and immediately have to change both halves for every request, you split along the wrong line. Merge them back.

SRP beyond classes

The same rule applies at every scale:

  • Functions: a function called validateAndSaveAndNotify has three reasons to change in its name.
  • Modules: orders/ should not contain the PDF invoice renderer, because the design team and the order team will collide in it.
  • Services: if two teams must coordinate a deploy of one service for unrelated features, the service has two responsibilities.

Common pitfalls

  1. Reading SRP as "one method per class". That produces dozens of classes that each do a fragment of a change. Group by reason to change, not by verb.
  2. Moving code without moving dependencies. If Order still imports the HTTP client "just for one helper", the split is cosmetic.
  3. Splitting by technical layer only. OrderController, OrderService, OrderRepository is a fine start, but a 2,000-line OrderService that handles pricing, refunds and exports still has many reasons to change.
  4. Letting the composition root leak. new HttpFulfillmentSender(...) belongs in one place at startup (or in your DI container), not inside OrderService.

Exercises

  1. Add a rule: at most 5 units of any product per order. Which class changes? Write the test first.
  2. Implement ConsoleSender for local development and switch to it with an environment variable, without changing OrderService.
  3. The warehouse now requires an idempotency key header so retried requests do not create duplicate shipments. Implement it and list which classes you touched.
  4. Take the largest service class in a codebase you work on, list every team that has requested a change to it in the last six months, and sketch the split.