Skip to content

The SOLID Principles

SOLID is an acronym for five principles of object-oriented design. They share a single aim: code that absorbs change without breaking, and that you can test in isolation. Almost every pattern later in this course is really one of these principles applied to a specific problem, so learning them now means the patterns will feel familiar rather than arbitrary.

A class should have one reason to change. If a class both formats a report and emails it, a change to the email logic risks breaking the formatting, and vice versa. Splitting those concerns into separate classes means each has a single, well-defined job — and a single force that can require it to change.

Software should be open for extension but closed for modification. You should be able to add new behaviour by adding new code, not by editing tried-and-tested code. A payment processor that switches on a payment-type string must be edited for every new method; one that accepts a PaymentMethod interface gains new methods by adding a class, leaving the processor untouched.

Any subtype must be usable wherever its base type is expected, without surprising the caller. If a Square subclass of Rectangle silently changes both dimensions when you set the width, code that worked with rectangles breaks with squares. The subtype broke the contract; substitution must be honest.

Clients should not be forced to depend on methods they do not use. A fat Machine interface with print, scan, and fax forces a simple printer to implement fax it cannot perform. Several small, focused interfaces let each client depend on exactly what it needs.

High-level policy should not depend on low-level details; both should depend on an abstraction. Below, a NotificationService depends on a MessageSender interface rather than a concrete email class. Swapping in SMS — or a fake for tests — requires no change to the service at all.

interface MessageSender {
send(to: string, body: string): void;
}
class EmailSender implements MessageSender {
send(to: string, body: string): void {
console.log(`email to ${to}: ${body}`);
}
}
class NotificationService {
// Depends on the abstraction, not a concrete sender.
constructor(private readonly sender: MessageSender) {}
notify(user: string): void {
this.sender.send(user, 'Your order has shipped');
}
}
const service = new NotificationService(new EmailSender());
service.notify('[email protected]');

The diagram below contrasts the rigid version, where high-level policy points straight at a concrete detail, with the inverted version, where both depend on an abstraction.

classDiagram
  class NotificationService
  class MessageSender {
    <<interface>>
    +send(to, body)
  }
  class EmailSender
  class SmsSender
  NotificationService --> MessageSender : depends on abstraction
  EmailSender ..|> MessageSender : implements
  SmsSender ..|> MessageSender : implements
Dependency Inversion: policy and details both depend on an interface
What does the Single Responsibility Principle state?
The Open/Closed Principle says software should be:
In the Dependency Inversion example, what does NotificationService depend on?
Which principle is violated when a subtype breaks the behaviour callers expect from its base type?