# Understanding SOLID Principles with Practical Low-Level Coding Examples

## Blog Details

- **Author**: Navneet
- **Date**: January 13, 2026
- **Tags**: SOLID principles, object-oriented design, software architecture, clean code, design patterns
- **Read Time**: 15 mins

# Understanding SOLID Principles with Practical Low-Level Coding Examples

## Introduction

You're staring at a class that does everything: validates input, queries databases, sends emails, formats responses, and logs errors. Adding a simple feature requires changing five different methods. Tests break in unexpected places. Your pull request grows from 10 lines to 200. Sound familiar?

This is the maintenance nightmare that SOLID principles were designed to prevent. Formalized by Robert C. Martin in the early 2000s, these five object-oriented design principles provide specific, actionable patterns for writing maintainable code. But here's the critical caveat that many developers miss: **SOLID principles are guidelines, not absolute rules**. Rigid adherence leads to over-engineering, creating unnecessary abstractions that obscure rather than clarify.

This article explores each SOLID principle through low-level code examples, showing both the technical patterns and the practical judgment required to apply them effectively. We'll examine when to use these principles, when to hold back, and how to balance architectural purity with pragmatic software delivery.

## What Are SOLID Principles and Why They Matter

SOLID is an acronym representing five design principles:

- **S**ingle Responsibility Principle (SRP)
- **O**pen/Closed Principle (OCP)
- **L**iskov Substitution Principle (LSP)
- **I**nterface Segregation Principle (ISP)
- **D**ependency Inversion Principle (DIP)

The core concept underlying all five principles is reducing "reasons to change." In software maintenance, each responsibility in a class represents a potential reason to modify that class. By isolating responsibilities, you create systems where changes remain localized rather than cascading through your codebase.

Research across multiple programming languages demonstrates three primary benefits:

1. **Maintainability**: Code changes isolated to single classes
2. **Testability**: Smaller, focused classes easier to unit test
3. **Extensibility**: New features added without modifying existing code

However, these benefits come with trade-offs. Additional abstractions increase initial complexity and require upfront design time. The skill lies in recognizing which parts of your system warrant SOLID principles and which benefit from simpler approaches.

## Single Responsibility Principle (SRP)

### The Principle

**"A class should have one, and only one, reason to change."**

SRP doesn't mean a class should do only one thing—it means a class should have one reason to change. Multiple methods are fine if they all support the same responsibility.

### The Problem: Multiple Responsibilities

Consider this class handling user registration:

```python
class UserRegistration:
    def register_user(self, email, password):
        # Responsibility 1: Input validation
        if not self._is_valid_email(email):
            raise ValueError("Invalid email")
        if len(password) < 8:
            raise ValueError("Password too short")
        
        # Responsibility 2: Password encryption
        hashed = self._hash_password(password)
        
        # Responsibility 3: Database persistence
        connection = self._get_db_connection()
        cursor = connection.cursor()
        cursor.execute(
            "INSERT INTO users (email, password) VALUES (?, ?)",
            (email, hashed)
        )
        connection.commit()
        
        # Responsibility 4: Email notification
        self._send_welcome_email(email)
        
        # Responsibility 5: Logging
        self._log_registration(email)
```

This class has five reasons to change:
1. Validation rules change
2. Password hashing algorithm changes
3. Database schema or ORM changes
4. Email service or template changes
5. Logging format or destination changes

### The Solution: Separated Responsibilities

Following the pattern documented in technical implementations, we decompose into specialized classes:

```python
class EmailValidator:
    def validate(self, email):
        # Single responsibility: email validation logic
        import re
        pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
        if not re.match(pattern, email):
            raise ValueError("Invalid email format")

class PasswordValidator:
    def validate(self, password):
        # Single responsibility: password validation rules
        if len(password) < 8:
            raise ValueError("Password must be at least 8 characters")
        if not any(c.isupper() for c in password):
            raise ValueError("Password must contain uppercase letter")

class PasswordHasher:
    def hash(self, password):
        # Single responsibility: password encryption
        import hashlib
        return hashlib.sha256(password.encode()).hexdigest()

class UserRepository:
    def __init__(self, connection):
        self.connection = connection
    
    def save(self, email, hashed_password):
        # Single responsibility: user persistence
        cursor = self.connection.cursor()
        cursor.execute(
            "INSERT INTO users (email, password) VALUES (?, ?)",
            (email, hashed_password)
        )
        self.connection.commit()

class WelcomeEmailService:
    def send(self, email):
        # Single responsibility: welcome email delivery
        # Email sending logic here
        pass

class RegistrationLogger:
    def log(self, email):
        # Single responsibility: registration event logging
        print(f"User registered: {email}")

class UserRegistrationService:
    def __init__(self, email_validator, password_validator, 
                 password_hasher, user_repository, 
                 email_service, logger):
        self.email_validator = email_validator
        self.password_validator = password_validator
        self.password_hasher = password_hasher
        self.user_repository = user_repository
        self.email_service = email_service
        self.logger = logger
    
    def register(self, email, password):
        # Orchestrates the registration process
        self.email_validator.validate(email)
        self.password_validator.validate(password)
        hashed = self.password_hasher.hash(password)
        self.user_repository.save(email, hashed)
        self.email_service.send(email)
        self.logger.log(email)
```

Now each class has exactly one reason to change. When password requirements change, you modify only `PasswordValidator`. When switching email providers, you modify only `WelcomeEmailService`.

### Practical Considerations

**When to apply SRP:**
- Classes with multiple distinct responsibilities
- Code that changes frequently for different reasons
- Components requiring independent testing

**When to hold back:**
- Simple scripts or utility functions
- Tightly coupled operations that always change together
- When abstractions add more complexity than they remove

## Open/Closed Principle (OCP)

### The Principle

**"Software entities should be open for extension, but closed for modification."**

You should be able to add new functionality without changing existing code. This is achieved through abstraction—using interfaces or abstract classes that allow new implementations without modifying the base code.

### The Problem: Modification for Extension

Here's a shape calculator requiring modification for each new shape:

```python
class AreaCalculator:
    def calculate_area(self, shape):
        if shape['type'] == 'rectangle':
            return shape['width'] * shape['height']
        elif shape['type'] == 'circle':
            return 3.14159 * shape['radius'] ** 2
        elif shape['type'] == 'triangle':
            return 0.5 * shape['base'] * shape['height']
        # Adding a new shape requires modifying this method
```

Every new shape type requires opening this class and adding another conditional branch. This violates OCP because the class isn't closed for modification.

### The Solution: Extension Through Abstraction

Following the implementation structure documented in research, we use abstraction:

```python
from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        pass

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height
    
    def area(self):
        return self.width * self.height

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius
    
    def area(self):
        import math
        return math.pi * self.radius ** 2

class Triangle(Shape):
    def __init__(self, base, height):
        self.base = base
        self.height = height
    
    def area(self):
        return 0.5 * self.base * self.height

class AreaCalculator:
    def sum(self, shapes):
        # Closed for modification - no changes needed for new shapes
        return sum(shape.area() for shape in shapes)
```

Now adding a new shape requires only creating a new class:

```python
class Pentagon(Shape):
    def __init__(self, side, apothem):
        self.side = side
        self.apothem = apothem
    
    def area(self):
        perimeter = 5 * self.side
        return 0.5 * perimeter * self.apothem
```

The `AreaCalculator` class never needs modification—it's closed for modification but open for extension through new `Shape` implementations.

### Real-World Application: Plugin Systems

*(Note: This is a constructed example for illustration)*

OCP shines in plugin architectures:

```python
class DataExporter(ABC):
    @abstractmethod
    def export(self, data):
        pass

class CSVExporter(DataExporter):
    def export(self, data):
        # CSV export logic
        return ','.join(str(d) for d in data)

class JSONExporter(DataExporter):
    def export(self, data):
        import json
        return json.dumps(data)

class ReportGenerator:
    def __init__(self, exporter: DataExporter):
        self.exporter = exporter
    
    def generate(self, data):
        processed = self._process_data(data)
        return self.exporter.export(processed)
```

New export formats (XML, PDF, Excel) are added without touching `ReportGenerator`.

### Practical Considerations

**When to apply OCP:**
- Anticipating multiple variations of an algorithm
- Building extensible frameworks or libraries
- Code with a history of frequent modification for similar changes

**When to hold back:**
- Requirements are stable and unlikely to change
- The abstraction cost exceeds the extension benefit
- You're prematurely optimizing for hypothetical future needs

## Liskov Substitution Principle (LSP)

### The Principle

**"Objects of a superclass should be replaceable with objects of its subclasses without affecting program correctness."**

LSP ensures that inheritance hierarchies are logically sound. A subclass must honor the behavioral contract established by its parent class.

### Understanding Behavioral Contracts

LSP operates at two levels:

1. **Signature compatibility**: Method parameters and return types
2. **Behavioral compatibility**: Semantic expectations and invariants

Many developers focus only on signature compatibility, but behavioral compatibility is where LSP violations typically occur.

### The Problem: Behavioral Contract Violation

Consider this classic violation documented in research:

```python
class Vehicle:
    def start_engine(self):
        # Start the engine
        print("Engine started")

class Car(Vehicle):
    def start_engine(self):
        print("Car engine started")

class Bicycle(Vehicle):
    def start_engine(self):
        # Bicycles don't have engines!
        raise NotImplementedError("Bicycles don't have engines")
```

This violates LSP because code expecting a `Vehicle` will break with a `Bicycle`:

```python
def prepare_for_journey(vehicle: Vehicle):
    vehicle.start_engine()  # Crashes if vehicle is a Bicycle
    print("Ready to go")

car = Car()
prepare_for_journey(car)  # Works

bike = Bicycle()
prepare_for_journey(bike)  # Raises exception - LSP violation
```

The behavioral contract of `Vehicle.start_engine()` implies it will successfully start. `Bicycle` breaks this contract.

### The Solution: Correct Abstraction

```python
class Vehicle:
    def start(self):
        # Generic start method
        pass

class MotorizedVehicle(Vehicle):
    def start(self):
        self.start_engine()
    
    def start_engine(self):
        print("Engine started")

class Car(MotorizedVehicle):
    def start_engine(self):
        print("Car engine started")

class Bicycle(Vehicle):
    def start(self):
        print("Ready to pedal")

def prepare_for_journey(vehicle: Vehicle):
    vehicle.start()  # Works for all Vehicle subtypes
    print("Ready to go")
```

Now both `Car` and `Bicycle` correctly implement the `Vehicle` contract.

### Method Signature Variance

LSP also requires careful handling of method signatures. The design rule documented in research states: **derived classes must accept same or broader parameter types and return same or narrower return types**.

```python
class NotificationService:
    def send(self, message: str) -> bool:
        # Returns True if sent successfully
        pass

class EmailNotificationService(NotificationService):
    # Valid: accepts same parameter type, returns same type
    def send(self, message: str) -> bool:
        # Email sending logic
        return True

class SMSNotificationService(NotificationService):
    # LSP violation: requires additional parameter
    def send(self, message: str, phone_number: str) -> bool:
        # SMS sending logic
        return True
```

The `SMSNotificationService` violates LSP because it cannot substitute for `NotificationService` without additional information.

### Practical Considerations

**When to apply LSP:**
- Designing inheritance hierarchies
- Creating polymorphic interfaces
- Building frameworks where substitutability is critical

**When LSP violations indicate design problems:**
- Subclasses throwing exceptions for inherited methods
- Subclasses returning null or empty results for required operations
- Need for type-checking before calling methods

## Dependency Inversion Principle (DIP)

### The Principle

As documented in research, DIP has two parts:

1. **"High-level modules should not import anything from low-level modules. Both should depend on abstractions."**
2. **"Abstractions should not depend on details. Details should depend on abstractions."**

DIP inverts the traditional dependency flow where high-level business logic depends directly on low-level implementation details.

### Understanding Abstraction Ownership

A critical aspect often overlooked: **ownership of abstractions belongs to the high-level layer**. The high-level module defines the interface it needs, and low-level modules implement that interface. This is the "inversion" in Dependency Inversion.

### The Problem: Direct Dependencies

```python
class MySQLDatabase:
    def connect(self):
        print("Connecting to MySQL")
    
    def query(self, sql):
        print(f"Executing: {sql}")
        return [{"id": 1, "name": "John"}]

class UserService:
    def __init__(self):
        # High-level module depends on low-level implementation
        self.database = MySQLDatabase()
    
    def get_user(self, user_id):
        self.database.connect()
        return self.database.query(f"SELECT * FROM users WHERE id = {user_id}")
```

Problems:
- `UserService` cannot use PostgreSQL without modification
- Testing requires a real MySQL database
- Changes to `MySQLDatabase` affect `UserService`

### The Solution: Dependency Inversion

```python
from abc import ABC, abstractmethod

# High-level module defines the abstraction it needs
class DatabaseInterface(ABC):
    @abstractmethod
    def fetch_user(self, user_id):
        pass

# High-level module depends only on abstraction
class UserService:
    def __init__(self, database: DatabaseInterface):
        self.database = database
    
    def get_user(self, user_id):
        return self.database.fetch_user(user_id)

# Low-level modules implement the abstraction
class MySQLDatabase(DatabaseInterface):
    def fetch_user(self, user_id):
        # MySQL-specific implementation
        print(f"MySQL: Fetching user {user_id}")
        return {"id": user_id, "name": "John"}

class PostgreSQLDatabase(DatabaseInterface):
    def fetch_user(self, user_id):
        # PostgreSQL-specific implementation
        print(f"PostgreSQL: Fetching user {user_id}")
        return {"id": user_id, "name": "John"}

class MockDatabase(DatabaseInterface):
    def fetch_user(self, user_id):
        # Test implementation
        return {"id": user_id, "name": "Test User"}

# Usage with dependency injection
mysql_db = MySQLDatabase()
user_service = UserService(mysql_db)
user_service.get_user(1)

# Easy to swap implementations
postgres_db = PostgreSQLDatabase()
user_service = UserService(postgres_db)
user_service.get_user(1)

# Easy to test
mock_db = MockDatabase()
test_service = UserService(mock_db)
```

The dependency flow is inverted:
- **Before**: `UserService` → `MySQLDatabase`
- **After**: `UserService` → `DatabaseInterface` ← `MySQLDatabase`

Both high-level and low-level modules depend on the abstraction.

### Dependency Injection Patterns

DIP is typically implemented through dependency injection. Three common patterns:

**Constructor Injection** (shown above):
```python
class OrderProcessor:
    def __init__(self, payment_gateway: PaymentGateway, 
                 inventory: InventoryService):
        self.payment_gateway = payment_gateway
        self.inventory = inventory
```

**Setter Injection**:
```python
class ReportGenerator:
    def set_data_source(self, source: DataSource):
        self.data_source = source
```

**Interface Injection** (less common in Python):
```python
class ConfigurableService:
    def configure(self, config: Configuration):
        self.config = config
```

### Practical Considerations

**When to apply DIP:**
- Integrating external services (databases, APIs, file systems)
- Code requiring extensive unit testing
- Components likely to have multiple implementations

**When to hold back:**
- Simple, stable dependencies unlikely to change
- Internal utilities with no alternative implementations
- When abstraction overhead exceeds flexibility benefits

## Applying SOLID Principles in Real-World Low-Level Design

### Case Study: Order Processing System

*(Note: This is a constructed example for illustration)*

Let's apply multiple SOLID principles to a realistic scenario:

**Requirements:**
- Process customer orders
- Validate order details
- Check inventory
- Process payment
- Send confirmation email
- Log transactions

**Initial Design (violating SOLID):**

```python
class OrderProcessor:
    def process_order(self, order_data):
        # Validation
        if not order_data.get('email'):
            raise ValueError("Email required")
        if order_data['total'] <= 0:
            raise ValueError("Invalid total")
        
        # Inventory check
        connection = self._connect_to_inventory_db()
        cursor = connection.cursor()
        cursor.execute("SELECT stock FROM inventory WHERE product_id = ?", 
                      (order_data['product_id'],))
        stock = cursor.fetchone()[0]
        if stock < order_data['quantity']:
            raise ValueError("Insufficient stock")
        
        # Payment processing
        if order_data['payment_method'] == 'credit_card':
            # Credit card logic
            pass
        elif order_data['payment_method'] == 'paypal':
            # PayPal logic
            pass
        
        # Email notification
        # Email sending code
        
        # Logging
        print(f"Order processed: {order_data['order_id']}")
```

**SOLID-Compliant Refactor:**

```python
# SRP: Separate validation
class OrderValidator:
    def validate(self, order):
        if not order.email:
            raise ValueError("Email required")
        if order.total <= 0:
            raise ValueError("Invalid total")

# SRP: Separate inventory checking
class InventoryService:
    def __init__(self, repository):
        self.repository = repository
    
    def check_availability(self, product_id, quantity):
        stock = self.repository.get_stock(product_id)
        return stock >= quantity

# OCP + DIP: Payment abstraction
class PaymentGateway(ABC):
    @abstractmethod
    def charge(self, amount, payment_details):
        pass

class CreditCardGateway(PaymentGateway):
    def charge(self, amount, payment_details):
        # Credit card processing
        return {"status": "success", "transaction_id": "cc_123"}

class PayPalGateway(PaymentGateway):
    def charge(self, amount, payment_details):
        # PayPal processing
        return {"status": "success", "transaction_id": "pp_456"}

# SRP: Separate notification
class NotificationService:
    def send_order_confirmation(self, email, order):
        # Email sending logic
        pass

# SRP: Separate logging
class TransactionLogger:
    def log_order(self, order_id, status):
        print(f"Order {order_id}: {status}")

# High-level orchestration
class OrderProcessor:
    def __init__(self, validator: OrderValidator,
                 inventory: InventoryService,
                 payment_gateway: PaymentGateway,
                 notifier: NotificationService,
                 logger: TransactionLogger):
        self.validator = validator
        self.inventory = inventory
        self.payment_gateway = payment_gateway
        self.notifier = notifier
        self.logger = logger
    
    def process(self, order):
        self.validator.validate(order)
        
        if not self.inventory.check_availability(
            order.product_id, order.quantity
        ):
            raise ValueError("Insufficient stock")
        
        result = self.payment_gateway.charge(
            order.total, order.payment_details
        )
        
        if result['status'] == 'success':
            self.notifier.send_order_confirmation(order.email, order)
            self.logger.log_order(order.id, 'completed')
        
        return result
```

**Benefits achieved:**
- **SRP**: Each class has one responsibility
- **OCP**: New payment methods added without modifying `OrderProcessor`
- **DIP**: High-level `OrderProcessor` depends on abstractions
- **Testability**: Each component can be tested independently with mocks

## Common Mistakes and Misconceptions

### Mistake 1: Over-Engineering Simple Code

The most common mistake is applying SOLID principles where they add unnecessary complexity. Not every class needs an interface. Not every method needs extraction.

**Bad:**
```python
# Overkill for a simple utility
class StringReverser(ABC):
    @abstractmethod
    def reverse(self, text):
        pass

class ConcreteStringReverser(StringReverser):
    def reverse(self, text):
        return text[::-1]
```

**Good:**
```python
# Simple function is fine
def reverse_string(text):
    return text[::-1]
```

### Mistake 2: Premature Abstraction

Creating abstractions before you have multiple concrete implementations often leads to wrong abstractions.

**Rule of thumb**: Wait until you have at least two implementations before creating an abstraction. The patterns will be clearer.

### Mistake 3: Confusing SRP with "Do One Thing"

SRP is about having one reason to change, not doing one thing. A class can have multiple methods if they all support the same responsibility.

**Valid SRP:**
```python
class UserAuthentication:
    def authenticate(self, username, password):
        pass
    
    def validate_password_strength(self, password):
        pass
    
    def hash_password(self, password):
        pass
    
    def verify_password(self, password, hash):
        pass
```

All methods relate to authentication—one responsibility, one reason to change.

### Mistake 4: LSP Violations Through Weakening Postconditions

Subclasses that return less information than the parent violate LSP:

```python
class UserRepository:
    def find_by_id(self, user_id) -> User:
        # Always returns a User or raises exception
        pass

class CachedUserRepository(UserRepository):
    def find_by_id(self, user_id) -> User:
        # Returns None if not in cache - LSP violation
        return self.cache.get(user_id)  
```

[![SOLID Principles](https://img.youtube.com/vi/gumM1H4qLUM/0.jpg)](https://www.youtube.com/watch?v=gumM1H4qLUM)
