PushBackLog

Single Responsibility Principle (SRP)

Advisory enforcement Complete by PushBackLog team
Topic: quality Topic: supportability Topic: architecture Methodology: SOLID Skillset: any Technology: generic Stage: refinement Stage: execution Stage: review

Single Responsibility Principle (SRP)

Status: Complete
Category: SOLID Principles
Default enforcement: Advisory
Author: PushBackLog team


Tags

  • Topic: quality, supportability, architecture
  • Methodology: SOLID
  • Skillset: any
  • Technology: generic
  • Stage: refinement, execution, review

Summary

A class, module, or function should have one — and only one — reason to change. Each software component is responsible for exactly one part of the system’s behaviour. When a requirement in that one area changes, only that component needs to be modified.

SRP is the foundational principle of maintainable software design. It creates natural seams in a codebase that allow confident, isolated modification.


Rationale

When a component does multiple things, a change in one area risks breaking another. A function that validates input, transforms data, persists it, and sends a notification is four responsibilities bundled into one unit. Any change to any one of them forces the developer to reason about all four.

The consequences compound over time: components grow larger, their internal contracts become implicit, and tests become harder to write because there is no clean surface to test against. The unit that “does everything” becomes the unit nobody wants to touch.

SRP matters especially in AI-assisted development. An AI persona executing against a codebase must understand what a component does to modify it safely. A component with one clear responsibility is self-documenting. A component with five responsibilities is a liability.


Guidance

Identifying a responsibility

A useful heuristic: state the purpose of the component in one sentence. If the sentence requires “and” or “or”, the component likely has more than one responsibility.

Another approach: list the different actors (users, external systems, business rules) who could request changes to this component. SRP says a component should answer to exactly one actor. A component that must change when the billing department changes their rules and when the engineering team changes the data model has two actors.

Granularity

SRP applies at every scale:

ScaleSingle responsibility means
FunctionOne transformation or one action (not both)
ClassOne coherent set of related behaviours
Module / fileOne domain concept or layer concern
ServiceOne bounded context

The risk of over-applying SRP is fragmentation: splitting a 50-line class into 20 single-method classes produces indirection without clarity. The heuristic is cohesion: if the parts of a component are tightly related and change together, they belong together.

SRP and cohesion

SRP and cohesion point in the same direction. Cohesion measures how strongly the parts of a component relate to each other. SRP says: keep things that change for the same reason together; separate things that change for different reasons. High cohesion is the positive statement; SRP is the constraint.


Examples

Controller with mixed responsibilities

// Before: one function doing four things
async function handleCreateOrder(req: Request, res: Response) {
  // 1. Input validation
  if (!req.body.items || req.body.items.length === 0) {
    return res.status(400).json({ error: 'Order must have items' });
  }

  // 2. Business logic
  const total = req.body.items.reduce((sum: number, item: any) => sum + item.price * item.qty, 0);
  if (total < 0) return res.status(400).json({ error: 'Invalid total' });

  // 3. Persistence
  const order = await db.query(
    'INSERT INTO orders (user_id, total) VALUES ($1, $2) RETURNING *',
    [req.user.id, total]
  );

  // 4. Notification
  await emailService.send(req.user.email, `Order ${order.rows[0].id} placed`);

  res.json(order.rows[0]);
}
// After: each responsibility has a home

// Validation
function validateCreateOrderRequest(body: unknown): OrderRequest {
  if (!body || !Array.isArray((body as any).items) || (body as any).items.length === 0) {
    throw new ValidationError('Order must have at least one item');
  }
  return body as OrderRequest;
}

// Business logic
function calculateOrderTotal(items: OrderItem[]): number {
  const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  if (total <= 0) throw new BusinessRuleError('Order total must be positive');
  return total;
}

// Persistence (OrderRepository)
class OrderRepository {
  async create(userId: string, total: number): Promise<Order> {
    const result = await db.query(
      'INSERT INTO orders (user_id, total) VALUES ($1, $2) RETURNING *',
      [userId, total]
    );
    return result.rows[0];
  }
}

// Notification (OrderNotificationService)
class OrderNotificationService {
  async notifyOrderPlaced(email: string, orderId: string): Promise<void> {
    await emailService.send(email, `Order ${orderId} placed`);
  }
}

// Controller: thin orchestration only
async function handleCreateOrder(req: Request, res: Response) {
  const request = validateCreateOrderRequest(req.body);
  const total = calculateOrderTotal(request.items);
  const order = await orderRepository.create(req.user.id, total);
  await notificationService.notifyOrderPlaced(req.user.email, order.id);
  res.json(order);
}

Anti-patterns

1. The God class

A class that grows to manage every aspect of a domain: UserService that handles authentication, profile updates, billing, and notification preferences. Any change to any feature requires modifying (and risking) all the others. Split by responsibility: AuthService, ProfileService, BillingService.

2. The utility module graveyard

utils.ts or helpers.ts that accumulates unrelated functions over months. Calculation helpers, string formatters, HTTP utilities, and date functions all in one file — sharing nothing except that someone wasn’t sure where to put them. Organise utilities by domain: numberUtils.ts, dateUtils.ts, httpUtils.ts.

3. Controllers with business logic

Business rules embedded directly in route handlers cannot be reused, tested in isolation, or changed without touching the HTTP layer. Extract business logic into domain services or use-case functions; keep controllers as thin orchestrators.

4. Functions with side-effect/transform conflation

// Does two unrelated things: transforms AND persists
function processAndSaveUser(data: RawUser): SavedUser {
  const normalized = normalizeUser(data);  // Transform
  db.save(normalized);                      // Side effect
  return normalized;
}

Separate transformation from side effects. Pure functions are independently testable; the persistence step can be tested with a mock or in an integration test.



Part of the PushBackLog Best Practices Library. Suggest improvements →