Change-Resilient Architecture with SOLID
Evaluates SOLID principles through change cost, dependency direction, testability, and simplicity of production code. It shows how to apply them without creating unnecessary abstractions.
The main problem in software design is not whether the code works today. It is how broad an area will be affected when it changes tomorrow. A system is fragile if a small requirement forces modifications across many classes. If one change breaks unrelated behavior, dependencies have been established incorrectly.
The SOLID principles address this problem from five directions. They separate responsibilities, create safe extension points for new behavior, constrain inheritance relationships behaviorally, organize interfaces around client needs, and protect business rules from technical details.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 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.
The SOLID principles protect the same design objective from different directions. SRP separates reasons for change. OCP creates safe space for new behavior. LSP preserves behavioral compatibility among types. ISP keeps clients away from unnecessary capabilities. DIP separates business rules from technical details. Good design produces the right boundaries, not the largest number of classes.