# Change-Resilient Architecture with SOLID

> SOLID is useful for controlling reasons for change and dependency direction, not for maximizing the number of abstractions; unnecessary indirection creates design debt of its own.

- Author: Muhammet Ali Köker
- Language: en
- Canonical: https://alikoker.com.tr/en/change-resilient-architecture-with-solid
- Translation: https://alikoker.com.tr/solid-ile-degisime-dayanikli-mimari
- Published: 2026-03-10T12:00:00+03:00
- Modified: 2026-08-31T22:45:00+03:00
- Verified: 2026-08-07T11:00:00+03:00
- Type: article

When evaluating SOLID, the deciding question is not how “clean” a class diagram looks, but how far a real change request propagates through the system. If a small business-rule change touches unrelated classes, breaks tests across a wide area, or requires the same dependency to be rearranged in several places, the design cost is already high regardless of line count.

I therefore treat SOLID as a set of constraints on the direction of change rather than as five independent slogans. Single Responsibility separates reasons to change; Open/Closed aims to add behaviour without repeatedly cutting through existing code; Liskov substitution, interface segregation, and dependency inversion define boundaries for substitutability and dependency direction.

These principles are not mechanical rules. Not every class must be small. It is unnecessary to create an interface for every class. Not every `switch` statement is poor design. The objective is not to produce more classes but to keep change in the correct place.

## Single Responsibility Principle

The Single Responsibility Principle states that a class should have only one reason to change. It is often explained as "a class should do one thing." Here, one thing does not mean one method. Several methods that cooperate toward the same purpose can remain within one responsibility. What matters is that the class is not affected by different actors and unrelated requirements.

User registration is a common example. The following class validates the user, saves the record to the database, and sends an email. These operations change for different reasons. Validation rules depend on business requirements. Persistence depends on data-storage technology. Email delivery depends on message templates and communication infrastructure.

```java
public final class UserService {
    public void register(final User user) {
        if (user.getEmail() == null || !user.getEmail().contains("@")) {
            throw new IllegalArgumentException("Invalid email");
        }

        System.out.println("User saved: " + user.getEmail());
        System.out.println("Welcome email sent: " + user.getEmail());
    }
}

public final class User {
    private final String name;
    private final String email;

    public User(final String name, final String email) {
        this.name = name;
        this.email = email;
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }
}
```

In a better design, every reason for change moves to a separate class. `UserRegistrationService` manages only the registration process. Validation, persistence, and notification are separate responsibilities. Orchestration itself is a valid responsibility, but business details should not be embedded in the orchestration class.

```java
public final class UserValidator {
    public void validate(final User user) {
        if (user.getEmail() == null || !user.getEmail().contains("@")) {
            throw new IllegalArgumentException("Invalid email");
        }
    }
}

public final class UserRepository {
    public void save(final User user) {
        System.out.println("User saved: " + user.getEmail());
    }
}

public final class EmailService {
    public void sendWelcomeEmail(final User user) {
        System.out.println("Welcome email sent: " + user.getEmail());
    }
}

public final class UserRegistrationService {
    private final UserValidator validator;
    private final UserRepository repository;
    private final EmailService emailService;

    public UserRegistrationService(final UserValidator validator, final UserRepository repository, final EmailService emailService) {
        this.validator = validator;
        this.repository = repository;
        this.emailService = emailService;
    }

    public void register(final User user) {
        validator.validate(user);
        repository.save(user);
        emailService.sendWelcomeEmail(user);
    }
}
```

A common result of violating SRP is the gradual growth of `Manager`, `Helper`, `Processor`, and `Service` classes. Such classes combine database access, business rules, file operations, and network work. Their tests become difficult and constructor dependencies multiply. Excessive fragmentation is not the answer either. Moving every method into a separate class scatters the code. The correct boundary is determined by behavior that changes together.

## Open Closed Principle

The Open Closed Principle states that software components should be open for extension and closed for modification. This closure is not absolute. Code can be changed to fix defects or alter fundamental behavior. The principle aims to prevent stable code from being edited repeatedly whenever a new option is added.

In an area-calculation example, a central class checks the concrete type of a shape. The same method must be changed whenever a new shape is introduced. As the number of shapes grows, the conditional structure expands, and the calculator must know every supported type.

```java
public final class Rectangle {
    private final double width;
    private final double height;

    public Rectangle(final double width, final double height) {
        this.width = width;
        this.height = height;
    }

    public double getWidth() {
        return width;
    }

    public double getHeight() {
        return height;
    }
}

public final class Circle {
    private final double radius;

    public Circle(final double radius) {
        this.radius = radius;
    }

    public double getRadius() {
        return radius;
    }
}

public final class AreaCalculator {
    public double calculate(final Object shape) {
        if (shape instanceof Rectangle) {
            final Rectangle rectangle = (Rectangle) shape;
            return rectangle.getWidth() * rectangle.getHeight();
        }

        if (shape instanceof Circle) {
            final Circle circle = (Circle) shape;
            return Math.PI * circle.getRadius() * circle.getRadius();
        }

        throw new IllegalArgumentException("Unsupported shape");
    }
}
```

A more appropriate design moves variable behavior into the `Shape` contract. Every shape calculates its own area. `AreaCalculator` uses only the common contract. Adding a triangle or ellipse does not change the calculator.

```java
public interface Shape {
    double area();
}

public final class Rectangle implements Shape {
    private final double width;
    private final double height;

    public Rectangle(final double width, final double height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public double area() {
        return width * height;
    }
}

public final class Circle implements Shape {
    private final double radius;

    public Circle(final double radius) {
        this.radius = radius;
    }

    @Override
    public double area() {
        return Math.PI * radius * radius;
    }
}

public final class Triangle implements Shape {
    private final double base;
    private final double height;

    public Triangle(final double base, final double height) {
        this.base = base;
        this.height = height;
    }

    @Override
    public double area() {
        return base * height / 2.0;
    }
}

public final class AreaCalculator {
    public double calculate(final List<Shape> shapes) {
        double total = 0.0;

        for (int i = 0; i < shapes.size(); i++) {
            total += shapes.get(i).area();
        }

        return total;
    }
}
```

OCP is not implemented only through inheritance. Patterns such as Strategy, Decorator, Factory, and Adapter can also create extension points. Creating an interface in advance for every possible change is not appropriate, however. A `switch` can be simpler when the change set is small and closed. OCP is not about predicting the future. It is about separating frequently changing behavior from stable code.

## Liskov Substitution Principle

The Liskov Substitution Principle requires a subtype to be usable wherever its base type is expected without breaking program correctness. Compiler acceptance of an inheritance relationship is insufficient. The subclass must preserve the behavior of the base class. Input conditions, result guarantees, error behavior, and object invariants must remain compatible.

The square and rectangle example shows this problem clearly. Width and height are independent for a rectangle. In a square, the two sides must always be equal. If `Square` inherits from `Rectangle`, it must change the behavior of the setters.

```java
public class Rectangle {
    private int width;
    private int height;

    public void setWidth(final int width) {
        this.width = width;
    }

    public void setHeight(final int height) {
        this.height = height;
    }

    public int area() {
        return width * height;
    }
}

public final class Square extends Rectangle {
    @Override
    public void setWidth(final int width) {
        super.setWidth(width);
        super.setHeight(width);
    }

    @Override
    public void setHeight(final int height) {
        super.setHeight(height);
        super.setWidth(height);
    }
}
```

A method can assume that width and height change independently. The result is 50 for an ordinary rectangle. When a square is supplied, the second setter also changes the first value, and the result becomes 25. The subclass has violated the expected behavior of the base class.

```java
public final class ShapeService {
    public int resizeAndCalculate(final Rectangle rectangle) {
        rectangle.setWidth(10);
        rectangle.setHeight(5);
        return rectangle.area();
    }
}
```

A better solution unifies the classes only under behavior they genuinely share. A square and rectangle are both shapes, but they do not share the same mutable-side contract.

```java
public interface Shape {
    int area();
}

public final class Rectangle implements Shape {
    private final int width;
    private final int height;

    public Rectangle(final int width, final int height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public int area() {
        return width * height;
    }
}

public final class Square implements Shape {
    private final int side;

    public Square(final int side) {
        this.side = side;
    }

    @Override
    public int area() {
        return side * side;
    }
}
```

LSP violations have recognizable signs. A subclass that throws `UnsupportedOperationException` from a method is a strong warning. An empty override indicates the same problem. If a client must inspect the concrete type with `instanceof`, the common contract is weak. In real systems, thread safety, timeout behavior, idempotency, and blocking behavior are also part of this contract.

## Interface Segregation Principle

The Interface Segregation Principle states that clients should not depend on methods they do not use. The objective is not to reduce every interface to one method. It is to avoid grouping unrelated capabilities in the same contract. A class that leaves some methods empty or throws an unsupported-operation error indicates that the interface is too broad.

In the multifunction-printer example, one `Machine` interface exposes printing, scanning, and faxing together. A basic printer can only print, yet it must still implement the other methods.

```java
public interface Machine {
    void print(Document document);
    void scan(Document document);
    void fax(Document document);
}

public final class BasicPrinter implements Machine {
    @Override
    public void print(final Document document) {
        System.out.println("Printing");
    }

    @Override
    public void scan(final Document document) {
        throw new UnsupportedOperationException();
    }

    @Override
    public void fax(final Document document) {
        throw new UnsupportedOperationException();
    }
}

public final class Document {
    private final String content;

    public Document(final String content) {
        this.content = content;
    }

    public String getContent() {
        return content;
    }
}
```

When interfaces are separated by capability, every class implements only the behavior it supports. A basic printer uses the `Printer` contract. A multifunction device can implement all three contracts.

```java
public interface Printer {
    void print(Document document);
}

public interface Scanner {
    void scan(Document document);
}

public interface Fax {
    void fax(Document document);
}

public final class BasicPrinter implements Printer {
    @Override
    public void print(final Document document) {
        System.out.println("Printing");
    }
}

public final class MultiFunctionPrinter implements Printer, Scanner, Fax {
    @Override
    public void print(final Document document) {
        System.out.println("Printing");
    }

    @Override
    public void scan(final Document document) {
        System.out.println("Scanning");
    }

    @Override
    public void fax(final Document document) {
        System.out.println("Sending fax");
    }
}
```

ISP does more than improve readability. It also narrows authorization boundaries. A read-only service should not receive a broad repository that includes delete and update capabilities. Excessive separation is also harmful. Creating a separate interface for every method can turn into the interface-explosion antipattern. The correct division follows the behaviors clients use together.

## Dependency Inversion Principle

The Dependency Inversion Principle states that high-level business rules should not depend directly on low-level technical details. A high-level module defines what the system does. A low-level module determines how it is done through email, SMS, a database, or a file system.

The following notification service constructs concrete classes internally. The service must therefore change whenever a new channel is added. Replacing the real senders during testing is also difficult.

```java
public final class EmailSender {
    public void send(final String message) {
        System.out.println("Email: " + message);
    }
}

public final class SmsSender {
    public void send(final String message) {
        System.out.println("SMS: " + message);
    }
}

public final class NotificationService {
    private final EmailSender emailSender = new EmailSender();
    private final SmsSender smsSender = new SmsSender();

    public void notifyUser(final String message) {
        emailSender.send(message);
        smsSender.send(message);
    }
}
```

In a better design, the high-level service knows only the `MessageSender` contract. Concrete senders are created externally and supplied through the constructor. A new channel can be added without changing the existing service.

```java
public interface MessageSender {
    void send(String message);
}

public final class EmailSender implements MessageSender {
    @Override
    public void send(final String message) {
        System.out.println("Email: " + message);
    }
}

public final class SmsSender implements MessageSender {
    @Override
    public void send(final String message) {
        System.out.println("SMS: " + message);
    }
}

public final class PushNotificationSender implements MessageSender {
    @Override
    public void send(final String message) {
        System.out.println("Push notification: " + message);
    }
}

public final class NotificationService {
    private final List<MessageSender> senders;

    public NotificationService(final List<MessageSender> senders) {
        this.senders = new ArrayList<MessageSender>(senders);
    }

    public void notifyUser(final String message) {
        for (int i = 0; i < senders.size(); i++) {
            senders.get(i).send(message);
        }
    }
}

public final class Application {
    public static void main(final String[] args) {
        final List<MessageSender> senders = new ArrayList<MessageSender>();
        senders.add(new EmailSender());
        senders.add(new SmsSender());
        senders.add(new PushNotificationSender());

        final NotificationService service = new NotificationService(senders);
        service.notifyUser("Order completed");
    }
}
```

DIP and [Dependency Injection](/en/wiki/dependency-injection) are not the same thing. DIP is the principle that determines dependency direction. Dependency Injection is a technique for supplying dependencies externally. An IoC container is not mandatory. Manual wiring can be clearer in small systems. Business classes should not search for services inside a container. That approach becomes the Service Locator antipattern and hides actual dependencies.

A design is not SOLID because it contains many interfaces or small classes. A more useful test is what happens when an actual change arrives: how many behaviours move with it, in which direction dependencies flow, and which contracts the tests can keep stable. The principles have architectural value only when they reduce that change cost.

## Relationship to Hexagonal Architecture

I connect Dependency Inversion to explicit application-boundary design in the [Hexagonal Architecture](/en/wiki/hexagonal-architecture) entry, using the ports/adapters distinction.

## References

- Barbara H. Liskov; Jeannette M. Wing. (1994). A Behavioral Notion of Subtyping. ACM Transactions on Programming Languages and Systems, 16(6), 1811-1841. [doi:10.1145/197320.197383](https://doi.org/10.1145/197320.197383)

- Robert C. Martin. (2000). Design Principles and Design Patterns. Object Mentor.

- Robert C. Martin. (2002). Agile Software Development: Principles, Patterns, and Practices. Prentice Hall.

## Cite This Work

Köker, M. A. (2026). Change-Resilient Architecture with SOLID. alikoker.com.tr. https://alikoker.com.tr/en/change-resilient-architecture-with-solid

- BibTeX: https://alikoker.com.tr/en/change-resilient-architecture-with-solid.bib
- RIS: https://alikoker.com.tr/en/change-resilient-architecture-with-solid.ris
- CSL-JSON: https://alikoker.com.tr/en/change-resilient-architecture-with-solid.csl.json
