SOLID Principles


If you’ve used an object‑oriented language for a while, you’ve probably run into the SOLID acronym. It’s a mnemonic for a set of principles that aren’t magic, but they consistently lead to code that scales. Code that’s easy to understand and therefore easier to change.

It might be an underappreciated quality, but I’d argue that “ease of change” is the most important quality of code for most of us, because most of what we do as developers is reading and changing code.

Even in this new era of AI, that hasn’t really changed. AI can generate a lot of code very quickly, but volume isn’t the same as design. Just like we get “AI slop” in text, we can get slop in code too. Good design still matters, maybe more than ever, because it’s what lets a system grow without collapsing under its own weight.

Single Responsibility Principle

Also known as just SRP, the Single Responsibility Principle states that a discrete module of code should have a single responsibility.

But that is easily misunderstood as: “This snippet of code must do one thing”.

I think a better name would be “Single Reason To Change Principle”. A discrete module of code (e.g., A class or related set of classes or functions) should have only one reason to change.

When a module changes, the impact should be isolated to that module, and its changes shouldn’t leak into other parts of the system. That’s what keeps the codebase predictable.

And if a reason to change affects more than one module, it means they are the same module. Either refactor it into a single module or extract the commonalities into its own module.

But that’s all quite abstract, let’s look at an actual example:

class OrderService {
constructor(private db: Database, private email: EmailClient) {}
async placeOrder(order: Order) {
// 1. Business rule: validate order
if (order.items.length === 0) {
throw new Error("Order must contain at least one item");
}
// 2. Persistence rule: save to DB
await this.db.save(order);
// 3. Communication rule: notify customer
await this.email.send({
to: order.customerEmail,
subject: "Order received",
body: "Thanks for your purchase!"
});
}
}

At first glance, it might look like the class is doing one thing: Placing an order. But it has three reasons to change:

  1. Validations might change
  2. Database might change
  3. Communication might change

The next logical step is to create one module for each of those things. Which, in most languages, that means a class:

class OrderValidator {
validate(order: Order) {
if (order.items.length === 0) {
throw new Error("Order must contain at least one item");
}
}
}
class OrderRepository {
constructor(private db: Database) {}
save(order: Order) {
return this.db.save(order);
}
}
class OrderNotifier {
constructor(private email: EmailClient) {}
notifyCustomer(order: Order) {
return this.email.send({
to: order.customerEmail,
subject: "Order received",
body: "Thanks for your purchase!"
});
}
}

Now our object is simply an orchestrator of smaller, focused objects:

class OrderService {
constructor(
private validator: OrderValidator,
private repo: OrderRepository,
private notifier: OrderNotifier
) {}
async placeOrder(order: Order) {
this.validator.validate(order);
await this.repo.save(order);
await this.notifier.notifyCustomer(order);
}
}

It’s important to note that SRP isn’t about making classes tiny, it’s about grouping code by the kind of change that affects it. When a set of behaviors evolves for the same underlying reason, it belongs together. When it changes for different reasons, it’s a sign the responsibilities are mixed.

Open/Closed Principle

Once responsibilities are separated, the next challenge is how to evolve behavior without constantly editing old code, and that’s exactly where the Open/Closed Principle helps.

The OCP (Open/Closed Principle) says that a module should be “open for extension, closed for modification”. But what does that mean, really? It’s a pretty confusing definition, but it makes sense once you see it in action.

In a nutshell, it means that the next time you have to make a change to a class, you won’t need to “open it” (literally opening the file in your editor and changing the code) but instead you will “extend it” by creating new code.

So rather than making changes by changing old code, you make changes by creating new code. This is one of my favorite principles because nobody really wants to touch old code, especially if someone else wrote it. Adding new code is easier and far less error‑prone.

Let’s look at an example:

class DiscountService {
calculate(price: number, type: "none" | "seasonal" | "vip") {
switch (type) {
case "seasonal":
return price * 0.9;
case "vip":
return price * 0.8;
default:
return price;
}
}
}

This class is not closed for modification because every time we want to make a change to the discount logic, we have to “open the class”, edit the switch statement and add some extra logic in there. This is the classic “change in one place breaks everything else” code smell.

So how do we fix it? In this case, we can use the replace conditional with polymorphism refactor.

You’ll see that most refactors and design patterns are just these fundamental principles applied consistently.

For example, when we apply this rule to this code, we end up with the Strategy Pattern!

interface DiscountStrategy {
apply(price: number): number;
}
// Null Object pattern by the way 👇
class NoDiscount implements DiscountStrategy {
apply(price: number) {
return price;
}
}
class SeasonalDiscount implements DiscountStrategy {
apply(price: number) {
return price * 0.9;
}
}
class VipDiscount implements DiscountStrategy {
apply(price: number) {
return price * 0.8;
}
}

Now the logic lives outside the switch. Each object already has the knowledge to do the discount perfectly isolated within themselves. So now the callers don’t need to know any details about the internals of the discount, they just ask each object to apply it, and they will know how:

class DiscountService {
constructor(private strategy: DiscountStrategy) {}
calculate(price: number) {
return this.strategy.apply(price);
}
}

We can now build the discounts dynamically at runtime:

const service = new DiscountService(new VipDiscount());
console.log(service.calculate(100)); // 80

If we want a different discount, we just pass in a different DiscountStrategy.

In the future, to add a new discount, we don’t need to modify DiscountService (it’s closed to modification), we can simply create a new class, implement DiscountStrategy, and pass it to our object. No need to change old code!

Liskov Substitution Principle

As soon as you start extending behavior, inheritance becomes tempting, and the Liskov Substitution Principle helps you avoid the subtle bugs that come from misusing it.

The LSP (Liskov Substitution Principle) states that any subtype should be able to replace its parent without breaking functionality.

In theory it sounds like programming languages enforce this for you, but there are subtle edge cases where we can still break the principle.

Let’s see an example:

class Rectangle {
constructor(public width: number, public height: number) {}
setWidth(w: number) {
this.width = w;
}
setHeight(h: number) {
this.height = h;
}
area() {
return this.width * this.height;
}
}

Our base type here is Rectangle. Now let’s extend it and create a subtype:

class Square extends Rectangle {
setWidth(w: number) {
this.width = w;
this.height = w; // forces height to match
}
setHeight(h: number) {
this.width = h;
this.height = h; // forces width to match
}
}

At compile-time, we might be able to use both a Square and a Rectangle interchangeably. But consider this function:

function resizeTo(rect: Rectangle) {
rect.setWidth(10);
rect.setHeight(5);
// Expectation: width = 10, height = 5
console.log(rect.area());
}

Oops! Squares always have the same width and height, so we can’t resize them like that. These kinds of bugs can be quite hard to debug.

There are a couple solutions to this problem, but the cleanest is to simply use an interface instead:

interface Shape {
area(): number;
}
class Rectangle implements Shape {
constructor(public width: number, public height: number) {}
area() {
return this.width * this.height;
}
}
class Square implements Shape {
constructor(public size: number) {}
area() {
return this.size * this.size;
}
}

Inheritance is one of the most misused features of object‑oriented languages. Most of the time, interfaces or composition leads to cleaner designs.

If you want to use inheritance, a good rule of thumb is to make sure that the subclasses use all of the methods from their base class.

Interface Segregation Principle

After looking at how inheritance can go wrong with LSP, ISP (Interface Segregation Principle) shifts the focus to interfaces. Specifically, how oversized contracts can force classes into awkward or misleading implementations.

The ISP states that consumers should depend only on the parts of an interface they actually use.

In other words, code should not depend on an interface that has a lot of “baggage” (methods that are not being used by this particular piece of code).

Let’s look at an example:

interface Worker {
work(): void;
attendMeeting(): void;
deployCode(): void;
}

Now every worker must implement all three methods, even if it doesn’t quite make sense:

class Developer implements Worker {
work() { /* coding */ }
attendMeeting() { /* standup */ }
deployCode() { /* CI/CD */ }
}
class Designer implements Worker {
work() { /* design */ }
attendMeeting() { /* design review */ }
// Designer doesn't deploy code, but is forced to implement it!
deployCode() {
throw new Error("Designers don't deploy code");
}
}

The designer class is forced to implement irrelevant behavior. So how can we fix this?

First, create specialized interfaces, each with a single role:

interface CanWork {
work(): void;
}
interface CanAttendMeetings {
attendMeeting(): void;
}
interface CanDeployCode {
deployCode(): void;
}

We then implement only the ones we actually need:

class Developer implements CanWork, CanAttendMeetings, CanDeployCode {
work() { /* coding */ }
attendMeeting() { /* standup */ }
deployCode() { /* CI/CD */ }
}
class Designer implements CanWork, CanAttendMeetings {
work() { /* design */ }
attendMeeting() { /* design review */ }
}

Now consumers can actually cherry-pick what features they need:

function runDailyWork(worker: CanWork) {
worker.work();
}
function runDeployment(engineer: CanDeployCode) {
engineer.deployCode();
}

This is great because smaller interfaces can evolve independently without breaking unrelated code. It also allows clients to be clearer and consume only what they actually need.

Another advantage we get for free is easier testing, because mocks and stubs become smaller and more focused.

A good rule to remember is: If it’s hard to test, it’s not well designed.

Dependency Inversion Principle

With interfaces in good shape, the final step is making sure high‑level logic doesn’t depend on low‑level details, which is where the DIP (Dependency Inversion Principle) ties everything together.

The name can be a bit confusing, but hopefully it will make more sense after the examples. For now, you can think of DIP as: Depend on abstractions, not concretions.

Let’s look at an example:

class EmailClient {
send(to: string, message: string) {
console.log(`Sending email to ${to}: ${message}`);
}
}
class OrderService {
// Direct dependency on a concrete class!
private email = new EmailClient();
placeOrder(order: Order) {
// business logic...
this.email.send(order.customerEmail, "Order received!");
}
}

Our example above depends on a concretion (an actual class implementation) rather than an abstraction (an interface).

If we want to change to SMS or push notifications, we’ll need to re-open the class (violating the Open/Closed Principle).

Also, high-level code (placing an order) depends on low-level code (how emails are sent).

In this case, the fix is quite simple. First, we create an abstraction:

interface Notifier {
notify(to: string, message: string): void;
}

We then make our concretions (classes) implement our abstraction (interface):

class EmailNotifier implements Notifier {
notify(to: string, message: string) {
console.log(`Sending email to ${to}: ${message}`);
}
}
class SmsNotifier implements Notifier {
notify(to: string, message: string) {
console.log(`Sending SMS to ${to}: ${message}`);
}
}

And now we can depend on the abstraction rather than the concretion:

class OrderService {
// We don't depend on a concretion anymore!
constructor(private notifier: Notifier) {}
placeOrder(order: Order) {
// business logic...
this.notifier.notify(order.customerEmail, "Order received!");
}
}

Our code is now more flexible, we can decide at runtime which strategy we want to use:

const service = new OrderService(new EmailNotifier());
service.placeOrder(order);

So switching implementations is trivial:

// Use SMS instead
const service = new OrderService(new SmsNotifier());

So why is it called “Dependency Inversion”? Well, if you look at the dependency diagram of our initial version:

OrderService -> EmailClient

The arrow goes from OrderService to EmailClient because the service depends on the email client.

But after our changes:

OrderService -> Notifier <- EmailNotifier

Now EmailNotifier depends on Notifier, and OrderService doesn’t depend on EmailNotifier at all, it only depends on Notifier.

So, the direction of the arrow was inverted. Our high-level code (OrderService) doesn’t depend on our low-level code anymore, instead our low‑level code (EmailNotifier) depends on the abstraction defined by the high‑level policy (Notifier).

Note that, at runtime, the order service does depend on the concrete email implementation, but at compile-time, it doesn’t, and it can be easily replaced for other implementations.

Conclusion

SOLID isn’t about following rules for their own sake. It’s about making code easier to change without fear.

When responsibilities are clear, behavior can be extended safely, subtypes behave predictably, interfaces stay focused, and high‑level logic depends on stable abstractions, your system becomes far more resilient.

Imagine working on a system where, no matter how big it gets, changes to one module don’t leak into others and break random things. A system where modules can be tested in isolation, and most implementation details — databases, email services, libraries, even frameworks — can be replaced or mocked without hassle. That kind of flexibility gives teams confidence, and confidence is what allows systems to scale gracefully.

SOLID principles help software projects manage complexity. Object‑oriented code can be verbose and sometimes feel ceremonial; it often requires going through more layers of indirection than a more “direct” approach. But that indirection is what makes the system manageable.

At a small scale, it does add some complexity. But in real‑world projects, where complexity compounds day after day, it’s far easier to follow a chain of small, focused objects than to untangle 5,000 lines of untestable, fragile spaghetti code where one change might break something completely unrelated on the other end of the system. That’s where scalability becomes a real issue. Where not even AI can save you from the mess.

And in a world where AI can generate code faster than ever, these principles help ensure that what gets generated is still worth maintaining.

🐝 How Beezwax Can Help

Beezwax helps teams design, build, and scale software that stays flexible as it grows. If you’re wrestling with architecture decisions, refactoring a legacy system, or trying to bring more SOLID thinking into your codebase, this is exactly the kind of work we do.

We build across the entire digital stack — web, mobile, desktop, FileMaker, AI, and design — and we help teams modernize systems, improve maintainability, and ship with confidence.

If you want a partner who can help you untangle complexity or accelerate your next project, we’d love to talk.