11001

SOLID principles (explained in Java)

Explain principle on one example which is extended on each principle (eazier?)

SOLID - abreviation which stands for five principles comes from Object Oriented Programming paradigm design (OOD). Why/ Problem? Principles help simplify maintenance and project scaling. It helps software engineers design and write maintainable, scalable, and flexible code. that help developers write maintainable, scalable, flexible, and understandable code by promoting loose coupling and high cohesion, reducing code rot, and making systems easier to extend and modify

*Loose coupling is a design principle where components/classes have minimal knowledge of and dependency on each other's internal workings.

*High cohesion means a component/class has a single, well-defined purpose where all its parts work together toward that specific responsibility


In this post , we will delve into all of SOLID’s principles and illustrate how they are implemented using one of the most popular programming languages, Java. While these principles can apply to various programming languages, the sample code contained in this post will use Java.

There is five principles:
S - Single Responsibility Principle

O - Open/ Closed Principle

L - Liskov Substitution Principle

I - Interface Segregation Principle

D - Dependency Inversion Principle


Dive into the principles
Single Responsibility Principle (SRP)
- A class or module should have one and only one reason to change, meaning that a class/ module should have only one job (responsibility). If a class handles more than one functionality, updating one functionality without affecting the others becomes tricky. To avoid these kinds of problems, we should do our best to write modular software in which concerns are separated. If a class has too many responsibilities or functionalities, it becomes a headache to modify. By using the single responsibility principle, we can write code that is modular, easier to maintain, and less error-prone. Implementing SRP involves breaking down complex tasks into smaller, more manageable pieces, making them easier to test, interpret, and change without affecting the system as a whole.
If a class does too many things (e.g., database access, business logic, rendering, and UI rendering), it becomes a "god class" that's hard to test, debug, or reuse. (god object)
get/save/log/send

A component should have one reason to change. Bad - multiple responsibilities, good - one

The subtle power: when email requirements change, you touch EmailService only. When validation rules evolve, only UserValidator changes. Each class has one master to serve.

// Violates SRP - multiple reasons to change
class UserService {
    public void registerUser(User user) {
        // Business logic
        validateUser(user);
        saveToDatabase(user);
        
        // Email logic
        sendWelcomeEmail(user);
        
        // Logging logic
        logRegistration(user);
    }
}

// Follows SRP - single responsibility per class
class UserRegistration {
    private final UserValidator validator;
    private final UserRepository repository;
    private final EmailService emailService;
    private final AuditLogger logger;
    
    public void register(User user) {
        validator.validate(user);
        repository.save(user);
        emailService.sendWelcome(user);
        logger.logRegistration(user);
    }
}



Open/ Close Principle (OCP)
The module should be open for extension, but closed for modification. Objects or entities should be open for extension but closed for modification. This means that a class should be extendable without modifying the class itself.

Classes should be open for extension, closed for modification.

The open-closed principle states that software components (classes, functions, modules, etc.) should be open to extension and closed to modification. I know what you’re thinking — yes, this idea of might seem contradictory at first. But the OCP is simply asking that the software is designed in a way that allows for extension without necessarily modifying the source code. The OCP is crucial for maintaining large code bases, as this guideline is what allows you to introduce new features with little to no risk of breaking the code. Instead of modifying the existing classes or modules when new requirements arise, you should extend the relevant classes by adding new components. As you do this, be sure to check that the new component doesn’t introduce any bugs to the system.

You achieve this through abstraction. New functionality should be added by writing new code, not altering existing, tested code.

// Violates OCP - must modify for new shapes
class AreaCalculator {
    public double calculateArea(Object shape) {
        if (shape instanceof Circle) {
            Circle circle = (Circle) shape;
            return Math.PI * circle.radius * circle.radius;
        } else if (shape instanceof Rectangle) {
            Rectangle rect = (Rectangle) shape;
            return rect.width * rect.height;
        }
        // Add new shape? Modify this method.
        return 0;
    }
}

// Follows OCP - extend through new implementations
interface Shape {
    double calculateArea();
}

class Circle implements Shape {
    private final double radius;
    
    public double calculateArea() {
        return Math.PI * radius * radius;
    }
}

class Rectangle implements Shape {
    private final double width, height;
    
    public double calculateArea() {
        return width * height;
    }
}

// Add triangle? Just create new class, zero existing code changes
class Triangle implements Shape {
    private final double base, height;
    
    public double calculateArea() {
        return 0.5 * base * height;
    }
}

The strategic insight: design your abstractions so that 80% of new features require only new classes, not modifications to existing ones. Your codebase grows by addition, not surgery.

// Bad - needs modification for new button types
function Button({ type, onClick }) {
  if (type === 'primary') return <button className="primary" onClick={onClick}/>;
  if (type === 'secondary') return <button className="secondary" onClick={onClick}/>;
  // Adding new type requires modifying this component
}

// Good - extensible through composition/props
function Button({ className, onClick, children }) {
  return <button className={className} onClick={onClick}>{children}</button>;
}

// Extend without modifying
const PrimaryButton = (props) => <Button className="primary" {...props} />;
const DangerButton = (props) => <Button className="danger" {...props} />;

Liskov Substitution Principle (LSP)

The Liskov substitution principle states that an object of a subclass should be able to replace an object of a superclass without breaking the code. Let’s break down how that works with an example: if L is a subclass of P, then an object of L should replace an object of P without breaking the system. This just means that a subclass should be able to override a superclass method in a way that does not break the system.

If replacing a parent with a child forces if checks, try/catch, or behavior surprises — LSP is broken.

Core idea: If you replace a parent with a child, everything should still work the same way. Subtype must be a perfect replacement for its parent type without breaking anything.

In practice, the Liskov substitution principle ensures that the following conditions are adhered to:

  • A subclass should override methods of the parent class without breaking the code

  • A subclass should not deviate from the behavior of the parent class, meaning subclasses can only add functionality but cannot alter or remove the parent class functionality

  • The code that works with the instances of the parent class should work with the instances of the subclasses without needing to know that the class has changed

Core Concept: Subtypes must be substitutable for their base types without altering program correctness.

This is the most misunderstood principle. It's not just about inheritance—it's about behavioral contracts. If S is a subtype of T, replacing T with S shouldn't surprise the caller.

Let q(x) be a property provable about objects of x of type T. Then q(y) should be provable for objects y of type S where S is a subtype of T.

This means that every subclass or derived class should be substitutable for their base or parent class.

Building off the example AreaCalculator class, consider a new VolumeCalculator class that extends the AreaCalculator class:

// Violates LSP - Square breaks Rectangle's behavioral contract
class Rectangle {
    protected int width, height;
    
    public void setWidth(int width) { this.width = width; }
    public void setHeight(int height) { this.height = height; }
    
    public int getArea() { return width * height; }
}

class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width; // Breaks caller's expectations
    }
    
    @Override
    public void setHeight(int height) {
        this.width = height;
        this.height = height;
    }
}

// Client code breaks
void resizeRectangle(Rectangle rect) {
    rect.setWidth(5);
    rect.setHeight(4);
    assert rect.getArea() == 20; // Fails for Square!
}

// Follows LSP - proper abstraction
interface Shape {
    int getArea();
}

class Rectangle implements Shape {
    private final int width, height;
    
    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }
    
    public int getArea() { return width * height; }
}

class Square implements Shape {
    private final int side;
    
    public Square(int side) {
        this.side = side;
    }
    
    public int getArea() { return side * side; }
}

The critical rule: preconditions cannot be strengthened, postconditions cannot be weakened in subtypes. If a method accepts null in the parent, the child must too. If the parent never throws an exception, neither should the child.

// Bad - breaks expected behavior
function BaseInput({ value, onChange }) {
  return <input value={value} onChange={onChange} />;
}

function NumberInput({ value, onChange }) {
  // Breaks contract - onChange receives number instead of event
  return <input type="number" onChange={(e) => onChange(Number(e.target.value))} />;
}

// Good - maintains contract
function BaseInput({ value, onChange }) {
  return <input value={value} onChange={onChange} />;
}

function NumberInput({ value, onChange }) {
  // Maintains same interface, handles conversion internally
  return <input type="number" value={value} onChange={onChange} />;
}

// Both can be used interchangeably
function Form({ InputComponent }) {
  const [val, setVal] = useState('');
  return <InputComponent value={val} onChange={(e) => setVal(e.target.value)} />;
}

class Shape {
  area() {}
}

class Rectangle extends Shape {
  constructor(w, h) {
    super()
    this.w = w
    this.h = h
  }
  area() {
    return this.w * this.h
  }
}

class Square extends Shape {
  constructor(size) {
    super()
    this.size = size
  }
  area() {
    return this.size * this.size
  }
}

Interface Segregation Principle (ISP)

No client should depend on methods it doesn't use. A client should never be forced to implement an interface that it doesn’t use, or clients shouldn’t be forced to depend on methods they do not use. Делайте интерфейсы маленькими, чтобы клиенты не зависели от вещей, которыми они не пользуются.

The interface segregation principle states that no client should be forced to depend on an interface it doesn’t use. It wants us to create smaller, more specific interfaces that are relevant to the particular clients, rather than having a large, monolithic interface that forces clients to implement methods they don’t need.

Keeping our interfaces compact makes code bases easier to debug, maintain, test, and extend. Without the ISP, a change in one part of a large interface could force changes in unrelated parts of the codebase, causing us to carry out code refactoring which in most cases and depending on the size of the code base can be a difficult task.

JavaScript, unlike C-based programming languages like Java, does not have built-in support for interfaces. However, there are techniques with which interfaces are implemented in JavaScript.

Interfaces are a set of method signatures that a class must implement.

This principle emphasizes that large, general-purpose interfaces should be broken down into smaller, more specific ones. This way, client classes only need to know about the methods that are relevant to them.

Fat interfaces force implementers to write empty methods and force clients to depend on functionality they don't need. This creates unnecessary coupling.

// Violates ISP - forces unnecessary implementations
interface Worker {
    void work();
    void eat();
    void sleep();
}

class Robot implements Worker {
    public void work() { /* meaningful */ }
    public void eat() { /* meaningless - robots don't eat */ }
    public void sleep() { /* meaningless */ }
}

// Follows ISP - segregated interfaces
interface Workable {
    void work();
}

interface Eatable {
    void eat();
}

interface Sleepable {
    void sleep();
}

class Human implements Workable, Eatable, Sleepable {
    public void work() { /* ... */ }
    public void eat() { /* ... */ }
    public void sleep() { /* ... */ }
}

class Robot implements Workable {
    public void work() { /* ... */ }
    // No forced implementation of eat/sleep
}

The practical wisdom: when you see a class implementing an interface but throwing UnsupportedOperationException or leaving methods empty, you've violated ISP. Split that interface.

Now, any shape you create must implement the volume method, but you know that squares are flat shapes and that they do not have volumes, so this interface would force the Square class to implement a method that it doesn’t need.

This would violate the interface segregation principle. Instead of having one large, monolithic interface, we should create separate, more granular interfaces that define specific capabilities.

We keep ShapeInterface for two-dimensional shapes that only have an area:

// Bad - fat interface
function ArticleCard({ 
  article, 
  onEdit, 
  onDelete, 
  onShare, 
  onComment, 
  onLike 
}) {
  // Read-only card doesn't need edit/delete
  return <div>{article.title}</div>;
}

// Good - segregated interfaces
function ArticleCard({ article }) {
  return <div>{article.title}</div>;
}

function EditableArticleCard({ article, onEdit, onDelete }) {
  return (
    <div>
      <ArticleCard article={article} />
      <button onClick={onEdit}>Edit</button>
      <button onClick={onDelete}>Delete</button>
    </div>
  );
}

function InteractiveArticleCard({ article, onLike, onComment }) {
  return (
    <div>
      <ArticleCard article={article} />
      <button onClick={onLike}>Like</button>
      <button onClick={onComment}>Comment</button>
    </div>
  );
}

Dependency Inversion Principle (DIP)

High-level modules shouldn't depend on low-level modules. Both should depend on abstractions.

Depend on abstractions, not concrete implementations.

What Got Inverted?

Before (Traditional):

  • High-level defines: "I need to save a user"

  • High-level depends on: MySQLDatabase class

  • Low-level controls: How saving works

  • Control flows DOWN ⬇️

After (Inverted):

  • High-level defines: "I need something that implements Database interface"

  • High-level depends on: Database abstraction

  • Low-level conforms to: Interface defined by high-level needs

  • Control flows UP ⬆️

// Violates DIP - high-level depends on low-level concrete class
class PaymentProcessor {
    private final MySQLDatabase database;
    
    public PaymentProcessor() {
        this.database = new MySQLDatabase(); // Tight coupling
    }
    
    public void processPayment(Payment payment) {
        // Process payment
        database.save(payment); // Depends on MySQL specifics
    }
}

// Follows DIP - both depend on abstraction
interface PaymentRepository {
    void save(Payment payment);
    Payment findById(String id);
}

class PaymentProcessor {
    private final PaymentRepository repository;
    
    public PaymentProcessor(PaymentRepository repository) {
        this.repository = repository; // Depends on abstraction
    }
    
    public void processPayment(Payment payment) {
        // Process payment
        repository.save(payment);
    }
}

// Concrete implementations depend on same abstraction
class MySQLPaymentRepository implements PaymentRepository {
    public void save(Payment payment) { /* MySQL specifics */ }
    public Payment findById(String id) { /* ... */ }
}

class MongoPaymentRepository implements PaymentRepository {
    public void save(Payment payment) { /* Mongo specifics */ }
    public Payment findById(String id) { /* ... */ }
}
// Bad - high-level module depends on low-level module
class MySQLDatabase {
    public void save(String data) {
        // MySQL specific code
    }
}

class UserService {
    private MySQLDatabase database = new MySQLDatabase();
    
    public void saveUser(User user) {
        database.save(user.toString());
        // Tightly coupled to MySQL
    }
}

// Good - both depend on abstraction
interface Database {
    void save(String data);
}

class MySQLDatabase implements Database {
    @Override
    public void save(String data) {
        // MySQL specific code
    }
}

class PostgreSQLDatabase implements Database {
    @Override
    public void save(String data) {
        // PostgreSQL specific code
    }
}

class UserService {
    private Database database;
    
    // Dependency injection
    public UserService(Database database) {
        this.database = database;
    }
    
    public void saveUser(User user) {
        database.save(user.toString());
        // Can work with any database implementation
    }
}

// Usage
Database db = new MySQLDatabase();
UserService service = new UserService(db);

// Easy to switch
Database newDb = new PostgreSQLDatabase();
UserService newService = new UserService(newDb);

Inject dependencies via props, context, or custom hooks. Never hardcode data sources

// Bad - depends on concrete implementation
function UserList() {
  const [users, setUsers] = useState([]);
  
  useEffect(() => {
    // Tightly coupled to fetch API
    fetch('/api/users')
      .then(res => res.json())
      .then(setUsers);
  }, []);
  
  return <ul>{users.map(u => <li>{u.name}</li>)}</ul>;
}

// Good - depends on abstraction (prop/hook)
function UserList({ users }) {
  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

// Data source is injected
function App() {
  const users = useUsers(); // Could be fetch, GraphQL, mock, etc.
  return <UserList users={users} />;
}

// Even better - custom hook as abstraction
function useUsers() {
  const [users, setUsers] = useState([]);
  useEffect(() => {
    apiClient.getUsers().then(setUsers); // apiClient is injectable
  }, []);
  return users;
}

*Dependency Injection (DI) is providing a component with its dependencies from the outside rather than the component creating them itself.

Core concept: "Don't create, receive."

Three Types of DI:

1. Constructor Injection (Preferred)

class UserService {
    private final UserRepository repository;
    
    public UserService(UserRepository repository) {
        this.repository = repository;
    }
}

2. Setter Injection

class UserService {
    private UserRepository repository;
    
    public void setRepository(UserRepository repository) {
        this.repository = repository;
    }
}

3. Interface Injection (Rare)

interface RepositoryInjector {
    void injectRepository(UserRepository repository);
}

class UserService implements RepositoryInjector {
    private UserRepository repository;
    
    @Override
    public void injectRepository(UserRepository repository) {
        this.repository = repository;
    }
}

React example:

// Without DI
function UserProfile() {
  const user = fetchUser(); // Creates dependency
  return <div>{user.name}</div>;
}

// With DI
function UserProfile({ user }) { // Dependency injected via props
  return <div>{user.name}</div>;
}

function App() {
  const user = useUser();
  return <UserProfile user={user} />; // Inject
}

// Without DI
function UserList() {
  const [users, setUsers] = useState([]);
  
  useEffect(() => {
    fetch('/api/users').then(setUsers); // Hardcoded dependency
  }, []);
  
  return <ul>{users.map(u => <li>{u.name}</li>)}</ul>;
}

// With DI
function UserList({ fetchUsers }) { // Function injected
  const [users, setUsers] = useState([]);
  
  useEffect(() => {
    fetchUsers().then(setUsers);
  }, [fetchUsers]);
  
  return <ul>{users.map(u => <li>{u.name}</li>)}</ul>;
}

// Or using custom hook
function useUsers(apiClient) {
  const [users, setUsers] = useState([]);
  useEffect(() => {
    apiClient.getUsers().then(setUsers);
  }, [apiClient]);
  return users;
}

Why Use DI?

  1. Testability - Inject mocks/stubs

// Direct Creation - can't test with mock
class UserService {
    private UserRepository repository = new UserRepository();
    
    public User getUser(int id) {
        return repository.findById(id); // Always hits real database
    }
}

@Test
void testGetUser() {
    UserService service = new UserService();
    // PROBLEM: Can't avoid real database call!
    User user = service.getUser(1);
}

// Constructor Injection - easy to test with mock
class UserService {
    private UserRepository repository;
    
    public UserService(UserRepository repository) {
        this.repository = repository;
    }
    
    public User getUser(int id) {
        return repository.findById(id);
    }
}

@Test
void testGetUser() {
    UserRepository mockRepo = mock(UserRepository.class);
    when(mockRepo.findById(1)).thenReturn(new User("John"));
    
    UserService service = new UserService(mockRepo); // Inject mock
    User user = service.getUser(1);
    
    assertEquals("John", user.getName());
}
  1. Flexibility - Swap implementations easily

// Direct Creation - stuck with one implementation
class UserService {
    private UserRepository repository = new MySQLUserRepository();
    // Want to switch to PostgreSQL? Must modify this class!
}

// Constructor Injection - easy to switch
class UserService {
    private UserRepository repository;
    
    public UserService(UserRepository repository) {
        this.repository = repository;
    }
}

// Switch implementations without changing UserService
UserRepository mysqlRepo = new MySQLUserRepository();
UserService service1 = new UserService(mysqlRepo);

UserRepository postgresRepo = new PostgreSQLUserRepository();
UserService service2 = new UserService(postgresRepo);

UserRepository mockRepo = new MockUserRepository();
UserService service3 = new UserService(mockRepo);
  1. Loose Coupling - Components don't know about concrete types

  2. Maintainability - Dependencies visible and manageable

// Direct Creation - hidden dependency
class UserService {
    private UserRepository repository = new UserRepository();
    // Can't see what dependencies this class needs
}

UserService service = new UserService(); // Looks simple but hides complexity

// Constructor Injection - visible dependency
class UserService {
    public UserService(UserRepository repository) {
        // Constructor signature shows EXACTLY what's needed
    }
}

UserService service = new UserService(repo); // Clear what's required
  1. Reusability - Components work with different dependencies

The Deeper Pattern

SOLID isn't five separate rules—it's one cohesive philosophy: manage dependencies and coupling through abstraction.

  • SRP reduces coupling by limiting responsibilities

  • OCP manages change through polymorphism

  • LSP ensures abstractions are reliable

  • ISP prevents unnecessary coupling

  • DIP inverts the natural dependency flow

When you violate SOLID, you create rigid systems where changes ripple unpredictably. When you follow SOLID, you create systems where each piece can evolve independently, where testing is natural, where understanding is local rather than global.

The experienced developer knows: SOLID principles aren't about perfect adherence—they're about recognizing trade-offs and knowing when to apply which principle to solve the specific coupling problem you're facing.

To run code, enable code execution and file creation in Settings > Capabilities.