SOLID Principles
First-principles notes on SRP, OCP, LSP, ISP, and Dependency Inversion with Go and Java examples.
SRP
SRP state that a class should have only one reason to change viz. each class should have only one resposibility.
so mutiple developer can work different responsibility
e.g Not following SRP
class Booking{
void CalculateFare(){}
int ApplyDiscountOnFair(){}
bool confirmBookingSaveTODB(){}
void sendNotifiation(){}
}
package main
import "fmt"
type Booking struct {
ID string
BaseFare float64
}
// Violates SRP: Calculating fares, database persistence, and sending
// notifications are completely different responsibilities.
func (b *Booking) CalculateFare() float64 {
return b.BaseFare * 1.18 // Adding tax
}
func (b *Booking) SaveToDB() bool {
fmt.Printf("Saving booking %s to MySQL...\n", b.ID)
return true
}
func (b *Booking) SendSMSNotification() {
fmt.Printf("Sending SMS notification for booking %s...\n", b.ID)
}
if u notice this class have multiple responsibility so this class is not followinf SRP
and there are several reason to change
suppose fare calculation logic changes
suppose apply discount changes based on market coditon or may be in future discount system is temprorarily removed
in future db can be changed from in-premise mysql to Mongodb to cloudbased
in furture ,notification service can be changed from sms,emai,whatsapp to what not.
so in order to make this design follow SRP we decompose it into multiple classes
class CalculateFare{
float CalculateFare(){}
}
class Discount{
float ApplyDiscountOnFair(){}
}
class CofirmBooking{
bool confirmBookingSaveTODB(){}
}
class Notification {
void sendNotifiation(){}
}
package main
import "fmt"
// Booking only holds data structures
type Booking struct {
ID string
BaseFare float64
}
// FareCalculator handles ONLY pricing logic
type FareCalculator struct{}
func (fc *FareCalculator) Calculate(baseFare float64) float64 {
return baseFare * 1.18
}
// BookingRepository handles ONLY database saving/loading
type BookingRepository struct{}
func (repo *BookingRepository) Save(b Booking) bool {
fmt.Printf("Saving booking %s to Cloud DB...\n", b.ID)
return true
}
// NotificationService handles ONLY messaging channels
type NotificationService struct{}
func (ns *NotificationService) SendWhatsApp(b Booking) {
fmt.Printf("Sending WhatsApp notification for booking %s...\n", b.ID)
}
now each class has one job
and changes are isolated.
code becomes unit testable
Open and closed principle
so this state that software should be open for extension but close for modification
viz.,This principle states that software entities (classes, modules, functions) should be open for extension, but closed for modification.
e.g suppose now in a class we have discount system for all users
in future client need additional support VIP user, festival offer, black friday offer
so we cannot do if and if and else in the same class this way we are changing the same class mutliple times
so what we do we make use of polymorphism where we introduce a parent class which has specification of discount(interface method)
so any class overriding and implementing it have their own definiton of discount
this we we acheive the discount interface method is open for extension by any class but the parent class is closed for modification
e.g without following open and closed principle
class DiscountService {
public double calculateDiscount(String type, double amount) {
// Version 1: General Users
if (type.equals("general")) {
return amount - (amount * 0.15);
}
// Version 2: Modified to add VIP
if (type.equals("vip")) {
return amount - (amount * 0.30);
}
// Version 3: Modified to add Festival Offer...
// Every new requirement forces us to modify this exact same class!
return amount;
}
}
package main
type DiscountService struct{}
// Violates OCP: Every time a new discount type arrives, we must edit this method.
func (ds *DiscountService) CalculateDiscount(discountType string, amount float64) float64 {
if discountType == "general" {
return amount - (amount * 0.15)
}
if discountType == "vip" {
return amount - (amount * 0.30)
}
// Modifying this file for "festival" or "black_friday" risks breaking existing code!
return amount
}
if u notice we are modifying the same class again and again when asked by client for additional discount feature
Refactored: Following OCP
We introduce an interface. The core design is now closed to modification, but completely open to extension by creating new classes.
interface DiscountStrategy {
double applyDiscount(double amount);
}
class GeneralDiscount implements DiscountStrategy {
@Override
public double applyDiscount(double amount) {
return amount - (amount * 0.15);
}
}
class VIPDiscount implements DiscountStrategy {
@Override
public double applyDiscount(double amount) {
System.out.println("Calculating VIP discount...");
return amount - (amount * 0.30);
}
}
// We can easily add a festival offer without touching existing code!
class FestivalDiscount implements DiscountStrategy {
@Override
public double applyDiscount(double amount) {
return amount - (amount * 0.20);
}
}
With OCP (Using Go Interfaces)
In Go, an interface is satisfied implicitly. Any struct that implements an Apply method matching the interface signature is automatically considered a DiscountStrategy. We can extend our system with infinite discount types without changing a single line of core logic.
package main
import "fmt"
// 1. Core interface: Open for extension, closed for modification.
type DiscountStrategy interface {
Apply(amount float64) float64
}
// 2. Extensible implementations
type GeneralDiscount struct{}
func (gd GeneralDiscount) Apply(amount float64) float64 {
return amount - (amount * 0.15)
}
type VIPDiscount struct{}
func (vd VIPDiscount) Apply(amount float64) float64 {
return amount - (amount * 0.30)
}
// 3. New feature requirement? Just add a new struct. No modification of old files.
type FestivalDiscount struct{}
func (fd FestivalDiscount) Apply(amount float64) float64 {
return amount - (amount * 0.20)
}
// 4. Usage example
func main() {
orderAmount := 1000.0
// We pass different strategies seamlessly
var strategy DiscountStrategy
strategy = VIPDiscount{}
fmt.Println("VIP Price:", strategy.Apply(orderAmount)) // 700
strategy = FestivalDiscount{}
fmt.Println("Festival Price:", strategy.Apply(orderAmount)) // 800
}
Now whenever we need to implement new discount type we dont need to modify any class just need to create a class and the new class will implement the inteface of parent class for new discount strategy.
so hence we made no modification in our old logic.
Liskov Substitution Principle
a child class must be able to do everything the parent class promises, without throwing unexpected errors or changing the expected behavior.
this state that a subclass cannot force a method which it should not have.
e.g penguin belongs to bird but cannot fly, bank have multiple types of account saving account can withdraw but fixed deposit account cannot , linux local storage have write read and setpermission function but awss3 dont have normal setpermission method like linux os.
e.g1.,
//our contract
type Storage interface{
Read() error
Write(data string) error
Setpermission(perm string) error
}
//class
type LocalStorage struct{}
func (ls *LocalStorage) Write(data string) error{
}
func (ls *LocalStorage)Read() error{
}
func (ls *LocalStorage)Setpermission(perm string)error{
}
//now voilation of lsp occurs here
type CloudStorage struct{}
func (cs *CloudStorage)read()error{
}
func (cs *CloudStorage)Write(data string)error{
}
//now s3 doesnot support linux style permission
// so returing an error or panic break application flow
//the parent expect that all it's child should support the contract let it be anyone but here we need to return an error
//this breaks lsp
func (cs *CloudStorage)Setpremission(perm string) error{
}
//now suppose client makes backup at two places local and s3
func (st *Storage) Backup{
err:=st.Write("xyz.txt")
if err!=nil{
fmt.Println("write failed")
}
err:=st.Setpermission("777") //// This breaks or acts unexpectedly when given to S3!
if err!=nil{
fmt.Println("backup crashed...)
}
}
// Why this violates LSP:** `S3CloudStorage` claims it is a `Storage` type, but it cannot fully substitute `LocalStorage` because it chokes on `SetPermissions`. The client code (`SetupBackup`) is forced to write special checks or risk breaking.
package main
import "fmt"
// 1. Break the interface down so structs don't inherit methods they don't support.
type Writer interface {
Write(data string)
}
type PermissionModifier interface {
SetPermissions(perm string)
}
// 2. LocalStorage can implement BOTH contracts safely.
type LocalStorage struct{}
func (ls LocalStorage) Write(data string) {
fmt.Println("Writing data to local disk:", data)
}
func (ls LocalStorage) SetPermissions(perm string) {
fmt.Println("Setting local permissions to", perm)
}
// 3. S3CloudStorage ONLY implements the Writer contract. It does not pretend to modify permissions.
type S3CloudStorage struct{}
func (s3 S3CloudStorage) Write(data string) {
fmt.Println("Uploading data to AWS S3:", data)
}
// 4. Client functions now accept exactly what they need.
// Any implementation of Writer can be swapped here perfectly without unexpected behavior.
func UploadBackup(w Writer) {
w.Write("backup_v2.tar.gz")
}
With LSP (Splitting the Contracts Gracefully)
To fix this according to LSP, we should not force structs to implement interface methods they cannot realistically fulfill. Instead, we break down our interfaces into smaller, atomic pieces (which also naturally aligns with Go’s philosophy of small interfaces).
package main
import "fmt"
// 1. Break the interface down so structs don't inherit methods they don't support.
//who so ever need to implement any of the inteface(contract) they will implemtn it(match the signature in go)
type Writer interface {
Write(data string)
}
type PermissionModifier interface {
SetPermissions(perm string)
}
// 2. LocalStorage can implement BOTH contracts safely.
type LocalStorage struct{}
func (ls LocalStorage) Write(data string) {
fmt.Println("Writing data to local disk:", data)
}
func (ls LocalStorage) SetPermissions(perm string) {
fmt.Println("Setting local permissions to", perm)
}
// 3. S3CloudStorage ONLY implements the Writer contract. It does not pretend to modify permissions.
type S3CloudStorage struct{}
func (s3 S3CloudStorage) Write(data string) {
fmt.Println("Uploading data to AWS S3:", data)
}
// 4. Client functions now accept exactly what they need.
// Any implementation of Writer can be swapped here perfectly without unexpected behavior.
func UploadBackup(w Writer) {
w.Write("backup_v2.tar.gz")
}
if you substitute a child/implementation, the program should still run flawlessly. If your code says "if Type == X { do this; } else if Type == Y { throw NotSupported; }" inside a shared method, you are likely breaking LSP.
JAVA:Banking Accounts
In banking systems, we might have standard savings accounts and fixed-term investment accounts (where your money is locked for a year, meaning you cannot withdraw it early).
If we design this poorly with inheritance, we break LSP.
Without LSP (The "Throw Exception" Trap)
Here, we force the `FixedTermDepositAccount` to inherit from `BankAccount`. Because a fixed-term account cannot allow withdrawals, we throw an exception. This completely breaks the parent class's guarantee.
// The Parent Class
class BankAccount {
protected double balance;
public void deposit(double amount) {
this.balance += amount;
}
// The contract promises that calling this will deduct money.
public void withdraw(double amount) {
this.balance -= amount;
}
public double getBalance() {
return balance;
}
}
// A Standard Account (Works fine)
class SavingsAccount extends BankAccount {
// Behaves exactly like a standard bank account
}
// --- VIOLATION OF LSP ---
class FixedTermDepositAccount extends BankAccount {
@Override
public void withdraw(double amount) {
// LSP Violation! We are breaking the behavior promised by the parent class.
// Client code expecting a standard BankAccount will crash unexpectedly here.
throw new UnsupportedOperationException("Money is locked! You cannot withdraw from a Fixed Term account early.");
}
}
// Client application processing withdrawals
class ATM {
public void processWithdrawal(BankAccount account, double amount) {
// If a FixedTermDepositAccount is passed here, the app crashes!
// We shouldn't have to check "if (account instanceof FixedTermDepositAccount)" to prevent a crash.
account.withdraw(amount);
}
}
// Why this violates LSP:The `ATM` class assumes that any instance of `BankAccount` can handle a withdrawal. By substituting `BankAccount` with `FixedTermDepositAccount`, the system breaks down because the subclass fails to deliver on the parent's fundamental guarantee.
With LSP (Refactored to Share the Right Contract)
To fix this according to LSP, we ensure that a subclass never inherits a behavior it cannot fulfill. We split the accounts into their proper logical hierarchies or interfaces.
// 1. Base class for all accounts (both support checking balance and depositing)
abstract class Account {
protected double balance;
public void deposit(double amount) {
this.balance += amount;
}
public double getBalance() {
return balance;
}
}
// 2. An explicit interface/class for accounts that actually support transactional withdrawals
abstract class WithdrawableAccount extends Account {
public void withdraw(double amount) {
this.balance -= amount;
}
}
// 3. Savings Account inherits directly from WithdrawableAccount
class SavingsAccount extends WithdrawableAccount {
// Safely supports deposit, balance checking, and withdrawal
}
// 4. Fixed Term Account only inherits from Account, because it isn't meant to be withdrawn from via an ATM
class FixedTermDepositAccount extends Account {
// Safely supports deposit and balance checking. No withdrawal method to break!
}
// 5. Client code is now 100% type-safe and substitution safe
class ATM {
// The ATM only accepts accounts that are explicitly marked as WithdrawableAccount.
public void processWithdrawal(WithdrawableAccount account, double amount) {
account.withdraw(amount); // Guaranteed to work for any subclass passed here!
}
If you find yourself writing a subclass and overriding a method just to type throw new UnsupportedOperationException();, your inheritance hierarchy is flawed. You are violating the Liskov Substitution Principle.
Interface Segregation Principle
the lsp is the goal or strategy and isp is the tool which help in acheiving lsp and these both are deeply interconnected.
suppose u bloated and made a large interface and at the end u will be voilatinng LSP because ur class will be forced to implement all the methods of interfaces.
so when we apply lsp we break down the large interfaces into smaller interfaces(lean interfaces) like role specifics , as if any class implement the interface fulfill the contract ultimately we are acheiving LSP.
e.g smartdevices can print,scan,brew,sense
if a printer implements the smartdevice . can it brew ? No.
so printer is forced to depend on brew which it doesnot use.
if main application loop through all the smart device objects and call brewcoffee() the application will crash when it hits printer object.
// VIOLATION OF ISP: The interface is too fat.
interface SmartDevice {
void printDocument();
void scanDocument();
void brewCoffee();
}
Now, we try to create a standard office Printer using this interface.
// ❌ VIOLATION OF LSP: Because we violated ISP, this class is forced to break LSP guarantees.
class OfficePrinter implements SmartDevice {
public void printDocument() { }
public void scanDocument() { }
public void brewCoffee() {
// A printer cannot make coffee! We crash the app or throw an exception.
throw new UnsupportedOperationException("Printers cannot brew coffee!");
}
}
so following ISP
//FOLLOWS ISP: Interfaces are lean and segregated by capability.
interface Printer {
void printDocument();
}
interface Scanner {
void scanDocument();
}
interface CoffeeMaker {
void brewCoffee();
}
Now, we implement our classes based _only_ on what they actually do.
//FOLLOWS LSP: This class can now perfectly substitute a Printer without surprises.
class OfficePrinter implements Printer, Scanner {
public void printDocument() { /* Printing logic */ }
public void scanDocument() { /* Scanning logic */ }
}
class PremiumEspressoMachine implements CoffeeMaker {
public void brewCoffee() { /* Espresso logic */ }
}
By isolating capabilities into distinct interfaces (ISP), we completely eliminated the risk of a subclass throwing an UnsupportedOperationException. The OfficePrinter is now a 100% safe substitute for a Printer contract (LSP).
Dependency Inversion
it’s backbone of spring framework
it states that high level module should not depend on(tightly coupled)low level module both should work on abstraction.
suppose we are following clean architecture principle for project building
now in verison one we used mysql but our future plan is to implement mongodb,RDS then our top layer i.e repository should not be dependent on mysql else it will create tight coupling and business logics break. so we abstract it in interface so any client lets say mysql,postgres,monogd they implement the interface methods for repository and have their own implementation definition so business logics dont break.
Without DIP (Tightly Coupled)
If the business service directly instantiates the MySQL class, you cannot swap databases without rewriting your core business code.
// Low-Level Module
class MySqlRepository {
public void saveBooking() { System.out.println("Saved to MySQL"); }
}
// High-Level Module
class BookingService {
// ❌ Violation: Tightly coupled to a specific database implementation!
private MySqlRepository repository = new MySqlRepository();
public void processBooking() {
repository.saveBooking();
}
}
GO: Without DIP (Tightly Coupled Go Code)
In this bad example, the high-level service directly creates and depends on a concrete MySQL struct. If you want to switch to MongoDB, you have to rewrite the service.
package main
import "fmt"
// Low-Level Module
type MySqlRepository struct{}
func (repo *MySqlRepository) SaveBooking() {
fmt.Println("Saved to MySQL database")
}
// High-Level Module
type BookingService struct {
// ❌ Violation: Tightly coupled to a specific database struct!
db *MySqlRepository
}
func (s *BookingService) Process() {
s.db.SaveBooking()
}
With DIP (Spring / Clean Architecture Style)
We invert the dependency by creating an interface. Spring can now dynamically inject whatever database implementation we want at runtime using @Autowired or constructor injection.
// 1. The Abstraction (The Repository Interface)
interface BookingRepository {
void saveBooking();
}
// 2. Low-Level Implementations (They conform to the abstraction)
@Repository
class MySqlRepository implements BookingRepository {
public void saveBooking() { System.out.println("Saved to MySQL"); }
}
@Repository
class MongoRepository implements BookingRepository {
public void saveBooking() { System.out.println("Saved to MongoDB Atlas Cloud"); }
}
// 3. High-Level Module (Perfectly decoupled)
@Service
class BookingService {
private final BookingRepository repository;
// Spring automatically injects the active bean here.
// BookingService doesn't know (or care) which database is running underneath!
public BookingService(BookingRepository repository) {
this.repository = repository;
}
public void processBooking() {
repository.saveBooking();
}
}
GO: With DIP (Decoupled, Idiomatic Go Code)
To fix this, the high-level module defines an interface outlining exactly what it needs. Any database struct that has a SaveBooking() method can be injected into it seamlessly.
package main
import "fmt"
// 1. The Abstraction: Owned by the business logic layer
type BookingRepository interface {
SaveBooking()
}
// 2. High-Level Module: Depends ONLY on the interface
type BookingService struct {
repo BookingRepository // ✔️ Decoupled! Accepts anything that satisfies the interface.
}
func (s *BookingService) Process() {
s.repo.SaveBooking()
}
// 3. Low-Level Module: MySQL implementation
type MySqlRepository struct{}
func (m MySqlRepository) SaveBooking() {
fmt.Println("Saved to local MySQL database")
}
// 4. Low-Level Module: MongoDB implementation
type MongoRepository struct{}
func (mongo MongoRepository) SaveBooking() {
fmt.Println("Saved to MongoDB Atlas Cloud")
}
// 5. Wiring it together (Dependency Injection)
func main() {
// We can easily swap out the repository implementation at runtime
mysqlRepo := MySqlRepository{}
mongoRepo := MongoRepository{}
// Injecting MySQL
serviceWithMySQL := BookingService{repo: mysqlRepo}
serviceWithMySQL.Process() // Prints: Saved to local MySQL database
// Injecting MongoDB without changing a single line of code inside BookingService!
serviceWithMongo := BookingService{repo: mongoRepo}
serviceWithMongo.Process() // Prints: Saved to MongoDB Atlas Cloud
}