SOLID Principles: Write Maintainable Software
Table of Contents
- Introduction
- What Is SOLID?
- S: Single Responsibility Principle
- O: Open/Closed Principle
- L: Liskov Substitution Principle
- I: Interface Segregation Principle
- D: Dependency Inversion Principle
- When SOLID Helps
- Conclusion
Introduction
As a project grows, code that once felt simple can become harder to maintain. A small change starts affecting many files. Tests become difficult to write. Classes know too much about each other. Adding a feature feels risky.
SOLID is a set of design principles that helps object-oriented code stay easier to change. It does not automatically make code good, but it gives developers a vocabulary for spotting design problems.
To keep the examples concrete, this article uses TypeScript. Its class, interface, and type system make responsibility boundaries, object contracts, and dependency injection easier to see.
What Is SOLID?
SOLID stands for:
- Single Responsibility Principle
- Open/Closed Principle
- Liskov Substitution Principle
- Interface Segregation Principle
- Dependency Inversion Principle
These principles are especially useful when building services, domain logic, modules, and systems that will evolve over time.
S: Single Responsibility Principle
The Single Responsibility Principle says a class should have one reason to change.
For example, this class has too many responsibilities:
type InvoiceItem = {
price: number;
quantity: number;
};
type Invoice = {
id: string;
items: InvoiceItem[];
};
class InvoiceService {
calculateTotal(items: InvoiceItem[]) {
return items.reduce((total, item) => total + item.price * item.quantity, 0);
}
saveToDatabase(invoice: Invoice) {
// Save invoice
}
sendEmail(invoice: Invoice) {
// Send invoice email
}
generatePdf(invoice: Invoice) {
// Generate invoice PDF
}
}
It calculates, persists, emails, and generates files. A better design separates responsibilities:
class InvoiceCalculator {
calculateTotal(items: InvoiceItem[]) {
return items.reduce((total, item) => total + item.price * item.quantity, 0);
}
}
class InvoiceRepository {
save(invoice: Invoice) {
// Save invoice
}
}
class InvoiceMailer {
send(invoice: Invoice) {
// Send invoice email
}
}
class InvoicePdfGenerator {
generate(invoice: Invoice) {
// Generate invoice PDF
}
}
Now each class changes for a clearer reason.
O: Open/Closed Principle
The Open/Closed Principle says code should be open for extension but closed for modification.
Imagine a payment service with many if statements:
function pay(method: string, amount: number) {
if (method === "credit_card") {
// Pay by card
} else if (method === "paypal") {
// Pay by PayPal
}
}
Every new payment method requires editing the same method. A more extensible design uses polymorphism:
interface PaymentGateway {
pay(amount: number): void;
}
class CreditCardGateway implements PaymentGateway {
pay(amount: number) {
console.log(`Paying ${amount} by credit card`);
}
}
class PaypalGateway implements PaymentGateway {
pay(amount: number) {
console.log(`Paying ${amount} by PayPal`);
}
}
class PaymentService {
constructor(private readonly gateway: PaymentGateway) {}
checkout(amount: number) {
this.gateway.pay(amount);
}
}
Adding a new gateway can be done by adding a new class instead of editing core payment logic.
L: Liskov Substitution Principle
The Liskov Substitution Principle says a subtype should be usable anywhere its parent type is expected.
If a class extends another class but breaks expected behavior, the design becomes dangerous.
For example, if ReadOnlyFile extends File but throws an error whenever write() is called, code that expects a normal File may break unexpectedly.
The lesson: inheritance should preserve behavior. If a child class cannot safely behave like the parent, composition or a different abstraction may be better.
I: Interface Segregation Principle
The Interface Segregation Principle says clients should not be forced to depend on methods they do not use.
Avoid large interfaces like:
interface Worker {
code(): void;
design(): void;
test(): void;
deploy(): void;
}
Not every worker does all of those things. Smaller interfaces are easier to implement:
interface CanCode {
code(): void;
}
interface CanDesign {
design(): void;
}
interface CanTest {
test(): void;
}
interface CanDeploy {
deploy(): void;
}
Focused interfaces reduce fake methods, empty implementations, and unnecessary coupling.
D: Dependency Inversion Principle
The Dependency Inversion Principle says high-level modules should depend on abstractions, not concrete details.
Instead of a service depending directly on a specific email provider:
class MailgunClient {
sendMessage(to: string, subject: string, body: string) {
// Send email through Mailgun
}
}
class UserService {
constructor(private readonly mailer: MailgunClient) {}
welcomeUser(email: string) {
this.mailer.sendMessage(email, "Welcome", "Thanks for joining us.");
}
}
depend on an interface:
interface Mailer {
send(to: string, subject: string, body: string): void;
}
class MailgunMailer implements Mailer {
send(to: string, subject: string, body: string) {
// Send email through Mailgun
}
}
class UserService {
constructor(private readonly mailer: Mailer) {}
welcomeUser(email: string) {
this.mailer.send(email, "Welcome", "Thanks for joining us.");
}
}
Now the service is easier to test and the email provider can be changed without rewriting business logic.
When SOLID Helps
SOLID is most useful when:
- a codebase has many business rules
- classes are reused in multiple places
- tests are difficult because dependencies are hard-coded
- features keep changing
- several developers work on the same system
- the project will be maintained for a long time
For tiny scripts, applying every principle heavily can be overkill. Use SOLID to reduce real complexity, not to add ceremony.
Conclusion
SOLID helps developers design code that is easier to extend, test, and maintain.
The practical goal is simple: when requirements change, the code should not collapse under its own structure. Start with responsibility boundaries, reduce direct dependencies, and introduce abstractions only when they make the system easier to work with.
In the examples above, TypeScript helps make the core idea visible: define clear contracts, keep implementations focused, and avoid letting high-level code depend too tightly on low-level details.
Related Articles
Keep reading within the same topic.
KISS, DRY, SoC, YAGNI, SOLID: Fix Unhealthy Code
Learn how KISS, DRY, separation of concerns, YAGNI, and SOLID help make code simpler, cleaner, easier to test, and easier to maintain.
Domain-Driven Design: Structuring Large Codebases
Use Domain-Driven Design to structure large codebases with ubiquitous language, bounded contexts, entities, value objects, aggregates, repositories, and domain services.
CQRS Pattern: Separate Reads and Writes for Scalable Systems
Use CQRS to separate read and write models, improve scalability, simplify complex queries, and design clearer application boundaries.
OpenClaw AI Assistant: Local-First Personal Command Center
Explore OpenClaw as a local-first AI assistant and personal command center for messaging apps, workflows, and multi-channel automation.