Robert C. Martin, an American software engineer, author and instructor promoted the idea of S.O.L.I.D design principles in Software engineering.
S.O.L.I.D, a set of five software design principles, helps you to write maintainable, scalable (without performance degradation), testable and extendable code (addition of new features/functionality).
The Five S.O.L.I.D Pricinples
- Single Responsibility principle
- Open/Closed principle
- Liskov Substitution principle
- Interface Segregation principle
- Dependency Inversion pricinple
1. Single Responsibility Principle
A class must have single responsibility. Imagine you’re building an ecommerce application. The OrderService class handles multiple responsibilities — Order generation, sending emails & invoice generation.
Bad Design
class OrderService {
createOrder() {
// Save order to database
}
sendEmail() {
// Send confirmation email
}
generateInvoice() {
// Generate PDF invoice
}
}
The same can be turned in to single responsibility classes in the following manner. Now each class has a clearly defined responsibility.
Good Design
class OrderService {
createOrder() {
// Create order
}
}
class EmailService {
sendEmail() {
// Send confirmation email
}
}
class InvoiceService {
generateInvoice() {
// Generate invoice
}
}
Why It Matters?
In case the email provider changes, we only need to modify the EmailService class and not the rest.
2. Open/Closed Principle
Software entities must be open for extension and closed for modification. Imagine the same e-commerce Application supports different payment integrations.
Bad Design
class PaymentService {
pay(method: string) {
if (method === "stripe") {
// Stripe payment
} else if (method === "paypal") {
// PayPal payment
}
}
}
Each time a new payment integration is required, PaymentService must be modified. This is bad design. the same can be turned into the following.
Good Design
interface PaymentGateway {
pay(amount: number): void;
}
class StripePayment implements PaymentGateway {
pay(amount: number) {
console.log("Stripe:", amount);
}
}
class PayPalPayment implements PaymentGateway {
pay(amount: number) {
console.log("PayPal:", amount);
}
}
class PaymentService {
constructor(private gateway: PaymentGateway) {}
process(amount: number) {
this.gateway.pay(amount);
}
}
By combining interfaces and single responsible classes, you can then use the classes like below:
const payment = new PaymentService(
new StripePayment()
);
payment.process(1000);
Why It Matters?
PaymentService doesn’t need to be modified in order to add a new payment gateway provider and is considered closed. The above system is open to the addition of new payment providers.
3. Liskov Substitution Pricinple
Take a look at the following example. A subclass must be usable whenever its parent’s class is expected without breaking the behaviour.
In ReadOnlyStorage, save() is inherited but not fulfilled.
Bad Design
class FileStorage {
save(file: string) {
console.log("Saving:", file);
}
}
class ReadOnlyStorage extends FileStorage {
save(file: string) {
throw new Error("Cannot save");
}
}
Good Design
interface ReadableStorage {
read(): string;
}
interface WritableStorage {
save(file: string): void;
}
class CloudStorage
implements ReadableStorage, WritableStorage {
read() {
return "file contents";
}
save(file: string) {
console.log("Saved:", file);
}
}
class ReadOnlyStorage implements ReadableStorage {
read() {
return "file contents";
}
}
In this case, ReadOnlyStorage fulfils the contract and so is the CloudStorage.
Why It Matters?
The substitutions read() and save() are implemented without breaking the contract.
4. Interface Segregation Principle
Clients (Class, modules or components) should not be forced to depend on methods they don’t use. Look at the following example. When a class implements `Worker`, all methods are forced.
Bad Design
interface Worker {
code(): void;
design(): void;
test(): void;
}
In this case, the contract is forced.
class Developer implements Worker {
code() {
console.log("Writing code");
}
design() {
throw new Error("Not supported");
}
test() {
throw new Error("Not supported");
}
}
Good Design
interface Developer {
code(): void;
}
interface Designer {
design(): void;
}
interface Tester {
test(): void;
}
class FrontendEngineer
implements Developer, Designer {
code() {
console.log("Writing code");
}
design() {
console.log("Designing UI");
}
}
Why It Matters?
Smaller, focused interfaces reduce unnecessary dependencies and make systems easier to evolve.
Dependency Inversion Pricinple
High-level business logic should depend on abstractions and not on concrete implementations.
Bad Design
class MySQLDatabase {
save(data: string) {
console.log("Saved to MySQL");
}
}
class UserService {
private db = new MySQLDatabase();
createUser(name: string) {
this.db.save(name);
}
}
You can observe that UserService directly depends on MySQLDatabase which is bad. Also, switching to another service requires changing UserService.
This can be made better in the following manner.
Good Design
interface Database {
save(data: string): void;
}
class MySQLDatabase implements Database {
save(data: string) {
console.log("Saved to MySQL");
}
}
class PostgresDatabase implements Database {
save(data: string) {
console.log("Saved to PostgreSQL");
}
}
class UserService {
constructor(private db: Database) {}
createUser(name: string) {
this.db.save(name);
}
}
Look, how switching provider is made possible in the run time based on the requirement.
const db = new PostgresDatabase();
const userService = new UserService(db);
userService.createUser("John");
Why It Matters?
Swapping implementations become easier. So is introducing mocks for unit testing. Also, dependency inversion can reduce coupling between components.
Along with SOLID, you may have heard about KISS (Keep It Short and Simple) and YAGNI (You’re Not Gonna Need It) in Software engineering.