Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 003 / 100

Single Responsibility, practically: a step-by-step guide

A class should have one reason to change.

Expona Admin7 min read

Single Responsibility, practically

I've watched production systems grind to a halt because one class does too much. You modify invoice calculations for accounting, but the same change breaks PDF rendering. You add email logging for compliance, and suddenly the entire invoicing pipeline becomes fragile. Single Responsibility, practically, means understanding that a class should have one reason to change—one stakeholder or concern driving modifications. This principle isn't about writing tiny classes; it's about preventing changes from colliding.

What "one reason to change" really means

I used to interpret "single responsibility" as "does one thing." That led me down a path of creating UserRepository, UserMapper, UserValidator, UserNotifier—each tiny, each with a single method. The code was technically correct but operationally useless. Nobody was asking me to change validation separately from mapping. They were asking me to handle users differently.

The real meaning is one reason to change—which translates to one stakeholder or concern. If your finance team and your engineering team both have reasons to modify the same class, you've got a problem. If your PDF rendering logic changes because design wants a different font, but your calculation logic changes because accounting demands a new tax rule, those are different concerns. They have different owners, different release cycles, different risks.

In a recent e-commerce platform I built, we had an OrderProcessor that handled inventory deduction, payment capture, and email notification. When the payment gateway changed its response format, we had to redeploy the entire service—including inventory and email logic—even though neither of those concerns changed. That's the cost of conflating responsibilities.

Invoice calculation and PDF rendering are different reasons

Let me walk through a concrete scenario. You're building an invoicing system. Initially, everything lives in one class:

typescript
class InvoiceService {
  calculateTotal(items: LineItem[]): number {
    return items.reduce((sum, item) => sum + (item.quantity * item.price), 0);
  }

  applyDiscount(total: number, discountPercent: number): number {
    return total * (1 - discountPercent / 100);
  }

  generatePdf(invoice: Invoice, filename: string): Buffer {
    const doc = new PDFDocument();
    doc.fontSize(12).text(`Invoice #${invoice.id}`);
    invoice.items.forEach(item => {
      doc.text(`${item.description}: ${item.price}`);
    });
    doc.end();
    return doc.buffer;
  }

  sendEmail(invoice: Invoice, recipient: string): void {
    const pdf = this.generatePdf(invoice, `invoice-${invoice.id}.pdf`);
    this.mailService.send({
      to: recipient,
      subject: `Invoice #${invoice.id}`,
      attachments: [{ content: pdf }]
    });
  }
}

This works until change happens. Your accounting department wants to add a surcharge for late payments—that touches calculateTotal. Your design team wants to redesign the PDF layout with new fonts and colors—that touches generatePdf. Your ops team wants to log email sending to a compliance system—that touches sendEmail. Now you've got three teams wanting changes to one class. They step on each other. A PDF redesign could accidentally break calculations if someone isn't careful.

The solution is splitting along those lines of concern:

typescript
class InvoiceCalculator {
  calculateTotal(items: LineItem[]): number {
    return items.reduce((sum, item) => sum + (item.quantity * item.price), 0);
  }

  applyDiscount(total: number, discountPercent: number): number {
    return total * (1 - discountPercent / 100);
  }

  applyLateFeeIfNeeded(total: number, invoice: Invoice): number {
    if (invoice.daysOverdue > 30) {
      return total * 1.05; // 5% surcharge
    }
    return total;
  }
}

class InvoicePdfRenderer {
  render(invoice: Invoice): Buffer {
    const doc = new PDFDocument();
    doc.fontSize(12).text(`Invoice #${invoice.id}`);
    invoice.items.forEach(item => {
      doc.text(`${item.description}: ${item.price}`);
    });
    doc.end();
    return doc.buffer;
  }
}

class InvoiceMailer {
  send(invoice: Invoice, recipient: string): void {
    const pdf = this.pdfRenderer.render(invoice);
    this.mailService.send({
      to: recipient,
      subject: `Invoice #${invoice.id}`,
      attachments: [{ content: pdf }]
    });
  }

  constructor(
    private mailService: MailService,
    private pdfRenderer: InvoicePdfRenderer
  ) {}
}

Now the accounting team modifies InvoiceCalculator without touching rendering. The design team modifies InvoicePdfRenderer without touching calculations. The ops team adds logging to InvoiceMailer without affecting either. Changes no longer collide.

Split when changes keep colliding

Here's the practical heuristic I use: don't split preemptively. Live with a slightly larger class until you feel the pain. That pain—when you're making changes and constantly worrying about side effects—is the signal to split.

In a trading platform I worked on, we had a PortfolioAnalyzer that calculated returns, generated CSV exports, and logged metrics to our monitoring system. For three months, nobody touched export formatting. We didn't need separate classes. Then the CEO wanted daily email reports with custom column ordering. Suddenly we had two teams wanting to change CSV logic independently. That's when we split.

The key question: are your commits for different reasons ending up in the same class? Are code reviews where someone asks "why are you changing the database query logic for a PDF layout feature?" If yes, split. If no, you're probably fine.

I also look at test files. If your tests for calculation logic keep breaking when you change rendering logic, that's a sign. Tests should be independent too.

typescript
// ❌ Bad: Tests are tangled
describe('InvoiceService', () => {
  let service: InvoiceService;

  beforeEach(() => {
    service = new InvoiceService(mockMailer, mockPdfLib);
  });

  it('calculates total correctly', () => {
    const total = service.calculateTotal([...]);
    expect(total).toBe(100);
  });

  it('generates PDF with correct layout', () => {
    const pdf = service.generatePdf(...);
    expect(pdf).toContain('Invoice');
  });
});

// ✅ Good: Tests are focused
describe('InvoiceCalculator', () => {
  it('calculates total correctly', () => {
    const calculator = new InvoiceCalculator();
    const total = calculator.calculateTotal([...]);
    expect(total).toBe(100);
  });
});

describe('InvoicePdfRenderer', () => {
  it('generates PDF with correct layout', () => {
    const renderer = new InvoicePdfRenderer();
    const pdf = renderer.render(...);
    expect(pdf).toContain('Invoice');
  });
});

Don't split into one-method classes for no reason

This is where junior developers go wrong after learning the principle. They write:

typescript
class UserIdExtractor {
  extract(user: User): string {
    return user.id;
  }
}

class UserNameFormatter {
  format(firstName: string, lastName: string): string {
    return `${firstName} ${lastName}`;
  }
}

class UserAgeCalculator {
  calculate(birthDate: Date): number {
    return new Date().getFullYear() - birthDate.getFullYear();
  }
}

Now you have fifteen classes, each with one method, and they're all doing trivial things. Nobody asked you to split these. The age calculation never changes. The name formatting hasn't been touched in six months. You've created complexity for zero benefit.

Single Responsibility means one reason to change, not one method. If nobody's asking for independent changes, keep related logic together. I tend to group:

  • Domain logic that changes for business reasons (calculations, validation rules)
  • Infrastructure logic that changes for technical reasons (database queries, API calls, file I/O)
  • Presentation logic that changes for user experience reasons (formatting, layout, rendering)

Those three categories often map to different stakeholders and change cycles. Everything else—helper methods, simple transformations—can live with the logic it supports.

Common mistakes

  • Splitting based on methods, not concerns. You end up with one-method classes that are never modified independently. The pain signal never comes because there's no pain.
  • Creating bloated orchestrator classes. After splitting, you end up with a "main" class that wires everything together and does all the coordination. That's fine, but don't put business logic there. Orchestration classes can be larger.
  • Making interfaces too granular. Each class doesn't need its own interface. I typically add interfaces when multiple implementations exist or when testing requires mocking. Otherwise, it's noise.
  • Ignoring cohesion. Related logic should stay together even if they don't strictly have the same reason to change. calculateTotal and applyDiscount go together because they're both about price computation. Don't split them just because they have separate methods.
  • Premature splitting in small services. If your entire service is 200 lines and does one business thing, splitting it into six classes adds no value. Wait until the pain arrives.

Key takeaways

  • One reason to change means one stakeholder or concern driving modifications—not one method or one function.
  • Watch for colliding changes: when you're modifying a class for different business reasons, that's your signal to split.
  • Split when you feel the pain, not when you predict it might happen someday.
  • Keep related logic together even if they don't have identical reasons to change—focus on cohesion.
  • Avoid creating trivial one-method classes for helper logic. Let those live alongside the code they support.
  • Test files are a good indicator: if tests for feature A break when you change feature B, your class probably violates this principle.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles