SOLID Principles: The Foundation of Great Software Design
The Story of a Collapsing Codebase
Picture this. You join a new company, excited about your first day. Your manager assigns you a "simple" task: add a new payment method to the checkout system. "Should take about a day," they say with a smile.
Three weeks later, you're still debugging. Changing one file breaks five others. The payment code is tangled with email notifications, inventory updates, and logging. Tests? There are none because the code is untestable. Every function depends on concrete database connections and third party services.
You've just experienced what happens when software is built without design principles.
SOLID principles exist to prevent this nightmare. They're five guidelines that, when followed, create code that's easy to understand, modify, test, and extend. Let's explore each one through stories, analogies, and real code.
What Does SOLID Stand For?
SOLID is an acronym coined by Robert C. Martin (Uncle Bob) representing five design principles:

Design principles diagram 1
These aren't arbitrary rules. Each principle addresses a specific problem that causes codebases to become unmaintainable over time.
Single Responsibility Principle (SRP)
The Principle in Plain English
A class or module should have only one reason to change.
Think about that phrase: "one reason to change." It doesn't mean a class should only do one tiny thing. It means a class should be responsible to only one actor or stakeholder in your system.
The Swiss Army Knife Problem
Imagine you're a chef in a restaurant kitchen. You need to chop vegetables, so you reach for... a Swiss Army knife? Sure, it has a blade, but also a corkscrew, screwdriver, can opener, and fifteen other tools you don't need right now.
What's wrong with this?
- It's awkward to use - All those extra tools get in the way
- It's harder to maintain - If the screwdriver breaks, you can't use any of it while it's being repaired
- It's not the best at anything - A dedicated chef's knife will always outperform the Swiss Army blade
Software that violates SRP is like a Swiss Army knife for every task. It tries to do everything and does nothing particularly well.
A Real World Disaster
Let's look at code that violates SRP:
go// Filename: bad_user_service.go // This is an example of TERRIBLE code - don't do this! package main import ( "database/sql" "encoding/json" "fmt" "log" "net/smtp" "os" "time" ) // User does EVERYTHING related to users // Why this is bad: This struct has at least 5 different reasons to change type User struct { ID int Name string Email string Password string CreatedAt time.Time // Database connection embedded in the model! db *sql.DB // Email configuration embedded too! smtpHost string smtpPort string } // Save handles database operations // Why this is bad: If database schema changes, this changes func (u *User) Save() error { query := "INSERT INTO users (name, email, password, created_at) VALUES (?, ?, ?, ?)" _, err := u.db.Exec(query, u.Name, u.Email, u.Password, u.CreatedAt) if err != nil { // Logging is also handled here! log.Printf("Failed to save user: %v", err) return err } // After saving, also send welcome email // Why this is bad: If email requirements change, this changes u.SendWelcomeEmail() return nil } // SendWelcomeEmail handles email sending // Why this is bad: If email template changes, this changes func (u *User) SendWelcomeEmail() error { auth := smtp.PlainAuth("", "noreply@company.com", "password", u.smtpHost) msg := fmt.Sprintf("Welcome %s! Thanks for joining.", u.Name) err := smtp.SendMail( u.smtpHost+":"+u.smtpPort, auth, "noreply@company.com", []string{u.Email}, []byte(msg), ) if err != nil { log.Printf("Failed to send email: %v", err) } return err } // ToJSON handles serialization // Why this is bad: If API format changes, this changes func (u *User) ToJSON() ([]byte, error) { return json.Marshal(map[string]interface{}{ "id": u.ID, "name": u.Name, "email": u.Email, // Note: We're excluding password, but this logic lives in User }) } // ValidatePassword handles security logic // Why this is bad: If security requirements change, this changes func (u *User) ValidatePassword() error { if len(u.Password) < 8 { return fmt.Errorf("password must be at least 8 characters") } // More validation logic... return nil } // GenerateReport handles reporting // Why this is bad: If report format changes, this changes func (u *User) GenerateReport() string { return fmt.Sprintf("User Report\n===========\nID: %d\nName: %s\nEmail: %s\nCreated: %s", u.ID, u.Name, u.Email, u.CreatedAt.Format("2006-01-02")) } // WriteToFile handles file operations // Why this is bad: If file format requirements change, this changes func (u *User) WriteToFile(filename string) error { data, _ := u.ToJSON() return os.WriteFile(filename, data, 0644) }
Count the reasons this
User struct might need to change:- Database schema changes -
Save()needs updating - Email provider changes -
SendWelcomeEmail()needs updating - API format changes -
ToJSON()needs updating - Security policy changes -
ValidatePassword()needs updating - Report format changes -
GenerateReport()needs updating - File storage changes -
WriteToFile()needs updating - Logging strategy changes - All methods with logging need updating
That's seven different stakeholders who might request changes to this single struct!
The Solution: Separation of Concerns
Let's redesign this properly:
go// Filename: srp_solution.go package main import ( "database/sql" "encoding/json" "fmt" "net/smtp" "os" "time" ) // ============================================================================= // DOMAIN LAYER: Pure data structures with no external dependencies // ============================================================================= // User represents a user in our system // Why: This struct has ONE job - represent user data // It changes only when business definition of "user" changes type User struct { ID int Name string Email string Password string CreatedAt time.Time } // ============================================================================= // PERSISTENCE LAYER: Handles all database operations // ============================================================================= // UserRepository handles database operations for users // Why: Single responsibility - data persistence // Changes only when database/storage requirements change type UserRepository struct { db *sql.DB } // NewUserRepository creates a repository with database connection func NewUserRepository(db *sql.DB) *UserRepository { return &UserRepository{db: db} } // Save persists a user to the database // Why: This method only cares about putting data in the database func (r *UserRepository) Save(user *User) error { query := `INSERT INTO users (name, email, password, created_at) VALUES (?, ?, ?, ?)` result, err := r.db.Exec(query, user.Name, user.Email, user.Password, user.CreatedAt) if err != nil { return fmt.Errorf("failed to save user: %w", err) } id, err := result.LastInsertId() if err != nil { return fmt.Errorf("failed to get user ID: %w", err) } user.ID = int(id) return nil } // FindByID retrieves a user by their ID func (r *UserRepository) FindByID(id int) (*User, error) { query := "SELECT id, name, email, password, created_at FROM users WHERE id = ?" user := &User{} err := r.db.QueryRow(query, id).Scan( &user.ID, &user.Name, &user.Email, &user.Password, &user.CreatedAt, ) if err != nil { return nil, fmt.Errorf("user not found: %w", err) } return user, nil } // FindByEmail retrieves a user by email func (r *UserRepository) FindByEmail(email string) (*User, error) { query := "SELECT id, name, email, password, created_at FROM users WHERE email = ?" user := &User{} err := r.db.QueryRow(query, email).Scan( &user.ID, &user.Name, &user.Email, &user.Password, &user.CreatedAt, ) if err != nil { return nil, fmt.Errorf("user not found: %w", err) } return user, nil } // ============================================================================= // EMAIL LAYER: Handles all email operations // ============================================================================= // EmailConfig holds SMTP configuration type EmailConfig struct { Host string Port string Username string Password string From string } // EmailService handles sending emails // Why: Single responsibility - email communication // Changes only when email requirements change type EmailService struct { config EmailConfig } // NewEmailService creates an email service with configuration func NewEmailService(config EmailConfig) *EmailService { return &EmailService{config: config} } // SendWelcome sends a welcome email to a new user func (e *EmailService) SendWelcome(user *User) error { subject := "Welcome to Our Platform!" body := fmt.Sprintf(` Dear %s, Welcome to our platform! We're excited to have you on board. Your account has been created successfully. You can now log in using your email: %s If you have any questions, feel free to reach out to our support team. Best regards, The Team `, user.Name, user.Email) return e.send(user.Email, subject, body) } // SendPasswordReset sends a password reset email func (e *EmailService) SendPasswordReset(user *User, resetToken string) error { subject := "Password Reset Request" body := fmt.Sprintf(` Dear %s, We received a request to reset your password. Click the link below to reset your password: https://ourplatform.com/reset?token=%s If you didn't request this, please ignore this email. Best regards, The Team `, user.Name, resetToken) return e.send(user.Email, subject, body) } // send is a private helper that actually sends the email func (e *EmailService) send(to, subject, body string) error { auth := smtp.PlainAuth("", e.config.Username, e.config.Password, e.config.Host) msg := []byte(fmt.Sprintf( "To: %s\r\nSubject: %s\r\n\r\n%s", to, subject, body, )) addr := fmt.Sprintf("%s:%s", e.config.Host, e.config.Port) return smtp.SendMail(addr, auth, e.config.From, []string{to}, msg) } // ============================================================================= // VALIDATION LAYER: Handles all validation logic // ============================================================================= // PasswordPolicy defines rules for valid passwords type PasswordPolicy struct { MinLength int RequireUppercase bool RequireNumbers bool RequireSpecial bool } // DefaultPasswordPolicy returns standard password requirements func DefaultPasswordPolicy() PasswordPolicy { return PasswordPolicy{ MinLength: 8, RequireUppercase: true, RequireNumbers: true, RequireSpecial: false, } } // UserValidator handles validation of user data // Why: Single responsibility - data validation // Changes only when validation rules change type UserValidator struct { passwordPolicy PasswordPolicy } // NewUserValidator creates a validator with given policy func NewUserValidator(policy PasswordPolicy) *UserValidator { return &UserValidator{passwordPolicy: policy} } // ValidateForRegistration checks if user data is valid for new registration func (v *UserValidator) ValidateForRegistration(user *User) []string { var errors []string // Validate name if len(user.Name) < 2 { errors = append(errors, "name must be at least 2 characters") } if len(user.Name) > 100 { errors = append(errors, "name cannot exceed 100 characters") } // Validate email if !v.isValidEmail(user.Email) { errors = append(errors, "invalid email format") } // Validate password passwordErrors := v.validatePassword(user.Password) errors = append(errors, passwordErrors...) return errors } // validatePassword checks password against policy func (v *UserValidator) validatePassword(password string) []string { var errors []string if len(password) < v.passwordPolicy.MinLength { errors = append(errors, fmt.Sprintf("password must be at least %d characters", v.passwordPolicy.MinLength)) } if v.passwordPolicy.RequireUppercase && !v.hasUppercase(password) { errors = append(errors, "password must contain at least one uppercase letter") } if v.passwordPolicy.RequireNumbers && !v.hasNumber(password) { errors = append(errors, "password must contain at least one number") } return errors } func (v *UserValidator) isValidEmail(email string) bool { // Simple validation - in production, use a proper library return len(email) > 5 && containsString(email, "@") && containsString(email, ".") } func (v *UserValidator) hasUppercase(s string) bool { for _, r := range s { if r >= 'A' && r <= 'Z' { return true } } return false } func (v *UserValidator) hasNumber(s string) bool { for _, r := range s { if r >= '0' && r <= '9' { return true } } return false } func containsString(s, substr string) bool { return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsString(s[1:], substr) || len(s) >= len(substr) && s[:len(substr)] == substr) } // ============================================================================= // SERIALIZATION LAYER: Handles data format conversions // ============================================================================= // UserSerializer handles converting users to different formats // Why: Single responsibility - data serialization // Changes only when output format requirements change type UserSerializer struct{} // UserDTO is the Data Transfer Object for API responses // Why: Separates internal User model from external API contract type UserDTO struct { ID int `json:"id"` Name string `json:"name"` Email string `json:"email"` CreatedAt string `json:"created_at"` // Note: Password is intentionally excluded } // ToJSON converts a user to JSON format func (s *UserSerializer) ToJSON(user *User) ([]byte, error) { dto := UserDTO{ ID: user.ID, Name: user.Name, Email: user.Email, CreatedAt: user.CreatedAt.Format(time.RFC3339), } return json.Marshal(dto) } // ToJSONList converts multiple users to JSON array func (s *UserSerializer) ToJSONList(users []*User) ([]byte, error) { dtos := make([]UserDTO, len(users)) for i, user := range users { dtos[i] = UserDTO{ ID: user.ID, Name: user.Name, Email: user.Email, CreatedAt: user.CreatedAt.Format(time.RFC3339), } } return json.Marshal(dtos) } // ============================================================================= // REPORTING LAYER: Handles report generation // ============================================================================= // UserReportGenerator creates reports about users // Why: Single responsibility - report generation // Changes only when report format requirements change type UserReportGenerator struct{} // GenerateSummary creates a text summary for a user func (r *UserReportGenerator) GenerateSummary(user *User) string { return fmt.Sprintf(` ╔══════════════════════════════════════╗ ║ USER SUMMARY REPORT ║ ╠══════════════════════════════════════╣ ║ ID: %-28d ║ ║ Name: %-28s ║ ║ Email: %-28s ║ ║ Created: %-28s ║ ╚══════════════════════════════════════╝ `, user.ID, user.Name, user.Email, user.CreatedAt.Format("2006-01-02 15:04:05")) } // GenerateCSVRow creates a CSV row for a user func (r *UserReportGenerator) GenerateCSVRow(user *User) string { return fmt.Sprintf("%d,%s,%s,%s", user.ID, user.Name, user.Email, user.CreatedAt.Format("2006-01-02"), ) } // ============================================================================= // FILE STORAGE LAYER: Handles file operations // ============================================================================= // FileStorage handles writing data to files // Why: Single responsibility - file system operations // Changes only when file storage requirements change type FileStorage struct { basePath string } // NewFileStorage creates a file storage with base path func NewFileStorage(basePath string) *FileStorage { return &FileStorage{basePath: basePath} } // SaveJSON writes JSON data to a file func (f *FileStorage) SaveJSON(filename string, data []byte) error { path := fmt.Sprintf("%s/%s", f.basePath, filename) return os.WriteFile(path, data, 0644) } // ============================================================================= // ORCHESTRATION LAYER: Coordinates the pieces // ============================================================================= // UserService orchestrates user-related operations // Why: This is a thin coordination layer that delegates to specialists // It changes when the workflow changes, not when implementation details change type UserService struct { repository *UserRepository validator *UserValidator email *EmailService serializer *UserSerializer } // NewUserService creates a user service with all dependencies func NewUserService( repo *UserRepository, validator *UserValidator, email *EmailService, serializer *UserSerializer, ) *UserService { return &UserService{ repository: repo, validator: validator, email: email, serializer: serializer, } } // RegisterUser handles the complete user registration workflow func (s *UserService) RegisterUser(name, email, password string) (*User, error) { // Create user object user := &User{ Name: name, Email: email, Password: password, // In production, hash this! CreatedAt: time.Now(), } // Step 1: Validate if errors := s.validator.ValidateForRegistration(user); len(errors) > 0 { return nil, fmt.Errorf("validation failed: %v", errors) } // Step 2: Save to database if err := s.repository.Save(user); err != nil { return nil, fmt.Errorf("failed to save user: %w", err) } // Step 3: Send welcome email (don't fail registration if email fails) go func() { if err := s.email.SendWelcome(user); err != nil { // Log error but don't fail the registration fmt.Printf("Warning: failed to send welcome email: %v\n", err) } }() return user, nil } // GetUserJSON retrieves a user and returns as JSON func (s *UserService) GetUserJSON(id int) ([]byte, error) { user, err := s.repository.FindByID(id) if err != nil { return nil, err } return s.serializer.ToJSON(user) } // ============================================================================= // DEMONSTRATION // ============================================================================= func main() { fmt.Println("SRP Example - Each component has a single responsibility") fmt.Println("=========================================================") // In a real app, these would be injected via dependency injection // Here we demonstrate the structure // Create a user for demonstration user := &User{ ID: 1, Name: "Alice Johnson", Email: "alice@example.com", Password: "SecurePass123", CreatedAt: time.Now(), } // Demonstrate serialization (single responsibility) serializer := &UserSerializer{} jsonData, _ := serializer.ToJSON(user) fmt.Println("\nSerialized User (UserSerializer responsibility):") fmt.Println(string(jsonData)) // Demonstrate reporting (single responsibility) reporter := &UserReportGenerator{} report := reporter.GenerateSummary(user) fmt.Println("\nUser Report (UserReportGenerator responsibility):") fmt.Println(report) // Demonstrate validation (single responsibility) validator := NewUserValidator(DefaultPasswordPolicy()) errors := validator.ValidateForRegistration(user) fmt.Println("Validation Result (UserValidator responsibility):") if len(errors) == 0 { fmt.Println("✓ User data is valid") } else { fmt.Printf("✗ Validation errors: %v\n", errors) } }
Expected Output:
SRP Example - Each component has a single responsibility ========================================================= Serialized User (UserSerializer responsibility): {"id":1,"name":"Alice Johnson","email":"alice@example.com","created_at":"2024-02-13T10:30:00Z"} User Report (UserReportGenerator responsibility): ╔══════════════════════════════════════╗ ║ USER SUMMARY REPORT ║ ╠══════════════════════════════════════╣ ║ ID: 1 ║ ║ Name: Alice Johnson ║ ║ Email: alice@example.com ║ ║ Created: 2024-02-13 10:30:00 ║ ╚══════════════════════════════════════╝ Validation Result (UserValidator responsibility): ✓ User data is valid
Why SRP Matters in Real Life
Scenario 1: The Marketing Team Wants a New Email Template
Without SRP: You modify the
User struct, risking bugs in database operations, validation, and reporting.With SRP: You only touch
EmailService. Everything else remains untouched and stable.Scenario 2: Security Audit Requires Stronger Passwords
Without SRP: You dig through a massive
User struct, hoping you find all the password logic.With SRP: You update
UserValidator and PasswordPolicy. Clean, isolated change.Scenario 3: New Developer Joins the Team
Without SRP: They spend days understanding what the
User struct does.With SRP: They can understand
EmailService in 10 minutes because it only does one thing.
Design principles diagram 2
Open/Closed Principle (OCP)
The Principle in Plain English
Software entities should be open for extension but closed for modification.
This sounds contradictory at first. How can something be both open and closed? The key is understanding what we're opening and closing:
- Open for extension: You can add new behavior
- Closed for modification: You don't change existing code to add that behavior
The Power Outlet Analogy
Think about the electrical outlets in your home. The outlet itself is "closed" - you don't rewire your house every time you buy a new appliance. But it's "open" - you can plug in a lamp, a phone charger, a TV, or a blender without any modifications to the outlet.
The outlet defines a standard interface (the plug shape, voltage, etc.). Any device that follows this interface can use the outlet. The outlet doesn't care if the device is a 1950s radio or a 2024 smartphone.
This is exactly what OCP asks of our code: create stable interfaces that new code can plug into.
A Real World Violation
Let's say you're building a payment system:
go// Filename: bad_payment_processor.go // This violates OCP - we modify this function for every new payment method package main import "fmt" // ProcessPayment handles all payment types // Why this is bad: Adding a new payment method means modifying this function func ProcessPayment(paymentType string, amount float64, details map[string]string) error { switch paymentType { case "credit_card": // Credit card processing logic cardNumber := details["card_number"] expiry := details["expiry"] cvv := details["cvv"] fmt.Printf("Processing credit card payment: %s, Amount: $%.2f\n", cardNumber[:4]+"****", amount) // Connect to Stripe, Authorize.net, etc. return nil case "paypal": // PayPal processing logic email := details["email"] fmt.Printf("Processing PayPal payment for: %s, Amount: $%.2f\n", email, amount) // Connect to PayPal API return nil case "bank_transfer": // Bank transfer logic accountNumber := details["account_number"] routingNumber := details["routing_number"] fmt.Printf("Processing bank transfer: %s, Amount: $%.2f\n", accountNumber, amount) // Connect to bank API return nil case "crypto": // ADDED LATER: Cryptocurrency payment // Every time we add a payment method, we modify this function! walletAddress := details["wallet"] fmt.Printf("Processing crypto payment to: %s, Amount: $%.2f\n", walletAddress, amount) return nil case "apple_pay": // ADDED EVEN LATER: Apple Pay // This function keeps growing and becoming more complex! token := details["token"] fmt.Printf("Processing Apple Pay with token: %s, Amount: $%.2f\n", token[:8]+"...", amount) return nil default: return fmt.Errorf("unsupported payment type: %s", paymentType) } } func main() { // Usage ProcessPayment("credit_card", 99.99, map[string]string{ "card_number": "4111111111111111", "expiry": "12/25", "cvv": "123", }) }
What's wrong with this?
- Risk: Every new payment method requires modifying tested, working code
- Testing: You need to retest all payment methods when adding a new one
- Complexity: The function grows linearly with each new payment type
- Merge Conflicts: Multiple developers adding payment methods will conflict
- Violation of SRP: This function knows too much about too many payment systems
The Solution: Extension Through Abstraction
go// Filename: ocp_payment_processor.go package main import ( "fmt" "time" ) // ============================================================================= // THE ABSTRACTION: Define what a payment processor must do // ============================================================================= // PaymentResult contains the outcome of a payment attempt type PaymentResult struct { Success bool TransactionID string Message string ProcessedAt time.Time } // PaymentProcessor defines the contract for all payment methods // Why: This interface is CLOSED - we won't change it // But the system is OPEN - anyone can add new implementations type PaymentProcessor interface { // ProcessPayment handles the payment ProcessPayment(amount float64) (*PaymentResult, error) // Validate checks if the payment details are valid before processing Validate() error // Name returns a human-readable name for logging/display Name() string } // ============================================================================= // IMPLEMENTATION 1: Credit Card Processor // ============================================================================= // CreditCardProcessor handles credit card payments type CreditCardProcessor struct { CardNumber string Expiry string CVV string CardHolder string } // NewCreditCardProcessor creates a credit card processor func NewCreditCardProcessor(cardNumber, expiry, cvv, holder string) *CreditCardProcessor { return &CreditCardProcessor{ CardNumber: cardNumber, Expiry: expiry, CVV: cvv, CardHolder: holder, } } func (c *CreditCardProcessor) Name() string { return "Credit Card" } func (c *CreditCardProcessor) Validate() error { if len(c.CardNumber) != 16 { return fmt.Errorf("invalid card number length") } if len(c.CVV) != 3 { return fmt.Errorf("invalid CVV") } // More validation... return nil } func (c *CreditCardProcessor) ProcessPayment(amount float64) (*PaymentResult, error) { // In real implementation: Connect to payment gateway (Stripe, etc.) fmt.Printf("💳 Connecting to credit card network...\n") fmt.Printf("💳 Authorizing card ending in %s\n", c.CardNumber[12:]) fmt.Printf("💳 Charging $%.2f to %s\n", amount, c.CardHolder) return &PaymentResult{ Success: true, TransactionID: fmt.Sprintf("CC-%d", time.Now().UnixNano()), Message: "Payment approved", ProcessedAt: time.Now(), }, nil } // ============================================================================= // IMPLEMENTATION 2: PayPal Processor // ============================================================================= // PayPalProcessor handles PayPal payments type PayPalProcessor struct { Email string Password string // In reality, use OAuth tokens } func NewPayPalProcessor(email, password string) *PayPalProcessor { return &PayPalProcessor{ Email: email, Password: password, } } func (p *PayPalProcessor) Name() string { return "PayPal" } func (p *PayPalProcessor) Validate() error { if p.Email == "" { return fmt.Errorf("PayPal email is required") } return nil } func (p *PayPalProcessor) ProcessPayment(amount float64) (*PaymentResult, error) { fmt.Printf("🅿️ Connecting to PayPal...\n") fmt.Printf("🅿️ Authenticating %s\n", p.Email) fmt.Printf("🅿️ Processing $%.2f\n", amount) return &PaymentResult{ Success: true, TransactionID: fmt.Sprintf("PP-%d", time.Now().UnixNano()), Message: "PayPal payment complete", ProcessedAt: time.Now(), }, nil } // ============================================================================= // IMPLEMENTATION 3: Cryptocurrency Processor (ADDED WITHOUT MODIFYING ABOVE!) // ============================================================================= // CryptoProcessor handles cryptocurrency payments // Why: We added this entire payment method WITHOUT changing any existing code! type CryptoProcessor struct { WalletAddress string Currency string // BTC, ETH, etc. } func NewCryptoProcessor(wallet, currency string) *CryptoProcessor { return &CryptoProcessor{ WalletAddress: wallet, Currency: currency, } } func (c *CryptoProcessor) Name() string { return fmt.Sprintf("Cryptocurrency (%s)", c.Currency) } func (c *CryptoProcessor) Validate() error { if len(c.WalletAddress) < 20 { return fmt.Errorf("invalid wallet address") } return nil } func (c *CryptoProcessor) ProcessPayment(amount float64) (*PaymentResult, error) { fmt.Printf("₿ Connecting to %s network...\n", c.Currency) fmt.Printf("₿ Sending to wallet: %s...%s\n", c.WalletAddress[:6], c.WalletAddress[len(c.WalletAddress)-4:]) fmt.Printf("₿ Amount: $%.2f equivalent\n", amount) return &PaymentResult{ Success: true, TransactionID: fmt.Sprintf("CRYPTO-%d", time.Now().UnixNano()), Message: fmt.Sprintf("%s payment broadcast to network", c.Currency), ProcessedAt: time.Now(), }, nil } // ============================================================================= // IMPLEMENTATION 4: Apple Pay (ADDED EVEN LATER - STILL NO MODIFICATIONS!) // ============================================================================= // ApplePayProcessor handles Apple Pay payments type ApplePayProcessor struct { PaymentToken string DeviceID string } func NewApplePayProcessor(token, deviceID string) *ApplePayProcessor { return &ApplePayProcessor{ PaymentToken: token, DeviceID: deviceID, } } func (a *ApplePayProcessor) Name() string { return "Apple Pay" } func (a *ApplePayProcessor) Validate() error { if a.PaymentToken == "" { return fmt.Errorf("Apple Pay token is required") } return nil } func (a *ApplePayProcessor) ProcessPayment(amount float64) (*PaymentResult, error) { fmt.Printf("🍎 Verifying Apple Pay token...\n") fmt.Printf("🍎 Device verified: %s\n", a.DeviceID[:8]) fmt.Printf("🍎 Processing $%.2f\n", amount) return &PaymentResult{ Success: true, TransactionID: fmt.Sprintf("APPLE-%d", time.Now().UnixNano()), Message: "Apple Pay authorized", ProcessedAt: time.Now(), }, nil } // ============================================================================= // THE PAYMENT SERVICE: Works with ANY processor without modification // ============================================================================= // PaymentService processes payments using any PaymentProcessor // Why: This service is CLOSED for modification // It works with any processor that implements the interface type PaymentService struct { // We could add logging, metrics, retry logic here // And none of the processors need to know about it } // Process handles a payment using the given processor // Why: This method never needs to change when we add new payment methods func (s *PaymentService) Process(processor PaymentProcessor, amount float64) (*PaymentResult, error) { fmt.Printf("\n{'='*50}\n") fmt.Printf("Processing %s payment for $%.2f\n", processor.Name(), amount) fmt.Printf("{'='*50}\n\n") // Step 1: Validate if err := processor.Validate(); err != nil { return &PaymentResult{ Success: false, Message: fmt.Sprintf("Validation failed: %v", err), ProcessedAt: time.Now(), }, err } fmt.Println("✓ Validation passed") // Step 2: Process result, err := processor.ProcessPayment(amount) if err != nil { return result, err } // Step 3: Log success fmt.Printf("\n✓ Payment successful!\n") fmt.Printf(" Transaction ID: %s\n", result.TransactionID) fmt.Printf(" Processed at: %s\n", result.ProcessedAt.Format(time.RFC3339)) return result, nil } // ProcessMultiple handles multiple payment processors (split payment) func (s *PaymentService) ProcessMultiple(processors []PaymentProcessor, amounts []float64) ([]*PaymentResult, error) { if len(processors) != len(amounts) { return nil, fmt.Errorf("processors and amounts must match") } var results []*PaymentResult for i, processor := range processors { result, err := s.Process(processor, amounts[i]) if err != nil { return results, fmt.Errorf("payment %d failed: %w", i+1, err) } results = append(results, result) } return results, nil } // ============================================================================= // DEMONSTRATION // ============================================================================= func main() { service := &PaymentService{} // Process credit card payment creditCard := NewCreditCardProcessor( "4111111111111111", "12/25", "123", "John Doe", ) service.Process(creditCard, 99.99) // Process PayPal payment paypal := NewPayPalProcessor("john@example.com", "secret") service.Process(paypal, 49.99) // Process crypto payment - NO CHANGES TO PaymentService! crypto := NewCryptoProcessor( "0x742d35Cc6634C0532925a3b844Bc9e7595f", "ETH", ) service.Process(crypto, 150.00) // Process Apple Pay - STILL NO CHANGES TO PaymentService! applePay := NewApplePayProcessor( "apple_pay_token_abc123xyz", "DEVICE-12345", ) service.Process(applePay, 29.99) fmt.Println("\n" + "="*50) fmt.Println("KEY INSIGHT: We added 4 different payment methods") fmt.Println("without ever modifying PaymentService!") fmt.Println("="*50) }
Expected Output:
================================================== Processing Credit Card payment for $99.99 ================================================== ✓ Validation passed 💳 Connecting to credit card network... 💳 Authorizing card ending in 1111 💳 Charging $99.99 to John Doe ✓ Payment successful! Transaction ID: CC-1707820800123456789 Processed at: 2024-02-13T10:00:00Z ================================================== Processing PayPal payment for $49.99 ================================================== ✓ Validation passed 🅿️ Connecting to PayPal... 🅿️ Authenticating john@example.com 🅿️ Processing $49.99 ✓ Payment successful! Transaction ID: PP-1707820800123456790 Processed at: 2024-02-13T10:00:01Z ================================================== Processing Cryptocurrency (ETH) payment for $150.00 ================================================== ✓ Validation passed ₿ Connecting to ETH network... ₿ Sending to wallet: 0x742d...95f ₿ Amount: $150.00 equivalent ✓ Payment successful! Transaction ID: CRYPTO-1707820800123456791 Processed at: 2024-02-13T10:00:02Z ================================================== Processing Apple Pay payment for $29.99 ================================================== ✓ Validation passed 🍎 Verifying Apple Pay token... 🍎 Device verified: DEVICE-1 🍎 Processing $29.99 ✓ Payment successful! Transaction ID: APPLE-1707820800123456792 Processed at: 2024-02-13T10:00:03Z ================================================== KEY INSIGHT: We added 4 different payment methods without ever modifying PaymentService! ==================================================
The Power of OCP Visualized

Design principles diagram 3
Liskov Substitution Principle (LSP)
The Principle in Plain English
Objects of a superclass should be replaceable with objects of its subclasses without breaking the application.
In Go terms: If a function accepts an interface, any type that implements that interface should work correctly without the function needing to know the specific type.
The Rectangle-Square Paradox
This is the classic example that trips up even experienced developers.
In geometry class, we learned: "A square is a special type of rectangle where all sides are equal."
So naturally, we might write:
go// This seems logical but is WRONG! type Rectangle interface { SetWidth(w int) SetHeight(h int) Area() int } type Square struct { side int } // When you set width on a square, you MUST also change height // Otherwise it's not a square anymore! func (s *Square) SetWidth(w int) { s.side = w // This also implicitly sets height } func (s *Square) SetHeight(h int) { s.side = h // This also implicitly sets width } func (s *Square) Area() int { return s.side * s.side }
Now look at this function that uses Rectangle:
gofunc DoubleWidth(r Rectangle) { w := r.Width() r.SetWidth(w * 2) // After doubling width, area should be width * 2 * height // For a 3x4 rectangle: new area should be 6 * 4 = 24 }
If you pass a Square with side 3:
- Before: Area = 9
- Set width to 6 (doubles)
- Expected area: 6 * 3 = 18 (if it were a rectangle)
- Actual area: 6 * 6 = 36 (because Square changed height too!)
The Square "broke" the contract that Rectangle promised. This violates LSP.
A Better Design
go// Filename: lsp_shapes.go package main import ( "fmt" "math" ) // ============================================================================= // CORRECT DESIGN: Focus on what shapes CAN DO, not what they ARE // ============================================================================= // Shape represents any geometric shape // Why: We define behavior that ALL shapes must support // Any shape can be substituted for any other in calculations type Shape interface { Area() float64 Perimeter() float64 Name() string } // Drawable represents shapes that can be drawn type Drawable interface { Draw() string } // Rectangle is a shape with width and height type Rectangle struct { Width float64 Height float64 } func (r Rectangle) Area() float64 { return r.Width * r.Height } func (r Rectangle) Perimeter() float64 { return 2 * (r.Width + r.Height) } func (r Rectangle) Name() string { return "Rectangle" } func (r Rectangle) Draw() string { return fmt.Sprintf(` ┌%s┐ │%s│ │%s│ └%s┘`, repeatChar("─", int(r.Width)), repeatChar(" ", int(r.Width)), repeatChar(" ", int(r.Width)), repeatChar("─", int(r.Width)), ) } // Square is ALSO a shape, but NOT a subtype of Rectangle // Why: Square has different construction (one dimension, not two) // but it fulfills the Shape contract perfectly type Square struct { Side float64 } func (s Square) Area() float64 { return s.Side * s.Side } func (s Square) Perimeter() float64 { return 4 * s.Side } func (s Square) Name() string { return "Square" } func (s Square) Draw() string { size := int(s.Side) return fmt.Sprintf(` ┌%s┐ %s └%s┘`, repeatChar("─", size), repeatLines("│"+repeatChar(" ", size)+"│\n", size-2), repeatChar("─", size), ) } // Circle is another shape type Circle struct { Radius float64 } func (c Circle) Area() float64 { return math.Pi * c.Radius * c.Radius } func (c Circle) Perimeter() float64 { return 2 * math.Pi * c.Radius } func (c Circle) Name() string { return "Circle" } // Triangle completes our shape collection type Triangle struct { Base float64 Height float64 Side1 float64 Side2 float64 Side3 float64 } func NewEquilateralTriangle(side float64) Triangle { height := side * math.Sqrt(3) / 2 return Triangle{ Base: side, Height: height, Side1: side, Side2: side, Side3: side, } } func (t Triangle) Area() float64 { return 0.5 * t.Base * t.Height } func (t Triangle) Perimeter() float64 { return t.Side1 + t.Side2 + t.Side3 } func (t Triangle) Name() string { return "Triangle" } // ============================================================================= // FUNCTIONS THAT WORK WITH ANY SHAPE (LSP in action!) // ============================================================================= // TotalArea calculates the total area of multiple shapes // Why: This function doesn't care about the specific shape type // Any Shape can be passed in, and it will work correctly func TotalArea(shapes []Shape) float64 { var total float64 for _, shape := range shapes { total += shape.Area() } return total } // TotalPerimeter calculates the total perimeter of multiple shapes func TotalPerimeter(shapes []Shape) float64 { var total float64 for _, shape := range shapes { total += shape.Perimeter() } return total } // PrintShapeInfo prints information about any shape // Why: LSP guarantees this works for ALL shapes, present and future func PrintShapeInfo(shape Shape) { fmt.Printf("┌─────────────────────────────┐\n") fmt.Printf("│ Shape: %-20s │\n", shape.Name()) fmt.Printf("├─────────────────────────────┤\n") fmt.Printf("│ Area: %14.2f │\n", shape.Area()) fmt.Printf("│ Perimeter: %14.2f │\n", shape.Perimeter()) fmt.Printf("└─────────────────────────────┘\n") } // FindLargest finds the shape with the largest area // Why: Works with ANY shape - Square, Rectangle, Circle, or any future shape func FindLargest(shapes []Shape) Shape { if len(shapes) == 0 { return nil } largest := shapes[0] for _, shape := range shapes[1:] { if shape.Area() > largest.Area() { largest = shape } } return largest } // ScalableShape extends Shape with scaling capability type ScalableShape interface { Shape Scale(factor float64) ScalableShape } // ScalableRectangle can be scaled type ScalableRectangle struct { Rectangle } func (r ScalableRectangle) Scale(factor float64) ScalableShape { return ScalableRectangle{ Rectangle: Rectangle{ Width: r.Width * factor, Height: r.Height * factor, }, } } // ScalableCircle can be scaled type ScalableCircle struct { Circle } func (c ScalableCircle) Scale(factor float64) ScalableShape { return ScalableCircle{ Circle: Circle{ Radius: c.Radius * factor, }, } } // DoubleSize doubles any scalable shape // Why: LSP - any ScalableShape works here func DoubleSize(shape ScalableShape) ScalableShape { return shape.Scale(2.0) } // Helper functions func repeatChar(char string, times int) string { result := "" for i := 0; i < times; i++ { result += char } return result } func repeatLines(line string, times int) string { result := "" for i := 0; i < times; i++ { result += line } return result } // ============================================================================= // DEMONSTRATION // ============================================================================= func main() { fmt.Println("╔══════════════════════════════════════════════════════════╗") fmt.Println("║ LISKOV SUBSTITUTION PRINCIPLE DEMONSTRATION ║") fmt.Println("╚══════════════════════════════════════════════════════════╝") // Create various shapes shapes := []Shape{ Rectangle{Width: 10, Height: 5}, Square{Side: 4}, Circle{Radius: 3}, NewEquilateralTriangle(6), } // Print info for each shape // LSP: PrintShapeInfo works identically for ALL shapes fmt.Println("\n📐 Individual Shape Information:") fmt.Println("================================") for _, shape := range shapes { PrintShapeInfo(shape) fmt.Println() } // Calculate totals // LSP: TotalArea works with any combination of shapes fmt.Println("📊 Aggregate Calculations:") fmt.Println("==========================") fmt.Printf("Total Area of all shapes: %.2f\n", TotalArea(shapes)) fmt.Printf("Total Perimeter of all shapes: %.2f\n", TotalPerimeter(shapes)) // Find largest // LSP: FindLargest correctly compares any shapes largest := FindLargest(shapes) fmt.Printf("\nLargest shape: %s (Area: %.2f)\n", largest.Name(), largest.Area()) // Demonstrate scaling fmt.Println("\n🔄 Scaling Demonstration:") fmt.Println("=========================") scalableRect := ScalableRectangle{Rectangle{Width: 5, Height: 3}} scalableCircle := ScalableCircle{Circle{Radius: 2}} fmt.Printf("Original rectangle area: %.2f\n", scalableRect.Area()) doubled := DoubleSize(scalableRect) fmt.Printf("Doubled rectangle area: %.2f\n", doubled.Area()) fmt.Printf("\nOriginal circle area: %.2f\n", scalableCircle.Area()) doubledCircle := DoubleSize(scalableCircle) fmt.Printf("Doubled circle area: %.2f\n", doubledCircle.Area()) fmt.Println("\n" + repeatChar("═", 60)) fmt.Println("KEY INSIGHT: Every shape is substitutable for any other") fmt.Println("in functions that accept the Shape interface!") fmt.Println(repeatChar("═", 60)) }
Expected Output:
╔══════════════════════════════════════════════════════════╗ ║ LISKOV SUBSTITUTION PRINCIPLE DEMONSTRATION ║ ╚══════════════════════════════════════════════════════════╝ 📐 Individual Shape Information: ================================ ┌─────────────────────────────┐ │ Shape: Rectangle │ ├─────────────────────────────┤ │ Area: 50.00 │ │ Perimeter: 30.00 │ └─────────────────────────────┘ ┌─────────────────────────────┐ │ Shape: Square │ ├─────────────────────────────┤ │ Area: 16.00 │ │ Perimeter: 16.00 │ └─────────────────────────────┘ ┌─────────────────────────────┐ │ Shape: Circle │ ├─────────────────────────────┤ │ Area: 28.27 │ │ Perimeter: 18.85 │ └─────────────────────────────┘ ┌─────────────────────────────┐ │ Shape: Triangle │ ├─────────────────────────────┤ │ Area: 15.59 │ │ Perimeter: 18.00 │ └─────────────────────────────┘ 📊 Aggregate Calculations: ========================== Total Area of all shapes: 109.86 Total Perimeter of all shapes: 82.85 Largest shape: Rectangle (Area: 50.00) 🔄 Scaling Demonstration: ========================= Original rectangle area: 15.00 Doubled rectangle area: 60.00 Original circle area: 12.57 Doubled circle area: 50.27 ════════════════════════════════════════════════════════════ KEY INSIGHT: Every shape is substitutable for any other in functions that accept the Shape interface! ════════════════════════════════════════════════════════════
LSP Violations in the Wild

Design principles diagram 4
Interface Segregation Principle (ISP)
The Principle in Plain English
No client should be forced to depend on methods it does not use.
In other words: many small, specific interfaces are better than one large, general interface.
The TV Remote Analogy
Imagine you have a universal remote control with 100 buttons. You use it for your TV, but it also has buttons for DVD players, sound systems, air conditioners, and smart home devices you don't own.
Every time you pick up the remote:
- You have to navigate around buttons you don't need
- You might accidentally press the wrong button
- The remote is bigger and more complex than necessary
- If the remote breaks, you lose control of everything
Now imagine having a simple TV remote with just the buttons you need: power, volume, channel, input. Simple, focused, effective.
ISP says your interfaces should be like that simple remote, not the universal one.
A Real Problem: The God Interface
go// Filename: bad_worker_interface.go // This is a BAD example - Don't do this! package main // Worker is a "God Interface" - it tries to do everything // Why this is bad: Not all workers can do all things! type Worker interface { Work() Eat() Sleep() TakeBreak() AttendMeeting() WriteReport() ManageTeam() ReviewCode() DeployApplication() HandleSupport() TrainNewHires() } // HumanDeveloper can do most things type HumanDeveloper struct { Name string } func (h *HumanDeveloper) Work() { /* writes code */ } func (h *HumanDeveloper) Eat() { /* eats lunch */ } func (h *HumanDeveloper) Sleep() { /* goes home and sleeps */ } func (h *HumanDeveloper) TakeBreak() { /* coffee break */ } func (h *HumanDeveloper) AttendMeeting() { /* joins standup */ } func (h *HumanDeveloper) WriteReport() { /* weekly report */ } func (h *HumanDeveloper) ManageTeam() { /* if senior */ } func (h *HumanDeveloper) ReviewCode() { /* pull requests */ } func (h *HumanDeveloper) DeployApplication() { /* CI/CD */ } func (h *HumanDeveloper) HandleSupport() { /* on-call */ } func (h *HumanDeveloper) TrainNewHires() { /* mentoring */ } // Robot worker - doesn't eat, sleep, or take breaks // But MUST implement all methods to satisfy Worker interface! type RobotWorker struct { ID string } func (r *RobotWorker) Work() { /* processes tasks */ } func (r *RobotWorker) Eat() { panic("Robots don't eat!") } // FORCED! func (r *RobotWorker) Sleep() { panic("Robots don't sleep!") } // FORCED! func (r *RobotWorker) TakeBreak() { panic("Robots don't take breaks!") } // FORCED! func (r *RobotWorker) AttendMeeting() { /* can join video calls */ } func (r *RobotWorker) WriteReport() { /* generates reports */ } func (r *RobotWorker) ManageTeam() { panic("Robots don't manage!") } // FORCED! func (r *RobotWorker) ReviewCode() { /* automated code review */ } func (r *RobotWorker) DeployApplication() { /* automated deployment */ } func (r *RobotWorker) HandleSupport() { /* chatbot support */ } func (r *RobotWorker) TrainNewHires() { panic("Robots don't train!") } // FORCED! // Contractor - only works, doesn't do internal activities type Contractor struct { Name string } func (c *Contractor) Work() { /* specific task */ } func (c *Contractor) Eat() { /* maybe, on own time */ } func (c *Contractor) Sleep() { /* not company's business */ } func (c *Contractor) TakeBreak() { /* own schedule */ } func (c *Contractor) AttendMeeting() { panic("Not in scope!") } // FORCED! func (c *Contractor) WriteReport() { panic("Not in scope!") } // FORCED! func (c *Contractor) ManageTeam() { panic("Not in scope!") } // FORCED! func (c *Contractor) ReviewCode() { panic("Not in scope!") } // FORCED! func (c *Contractor) DeployApplication() { panic("Not in scope!") } // FORCED! func (c *Contractor) HandleSupport() { panic("Not in scope!") } // FORCED! func (c *Contractor) TrainNewHires() { panic("Not in scope!") } // FORCED!
This is terrible! Robots are forced to implement
Eat() and Sleep(). Contractors must implement ManageTeam(). The interface is lying about what these types can actually do.The Solution: Segregated Interfaces
go// Filename: isp_worker_interfaces.go package main import ( "fmt" "time" ) // ============================================================================= // SEGREGATED INTERFACES: Each interface has a single, focused purpose // ============================================================================= // Worker represents basic work capability // Why: Every worker can at least do this type Worker interface { Work() string ID() string } // Breakable represents entities that need breaks // Why: Humans need breaks, robots don't type Breakable interface { TakeBreak(duration time.Duration) NeedsBreak() bool } // Feedable represents entities that need to eat // Why: Only biological workers need food type Feedable interface { Eat(meal string) IsHungry() bool } // Sleepable represents entities that need sleep // Why: Only biological workers need sleep type Sleepable interface { Sleep(hours int) IsTired() bool } // MeetingAttendee can attend meetings // Why: Both humans and video-capable robots can do this type MeetingAttendee interface { AttendMeeting(topic string) string AvailableForMeeting() bool } // ReportWriter can write reports // Why: Humans write reports, AI can generate them type ReportWriter interface { WriteReport(title string) string } // TeamManager can manage a team // Why: Only certain roles have this capability type TeamManager interface { ManageTeam() []string TeamSize() int } // CodeReviewer can review code // Why: Senior devs and automated tools type CodeReviewer interface { ReviewCode(prID string) string } // Deployer can deploy applications // Why: Ops people, senior devs, CI/CD systems type Deployer interface { Deploy(version string) error } // SupportHandler can handle customer support // Why: Support team, chatbots type SupportHandler interface { HandleSupport(ticket string) string } // Trainer can train new employees // Why: Senior staff, training programs type Trainer interface { Train(newHire string) string } // ============================================================================= // COMPOSED INTERFACES: Combine small interfaces for specific roles // ============================================================================= // HumanWorker combines interfaces needed by human employees type HumanWorker interface { Worker Breakable Feedable Sleepable } // FullTimeEmployee adds meeting and reporting capabilities type FullTimeEmployee interface { HumanWorker MeetingAttendee ReportWriter } // SeniorDeveloper adds code review and deployment type SeniorDeveloper interface { FullTimeEmployee CodeReviewer Deployer Trainer } // AutomatedWorker represents robotic/AI workers type AutomatedWorker interface { Worker // Note: No Breakable, Feedable, or Sleepable! } // ============================================================================= // IMPLEMENTATIONS // ============================================================================= // JuniorDev is a junior developer type JuniorDev struct { name string hungry bool tired bool needsBreak bool } func NewJuniorDev(name string) *JuniorDev { return &JuniorDev{name: name} } // Worker interface func (j *JuniorDev) Work() string { j.needsBreak = true j.hungry = true return fmt.Sprintf("%s is writing code", j.name) } func (j *JuniorDev) ID() string { return fmt.Sprintf("JR-%s", j.name) } // Breakable interface func (j *JuniorDev) TakeBreak(duration time.Duration) { fmt.Printf("%s is taking a %v break\n", j.name, duration) j.needsBreak = false } func (j *JuniorDev) NeedsBreak() bool { return j.needsBreak } // Feedable interface func (j *JuniorDev) Eat(meal string) { fmt.Printf("%s is eating %s\n", j.name, meal) j.hungry = false } func (j *JuniorDev) IsHungry() bool { return j.hungry } // Sleepable interface func (j *JuniorDev) Sleep(hours int) { fmt.Printf("%s is sleeping for %d hours\n", j.name, hours) j.tired = false } func (j *JuniorDev) IsTired() bool { return j.tired } // MeetingAttendee interface func (j *JuniorDev) AttendMeeting(topic string) string { return fmt.Sprintf("%s attended meeting about %s", j.name, topic) } func (j *JuniorDev) AvailableForMeeting() bool { return !j.needsBreak } // ReportWriter interface func (j *JuniorDev) WriteReport(title string) string { return fmt.Sprintf("Report '%s' by %s", title, j.name) } // --- // SeniorDev is a senior developer with more capabilities type SeniorDev struct { JuniorDev teamSize int } func NewSeniorDev(name string) *SeniorDev { return &SeniorDev{ JuniorDev: JuniorDev{name: name}, teamSize: 3, } } // CodeReviewer interface func (s *SeniorDev) ReviewCode(prID string) string { return fmt.Sprintf("%s reviewed PR #%s: LGTM!", s.name, prID) } // Deployer interface func (s *SeniorDev) Deploy(version string) error { fmt.Printf("%s deploying version %s...\n", s.name, version) return nil } // TeamManager interface func (s *SeniorDev) ManageTeam() []string { return []string{"junior1", "junior2", "intern"} } func (s *SeniorDev) TeamSize() int { return s.teamSize } // Trainer interface func (s *SeniorDev) Train(newHire string) string { return fmt.Sprintf("%s is training %s on best practices", s.name, newHire) } // --- // CICDBot is an automated deployment system type CICDBot struct { name string } func NewCICDBot(name string) *CICDBot { return &CICDBot{name: name} } // Worker interface func (c *CICDBot) Work() string { return fmt.Sprintf("%s is running automated pipelines", c.name) } func (c *CICDBot) ID() string { return fmt.Sprintf("BOT-%s", c.name) } // Deployer interface func (c *CICDBot) Deploy(version string) error { fmt.Printf("%s automatically deploying version %s\n", c.name, version) return nil } // CodeReviewer interface (automated code review) func (c *CICDBot) ReviewCode(prID string) string { return fmt.Sprintf("%s ran automated checks on PR #%s: All checks passed", c.name, prID) } // Notice: CICDBot does NOT implement Breakable, Feedable, or Sleepable // because it doesn't need to! // --- // Chatbot handles customer support type Chatbot struct { name string } func NewChatbot(name string) *Chatbot { return &Chatbot{name: name} } // Worker interface func (c *Chatbot) Work() string { return fmt.Sprintf("%s is handling customer inquiries", c.name) } func (c *Chatbot) ID() string { return fmt.Sprintf("CHAT-%s", c.name) } // SupportHandler interface func (c *Chatbot) HandleSupport(ticket string) string { return fmt.Sprintf("%s resolved ticket %s automatically", c.name, ticket) } // Notice: Chatbot only implements what it can actually do! // ============================================================================= // FUNCTIONS THAT USE SPECIFIC INTERFACES // ============================================================================= // ScheduleLunch only works with Feedable entities // Why: ISP - we only require what we need func ScheduleLunch(workers []Feedable) { fmt.Println("\n🍽️ Scheduling lunch...") for _, worker := range workers { if worker.IsHungry() { worker.Eat("sandwich and coffee") } } } // RunCodeReview only works with CodeReviewer entities // Why: Both humans and bots can review code func RunCodeReview(reviewers []CodeReviewer, prID string) []string { fmt.Printf("\n📝 Running code review for PR #%s...\n", prID) var results []string for _, reviewer := range reviewers { results = append(results, reviewer.ReviewCode(prID)) } return results } // DeployNewVersion only works with Deployer entities func DeployNewVersion(deployers []Deployer, version string) { fmt.Printf("\n🚀 Deploying version %s...\n", version) for _, deployer := range deployers { deployer.Deploy(version) } } // ManageBreaks only works with Breakable entities // Why: Robots don't need break management! func ManageBreaks(workers []Breakable) { fmt.Println("\n☕ Checking who needs a break...") for _, worker := range workers { if worker.NeedsBreak() { worker.TakeBreak(15 * time.Minute) } } } // ============================================================================= // DEMONSTRATION // ============================================================================= func main() { fmt.Println("╔══════════════════════════════════════════════════════════╗") fmt.Println("║ INTERFACE SEGREGATION PRINCIPLE DEMONSTRATION ║") fmt.Println("╚══════════════════════════════════════════════════════════╝") // Create different types of workers junior := NewJuniorDev("Alice") senior := NewSeniorDev("Bob") cicd := NewCICDBot("Jenkins") chatbot := NewChatbot("SupportBot") // All workers can work fmt.Println("\n👷 All workers performing work:") workers := []Worker{junior, senior, cicd, chatbot} for _, w := range workers { fmt.Printf(" [%s] %s\n", w.ID(), w.Work()) } // Only biological workers need lunch fmt.Println("\n🍽️ Lunch management (only for Feedable):") feedableWorkers := []Feedable{junior, senior} ScheduleLunch(feedableWorkers) // Note: cicd and chatbot are NOT included - they don't implement Feedable! // Only breakable workers need break management fmt.Println("\n☕ Break management (only for Breakable):") breakableWorkers := []Breakable{junior, senior} ManageBreaks(breakableWorkers) // Note: Bots don't need breaks! // Both humans and bots can review code fmt.Println("\n📝 Code review (CodeReviewers only):") reviewers := []CodeReviewer{senior, cicd} reviews := RunCodeReview(reviewers, "42") for _, review := range reviews { fmt.Printf(" %s\n", review) } // Note: junior is not included - not yet a code reviewer! // Multiple deployers can deploy fmt.Println("\n🚀 Deployment (Deployers only):") deployers := []Deployer{senior, cicd} DeployNewVersion(deployers, "2.0.0") // Note: junior and chatbot can't deploy! fmt.Println("\n" + "═"*60) fmt.Println("KEY INSIGHT: Each worker only implements interfaces") fmt.Println("that match its actual capabilities!") fmt.Println("═"*60) }
Expected Output:
╔══════════════════════════════════════════════════════════╗ ║ INTERFACE SEGREGATION PRINCIPLE DEMONSTRATION ║ ╚══════════════════════════════════════════════════════════╝ 👷 All workers performing work: [JR-Alice] Alice is writing code [JR-Bob] Bob is writing code [BOT-Jenkins] Jenkins is running automated pipelines [CHAT-SupportBot] SupportBot is handling customer inquiries 🍽️ Lunch management (only for Feedable): 🍽️ Scheduling lunch... Alice is eating sandwich and coffee Bob is eating sandwich and coffee ☕ Break management (only for Breakable): ☕ Checking who needs a break... Alice is taking a 15m0s break Bob is taking a 15m0s break 📝 Code review (CodeReviewers only): 📝 Running code review for PR #42... Bob reviewed PR #42: LGTM! Jenkins ran automated checks on PR #42: All checks passed 🚀 Deployment (Deployers only): 🚀 Deploying version 2.0.0... Bob deploying version 2.0.0... Jenkins automatically deploying version 2.0.0 ════════════════════════════════════════════════════════════ KEY INSIGHT: Each worker only implements interfaces that match its actual capabilities! ════════════════════════════════════════════════════════════
ISP Visualization

Design principles diagram 5
Dependency Inversion Principle (DIP)
The Principle in Plain English
High-level modules should not depend on low-level modules. Both should depend on abstractions.
And: Abstractions should not depend on details. Details should depend on abstractions.
The Electrical Appliance Analogy
When you buy a lamp, do you worry about which power plant supplies your electricity? Coal, nuclear, solar, wind - it doesn't matter. Your lamp depends on the abstraction (the electrical outlet standard), not on the detail (specific power source).
Similarly, the power plant doesn't care about your specific lamp. It produces electricity according to the standard, and any compliant device can use it.
Both the lamp (high-level: consumes power) and the power plant (low-level: produces power) depend on the abstraction (electrical standards), not on each other directly.
A Tightly Coupled Disaster
go// Filename: bad_notification_system.go // This is BAD code - high-level module depends on low-level details package main import ( "database/sql" "fmt" "net/smtp" ) // MySQLDatabase is a low-level module type MySQLDatabase struct { connection *sql.DB } func (m *MySQLDatabase) GetUserEmail(userID int) string { // Query MySQL directly return "user@example.com" } // SMTPEmailer is a low-level module type SMTPEmailer struct { host string port int username string password string } func (s *SMTPEmailer) SendEmail(to, subject, body string) error { auth := smtp.PlainAuth("", s.username, s.password, s.host) msg := []byte(fmt.Sprintf("Subject: %s\r\n\r\n%s", subject, body)) addr := fmt.Sprintf("%s:%d", s.host, s.port) return smtp.SendMail(addr, auth, "noreply@company.com", []string{to}, msg) } // NotificationService is a HIGH-LEVEL module // But it directly depends on LOW-LEVEL modules! type NotificationService struct { // PROBLEM: Direct dependency on MySQL database *MySQLDatabase // PROBLEM: Direct dependency on SMTP emailer *SMTPEmailer } // NewNotificationService creates the service // PROBLEM: We can't create this without real MySQL and SMTP! func NewNotificationService() *NotificationService { return &NotificationService{ database: &MySQLDatabase{ // Need real MySQL connection! }, emailer: &SMTPEmailer{ host: "smtp.company.com", port: 587, username: "notifications", password: "secret", }, } } // NotifyUser sends a notification to a user // PROBLEM: This is untestable without real database and email server! func (n *NotificationService) NotifyUser(userID int, message string) error { // Directly using MySQL email := n.database.GetUserEmail(userID) // Directly using SMTP return n.emailer.SendEmail(email, "Notification", message) } // PROBLEMS: // 1. Can't test NotificationService without MySQL and SMTP server // 2. Can't switch to PostgreSQL without modifying NotificationService // 3. Can't switch to SendGrid without modifying NotificationService // 4. Can't use this in development without production infrastructure
The Solution: Depend on Abstractions
go// Filename: dip_notification_system.go package main import ( "fmt" "strings" "time" ) // ============================================================================= // ABSTRACTIONS: Interfaces that both high and low-level modules depend on // ============================================================================= // UserRepository abstracts data access // Why: High-level code doesn't care if data comes from MySQL, Postgres, or a file type UserRepository interface { GetUserByID(id int) (*User, error) GetUsersByRole(role string) ([]*User, error) } // EmailSender abstracts email sending // Why: High-level code doesn't care about SMTP, SendGrid, or SES type EmailSender interface { Send(to, subject, body string) error } // Logger abstracts logging // Why: High-level code doesn't care about console, file, or cloud logging type Logger interface { Info(message string) Error(message string) Debug(message string) } // NotificationTemplate abstracts template rendering type NotificationTemplate interface { Render(data map[string]interface{}) string } // ============================================================================= // DOMAIN MODEL // ============================================================================= // User represents a user in the system type User struct { ID int Name string Email string Role string CreatedAt time.Time } // Notification represents a notification to be sent type Notification struct { UserID int Title string Message string CreatedAt time.Time } // ============================================================================= // LOW-LEVEL IMPLEMENTATIONS (Details depend on abstractions) // ============================================================================= // --- Database Implementations --- // MySQLUserRepository implements UserRepository using MySQL type MySQLUserRepository struct { connectionString string } func NewMySQLUserRepository(connStr string) *MySQLUserRepository { return &MySQLUserRepository{connectionString: connStr} } func (m *MySQLUserRepository) GetUserByID(id int) (*User, error) { fmt.Printf("[MySQL] Querying user with ID %d\n", id) // In real implementation: query MySQL return &User{ ID: id, Name: "John Doe", Email: "john@example.com", Role: "admin", }, nil } func (m *MySQLUserRepository) GetUsersByRole(role string) ([]*User, error) { fmt.Printf("[MySQL] Querying users with role '%s'\n", role) return []*User{ {ID: 1, Name: "Admin 1", Email: "admin1@example.com", Role: role}, {ID: 2, Name: "Admin 2", Email: "admin2@example.com", Role: role}, }, nil } // PostgresUserRepository implements UserRepository using PostgreSQL type PostgresUserRepository struct { connectionString string } func NewPostgresUserRepository(connStr string) *PostgresUserRepository { return &PostgresUserRepository{connectionString: connStr} } func (p *PostgresUserRepository) GetUserByID(id int) (*User, error) { fmt.Printf("[PostgreSQL] Querying user with ID %d\n", id) return &User{ ID: id, Name: "Jane Doe", Email: "jane@example.com", Role: "user", }, nil } func (p *PostgresUserRepository) GetUsersByRole(role string) ([]*User, error) { fmt.Printf("[PostgreSQL] Querying users with role '%s'\n", role) return []*User{ {ID: 3, Name: "User 1", Email: "user1@example.com", Role: role}, }, nil } // InMemoryUserRepository implements UserRepository for testing type InMemoryUserRepository struct { users map[int]*User } func NewInMemoryUserRepository() *InMemoryUserRepository { return &InMemoryUserRepository{ users: map[int]*User{ 1: {ID: 1, Name: "Test User", Email: "test@example.com", Role: "tester"}, }, } } func (i *InMemoryUserRepository) GetUserByID(id int) (*User, error) { if user, ok := i.users[id]; ok { return user, nil } return nil, fmt.Errorf("user not found") } func (i *InMemoryUserRepository) GetUsersByRole(role string) ([]*User, error) { var result []*User for _, user := range i.users { if user.Role == role { result = append(result, user) } } return result, nil } func (i *InMemoryUserRepository) AddUser(user *User) { i.users[user.ID] = user } // --- Email Implementations --- // SMTPEmailSender implements EmailSender using SMTP type SMTPEmailSender struct { host string port int username string password string } func NewSMTPEmailSender(host string, port int, user, pass string) *SMTPEmailSender { return &SMTPEmailSender{ host: host, port: port, username: user, password: pass, } } func (s *SMTPEmailSender) Send(to, subject, body string) error { fmt.Printf("[SMTP] Sending email to %s\n", to) fmt.Printf("[SMTP] Subject: %s\n", subject) fmt.Printf("[SMTP] Body preview: %s...\n", truncate(body, 50)) // Real implementation would use net/smtp return nil } // SendGridEmailSender implements EmailSender using SendGrid API type SendGridEmailSender struct { apiKey string } func NewSendGridEmailSender(apiKey string) *SendGridEmailSender { return &SendGridEmailSender{apiKey: apiKey} } func (s *SendGridEmailSender) Send(to, subject, body string) error { fmt.Printf("[SendGrid] Sending email to %s via API\n", to) fmt.Printf("[SendGrid] Subject: %s\n", subject) // Real implementation would call SendGrid API return nil } // MockEmailSender implements EmailSender for testing type MockEmailSender struct { SentEmails []struct { To string Subject string Body string } } func NewMockEmailSender() *MockEmailSender { return &MockEmailSender{} } func (m *MockEmailSender) Send(to, subject, body string) error { m.SentEmails = append(m.SentEmails, struct { To string Subject string Body string }{to, subject, body}) fmt.Printf("[MOCK] Email recorded: to=%s, subject=%s\n", to, subject) return nil } // --- Logger Implementations --- // ConsoleLogger implements Logger for console output type ConsoleLogger struct { prefix string } func NewConsoleLogger(prefix string) *ConsoleLogger { return &ConsoleLogger{prefix: prefix} } func (c *ConsoleLogger) Info(message string) { fmt.Printf("[%s][INFO] %s\n", c.prefix, message) } func (c *ConsoleLogger) Error(message string) { fmt.Printf("[%s][ERROR] %s\n", c.prefix, message) } func (c *ConsoleLogger) Debug(message string) { fmt.Printf("[%s][DEBUG] %s\n", c.prefix, message) } // CloudLogger implements Logger for cloud logging service type CloudLogger struct { serviceName string endpoint string } func NewCloudLogger(service, endpoint string) *CloudLogger { return &CloudLogger{serviceName: service, endpoint: endpoint} } func (c *CloudLogger) Info(message string) { fmt.Printf("[CLOUD->%s] INFO: %s\n", c.endpoint, message) } func (c *CloudLogger) Error(message string) { fmt.Printf("[CLOUD->%s] ERROR: %s\n", c.endpoint, message) } func (c *CloudLogger) Debug(message string) { // Cloud logger might skip debug in production fmt.Printf("[CLOUD->%s] DEBUG: %s\n", c.endpoint, message) } // ============================================================================= // HIGH-LEVEL MODULE (Depends only on abstractions) // ============================================================================= // NotificationService is the high-level business logic // Why: It depends on INTERFACES (abstractions), not concrete implementations type NotificationService struct { userRepo UserRepository emailer EmailSender logger Logger } // NewNotificationService creates a notification service with injected dependencies // Why: Dependencies are INJECTED, not created internally // This allows swapping implementations without changing this code func NewNotificationService( userRepo UserRepository, emailer EmailSender, logger Logger, ) *NotificationService { return &NotificationService{ userRepo: userRepo, emailer: emailer, logger: logger, } } // NotifyUser sends a notification to a specific user func (n *NotificationService) NotifyUser(userID int, title, message string) error { n.logger.Info(fmt.Sprintf("Sending notification to user %d", userID)) // Get user from repository (doesn't know if it's MySQL, Postgres, or in-memory) user, err := n.userRepo.GetUserByID(userID) if err != nil { n.logger.Error(fmt.Sprintf("Failed to find user %d: %v", userID, err)) return fmt.Errorf("user not found: %w", err) } n.logger.Debug(fmt.Sprintf("Found user: %s (%s)", user.Name, user.Email)) // Send email (doesn't know if it's SMTP, SendGrid, or mock) fullMessage := fmt.Sprintf("Hello %s,\n\n%s\n\nBest regards,\nThe Team", user.Name, message) if err := n.emailer.Send(user.Email, title, fullMessage); err != nil { n.logger.Error(fmt.Sprintf("Failed to send email: %v", err)) return fmt.Errorf("failed to send notification: %w", err) } n.logger.Info(fmt.Sprintf("Successfully notified user %d", userID)) return nil } // NotifyAllAdmins sends a notification to all administrators func (n *NotificationService) NotifyAllAdmins(title, message string) error { n.logger.Info("Sending notification to all admins") admins, err := n.userRepo.GetUsersByRole("admin") if err != nil { n.logger.Error(fmt.Sprintf("Failed to get admins: %v", err)) return err } n.logger.Info(fmt.Sprintf("Found %d admins", len(admins))) var errors []string for _, admin := range admins { fullMessage := fmt.Sprintf("Hello %s,\n\n%s", admin.Name, message) if err := n.emailer.Send(admin.Email, title, fullMessage); err != nil { errors = append(errors, fmt.Sprintf("failed to notify %s: %v", admin.Email, err)) } } if len(errors) > 0 { return fmt.Errorf("some notifications failed: %s", strings.Join(errors, "; ")) } return nil } // ============================================================================= // DEPENDENCY INJECTION CONTAINER (Optional pattern for managing dependencies) // ============================================================================= // Container manages all application dependencies type Container struct { UserRepository UserRepository EmailSender EmailSender Logger Logger } // NewProductionContainer creates dependencies for production func NewProductionContainer() *Container { return &Container{ UserRepository: NewMySQLUserRepository("mysql://prod:secret@proddb/app"), EmailSender: NewSendGridEmailSender("SG.production-api-key"), Logger: NewCloudLogger("notification-service", "logs.company.com"), } } // NewDevelopmentContainer creates dependencies for development func NewDevelopmentContainer() *Container { return &Container{ UserRepository: NewPostgresUserRepository("postgres://dev:dev@localhost/app"), EmailSender: NewSMTPEmailSender("localhost", 1025, "", ""), Logger: NewConsoleLogger("DEV"), } } // NewTestContainer creates dependencies for testing func NewTestContainer() *Container { return &Container{ UserRepository: NewInMemoryUserRepository(), EmailSender: NewMockEmailSender(), Logger: NewConsoleLogger("TEST"), } } // Helper function func truncate(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen] } // ============================================================================= // DEMONSTRATION // ============================================================================= func main() { fmt.Println("╔══════════════════════════════════════════════════════════╗") fmt.Println("║ DEPENDENCY INVERSION PRINCIPLE DEMONSTRATION ║") fmt.Println("╚══════════════════════════════════════════════════════════╝") // --- SCENARIO 1: Production Configuration --- fmt.Println("\n📦 SCENARIO 1: Production Environment") fmt.Println("━"*50) prodContainer := NewProductionContainer() prodService := NewNotificationService( prodContainer.UserRepository, prodContainer.EmailSender, prodContainer.Logger, ) prodService.NotifyUser(1, "System Update", "The system will be updated tonight.") // --- SCENARIO 2: Development Configuration --- fmt.Println("\n📦 SCENARIO 2: Development Environment") fmt.Println("━"*50) devContainer := NewDevelopmentContainer() devService := NewNotificationService( devContainer.UserRepository, devContainer.EmailSender, devContainer.Logger, ) devService.NotifyUser(1, "Test Notification", "This is a test message.") // --- SCENARIO 3: Test Configuration --- fmt.Println("\n📦 SCENARIO 3: Test Environment (with assertions)") fmt.Println("━"*50) testRepo := NewInMemoryUserRepository() testRepo.AddUser(&User{ ID: 42, Name: "Test Admin", Email: "admin@test.com", Role: "admin", }) mockEmailer := NewMockEmailSender() testLogger := NewConsoleLogger("TEST") testService := NewNotificationService(testRepo, mockEmailer, testLogger) testService.NotifyUser(42, "Test Title", "Test Body") // Verify the email was "sent" fmt.Printf("\n✓ Assertion: %d emails were sent\n", len(mockEmailer.SentEmails)) if len(mockEmailer.SentEmails) == 1 { email := mockEmailer.SentEmails[0] fmt.Printf("✓ Assertion: Email sent to %s\n", email.To) fmt.Printf("✓ Assertion: Subject was '%s'\n", email.Subject) } fmt.Println("\n" + "═"*60) fmt.Println("KEY INSIGHTS:") fmt.Println("1. NotificationService never changed between environments") fmt.Println("2. We can test without real databases or email servers") fmt.Println("3. Switching from MySQL to Postgres requires no code changes") fmt.Println("4. Switching from SMTP to SendGrid requires no code changes") fmt.Println("═"*60) }
Expected Output:
╔══════════════════════════════════════════════════════════╗ ║ DEPENDENCY INVERSION PRINCIPLE DEMONSTRATION ║ ╚══════════════════════════════════════════════════════════╝ 📦 SCENARIO 1: Production Environment ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ [CLOUD->logs.company.com] INFO: Sending notification to user 1 [MySQL] Querying user with ID 1 [CLOUD->logs.company.com] DEBUG: Found user: John Doe (john@example.com) [SendGrid] Sending email to john@example.com via API [SendGrid] Subject: System Update [CLOUD->logs.company.com] INFO: Successfully notified user 1 📦 SCENARIO 2: Development Environment ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ [DEV][INFO] Sending notification to user 1 [PostgreSQL] Querying user with ID 1 [DEV][DEBUG] Found user: Jane Doe (jane@example.com) [SMTP] Sending email to jane@example.com [SMTP] Subject: Test Notification [SMTP] Body preview: Hello Jane Doe, This is a test message.... [DEV][INFO] Successfully notified user 1 📦 SCENARIO 3: Test Environment (with assertions) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ [TEST][INFO] Sending notification to user 42 [TEST][DEBUG] Found user: Test Admin (admin@test.com) [MOCK] Email recorded: to=admin@test.com, subject=Test Title [TEST][INFO] Successfully notified user 42 ✓ Assertion: 1 emails were sent ✓ Assertion: Email sent to admin@test.com ✓ Assertion: Subject was 'Test Title' ════════════════════════════════════════════════════════════ KEY INSIGHTS: 1. NotificationService never changed between environments 2. We can test without real databases or email servers 3. Switching from MySQL to Postgres requires no code changes 4. Switching from SMTP to SendGrid requires no code changes ════════════════════════════════════════════════════════════
DIP Architecture Visualization

Design principles diagram 6
SOLID Principles Working Together
The real power of SOLID comes when all five principles work together. Let's see a complete example:

Design principles diagram 7
When to Apply SOLID (And When Not To)
Use SOLID When:
| Scenario | Why SOLID Helps |
|---|---|
| Building long-lived applications | Code evolves over years; SOLID makes changes safe |
| Working in teams | Clear responsibilities reduce conflicts |
| Writing testable code | Dependency injection enables mocking |
| Building libraries/frameworks | Users need to extend without modifying source |
| Complex business logic | Separation of concerns aids understanding |
Be Careful When:
| Scenario | Why SOLID Might Hurt |
|---|---|
| Quick prototypes | Over-engineering slows experimentation |
| Tiny scripts | Abstractions add unnecessary complexity |
| Performance-critical code | Interfaces add (minimal) overhead |
| One-time use code | Investment in design won't pay off |
Common SOLID Mistakes
⚠️ Mistake 1: Too Many Interfaces
go// OVERKILL: Interface with one implementation type UserRepositoryInterface interface { Save(user *User) error } type UserRepository struct{} // Only implementation // BETTER: Skip the interface until you need it // Go's implicit interfaces mean you can add one later
⚠️ Mistake 2: Premature Abstraction
go// OVERKILL: Abstracting before you understand the problem type Logger interface { Log(level, message string) } type ConsoleLogger struct{} type FileLogger struct{} type CloudLogger struct{} // ... created before knowing actual requirements // BETTER: Start concrete, extract interface when needed
⚠️ Mistake 3: Violating YAGNI (You Aren't Gonna Need It)
go// OVERKILL: Supporting every possible database from day one type Repository interface { Save(entity interface{}) error Find(id interface{}) (interface{}, error) // ... 20 more methods for every possible operation } // BETTER: Start with what you need now type UserRepository interface { Save(user *User) error FindByID(id int) (*User, error) }
Summary: The SOLID Cheat Sheet
| Principle | One-Liner | Key Question to Ask |
|---|---|---|
| SRP | One reason to change | "If this changes, what else breaks?" |
| OCP | Extend, don't modify | "Can I add features without changing existing code?" |
| LSP | Subtypes are substitutable | "Can I swap implementations without surprises?" |
| ISP | Small, focused interfaces | "Does this interface force unused methods?" |
| DIP | Depend on abstractions | "Is this class tightly coupled to infrastructure?" |
Next Steps
Now that you understand SOLID:
-
Audit Your Code: Pick a class in your project. Does it have multiple reasons to change? Consider splitting it.
-
Identify God Interfaces: Look for interfaces with 5+ methods. Can they be split?
-
Check Dependencies: Are your high-level modules importing low-level implementation packages directly? Consider introducing interfaces.
-
Write Tests: If testing is hard, you probably have DIP violations. Introduce interfaces for external dependencies.