SOLID

SOLID is an acronym for five Object-Oriented Design Principles.

  • S - Single Responsibility Principle
  • O - Open-Closed Principle
  • L - Liskov Substitution Principle
  • I - Interface Segregation Principle
  • D - Dependency Inversion Principle

We will go through each and show examples of what a violation looks like, as well as how to correct that violation. At core here is that you will need a solid understanding of polymorphism in Java to understand the code snippets used.

Be aware that the code shown here is intentionally incomplete. The goal is to show just enough code for you to understand the principle at play.

Single Responsibility Principle

Each software module should have one, and only one, reason to change

Consider the following partial class with some selected methods entered:

public class Student {
    private String firstName, lastName, email;
    private List<Course> currentCourses;
    private List<CourseRecord> courseGrades;
    private boolean isTA;
    private Course courseTA;
    private double currentWeeklyHours;
    
    public double generateTranscriptDocument() {
        // does something with courseGrades;
    }
    
    public void semesterReset() {
        currentCourses.clear();
        isTA = false;
        courseTA = null;
        currentWeeklyHours = 0;
    }
    
    public void uploadFinalGrade(Grade grade, Course c) {
        CourseRecord courseRecord = new CourseRecord(c, grade);
        courseGrades.add(courseRecord);
    }
    
    public double calculateGPA() {
        // iterate through courseRecord
    }
    
    public double calculatePay(double hourlyRate) {
        return currentWeeklyHours * hourlyRate;
    }
}

This class feels awkward, like it is doing too much. That’s because it is! Think of what human-beings using the software are using this class for:

  • Registrar - the registrar will want to generateTranscriptDocument
  • Professor - the professor will want to uploadFinalGrade
  • HR - HR will want to use calculatePay

Think of the reasons, however, that this class could change:

1) __generateTranscriptDocument__ may need to change if the printed format changes. 2) __uploadFinalGrade__ and __calculateGPA__ may need to change if the available grades change (as they did during Covid with the addition of CR, GC, and NC grades) 3) __calculatePay__ and fields about TAing may need to change if TAs are moved to a salaried position, or may need to work different if the student is a Graduate student.

In short, there’s a lot of different reasons this class can change. This means that this class is violating the Single Responsibility Principle. As a hint, the use-case of each of the roles mentioned describes a functional module. Using that, we can break this class up:

public class Student {
    ...
}

public class GPACalculator {
    public double calculateGPA(Student student);
}

public class Transcript {
    public void generateTranscript(Student student);
}

public class TAPayment {
    public double calculatePay(Student student);
}

Now, each of these classes has one responsibility. Yes, each of these classes is heavily dependent on Student, but that was still they case when they were all one-class. The key, however, is that if the software engineer receives a request to change a feature related to GPA calculation, they now know the primary class that they are working from. And because GPACalculation is encapsulated from Student, the developer for GPACalculator is significantly less likely to make changes that could break the other two use-cases.

Note that we probably should decompose this further. For instance, it probably would make sense to separate keeping track of the students current courses, from keeping track of their course history, from keeping track of their TA hours. But this is meant to just be a starting point.

Open-Closed Principle

A software artifact should be open for extension, but closed for modifications

This means that, whenever possible, we want to avoid changing existing module code that 1) is working 2) is well tested and 3) has other modules dependent on it. Thus, instead of changing existing code, we can extend it: that is extend the behavior and interface to another class that handles the new feature needs.

Considering our Student class again, what if our software receives a request from HR that we need to change how calculatePay is handled for graduate students. To that end, you decide to update the student class:


public class Student {
    private boolean isGradStudent();
    
    public boolean isGradStudent() {
        return isGradStudent();
    }
    
    public PayInformation getUndergradPayInformation() { 
        if (isGradStudent()) {
            throw new RuntimeException();
        }
        ...
    }

    public PayInformation getGraduatePayInformation() {
        if (!isGradStudent()) {
            throw new RuntimeException();
        }
        ...
    }
}

and the CalculatePay class:

public class TAPayment {
    public double calculatePay(Student student) {
        if(student.isGradStudent()) {
            return calculateGradStudentPay(student);
        } else {
            return calculateUndergradStudentPay(student);
        }
    }
    
    public double calculateGradStudentPay(Student student) {
        PayInformation info = student.getGraduatePayInformation();
        return processPayInformationAndGetWages(info);
    }
    
    public double calculateUndergradStudentPay(Student student) {
        PayInformation info = student.getUndergradPayInformation();
        return processPayInformationAndGetWages(info);
    }
}

Of course, you’ll similarly need to update the constructor for Student to allow to specify a GraduateStudent, or at minimum add a function. But now HR tells you that if a student is a grad student, you’ll need methods to specify a professor as a PI who pays the students stipend and tuition waiver. So now you’re adding a whole slew of fields to the Student class that only affect some students. And your changing existing methods and adding new methods in TAPayment. That means whatever class uses TAPayment will also be affected.

At this point, you should consider the Open-Closed Principle. Rather than modifying the Student class, it may make sense to extend it, or at minimum it’s abstraction. For example:

public abstract class Student {
    public abstract PayInformation getPayInformation();
}

public abstract class UndergradStudent extends Student {
    @Override
    public PayInformation getPayInformation() {
        ...
    }
}

public abstract class GraduateStudent extends Student {
    @Override
    public PayInformation getPayInformation() {
        ...
    }
}

Now, we can still define TAPayment with:

public class TAPayment {
    public double calculatePay(Student student) {
        PayInformation info = student.getPayInformation();
        return processPayInformationAndGetWages(info);
    }
    ...
}

Is this way, the correct function is called, but without needing if statements, or without TAPayment needing to explicitly handle GraduateStudent and UndergradStudent differently. This is also advantageous, as it sets us up later to add classes like PartTimeStudent as needed.

Liscov Substitution Principle

All subclasses should be substitutable for their base or parent class

The above means that “a child should, externally, appear to behave the same as the parent”. That is, every single behavior the parent class has must be inherited by the child.

Consider an idea where HR informs you that, going forward, you can hire non-students (such as local software developers) as TAs for your course. Obviously this isn’t likely to happen for a number of reasons. As so, you add a new extension, ExternalTA for public class Student. Remember, however, Student needs to interface with Transcript and GPACalculator, which our non-students won’t have. You could write something like:

public class ProfessionalTA extends Student {
    @Override
    public PayInformation getPayInformation() {
        ...
    }
    
    //used by Transcript
    @Override
    public TranscriptData getTranscriptData() { 
        throw new UnsupportedOperationException("ProfessionalTA has not course history");
    }

    //used by GPACalculator
    @Override
    public List<CourseRecord> getCourseHistory() {
        throw new UnsupportedOperationException("ProfessionalTA has not course history");
    }
}

…but this is a bad idea. Because now, for a change that should only affect the TAPayment class, I now will have to add to the other two classes that use the Student interface, GPACalculator and Transcript. For example, Transcript may now need to have to change from something like:

public class Transcript {
    public void generateTranscript(Student student) {
        TranscriptData data = student.getTranscriptData();
    }
}

… to something like …

public class Transcript {
    public void generateTranscript(Student student) {
        if (student instanceof ProfessionalTA) {
            throw new IllegalArgumentException("Non-student - "  + student);
        }
        TranscriptData data = student.getTranscriptData();
    }
}

That is, a change to a feature completely unrelated to Transcript forces us to modify Transcript

To fix this problem, we can instead separate the interface for a TA to a separate interface. For example, we make the following changes:

public interface TA {
    public PayInformation getPayInformation();
}

public abstract class Student implements TA {
    public abstract PayInformation getPayInformation();
}

public class ProfessionalTA implements TA {
    @Override
    public PayInformation getPayInformation() {
        ...
    }
}

public class TAPayment {
    public double calculatePay(TA ta) {
        PayInformation info = ta.getPayInformation();
        return processPayInformationAndGetWages(info);
    }
    ...
}

Specifically, notice now that TAPayment no longer uses Student, but instead uses TA. Because Student implements TA, we still have the relationship where all students also implement the needed TA behavior. Additionally, ProfessionalTA implements TA, so it can still be used by TAPayment, but cannot be mistaken for a Student by GPACalculator and Transcript.

Now, we no longer have to worry about changes to TAPayment and the TA interface influencing GPACalculator and Transcript.

Note: that the reason I use an interface instead of an abstract class is because I am adding a single behavior to a class that otherwise might have a purpose (like Student). Abstract classes generally imply a strict hierarchy, and each class can only extend one abstract class. However, we can always implement multiple interfaces.

Interface Segregation Principle

A client should never be forced to depend on an interface they do not use

Specifically, a module should only be dependent upon interfaces it needs. In order to ensure this, we should not group unlike behaviors into the same interface. We have actually already demonstrated the Interface Segregation Principle as well. Specifically, we separated the interface TA from the interface of the abstract class Student, as the two interfaces were not related to on another in any meaningful way.

In general, what this means is if we wish to add a new feature to a class that is unrelated to any interfaces it currently extends, we should not add the new feature to the existing interface. For example:

public interface Notifiable {
    public void notify(String message);
}

In this case, Students can be notified, but so can Professors. As such, rather than force this behavior into the existing Student interface, we simply define it as a separate interface and add it to all the classes we want to support that behavior.

public class Professor extends Notifiable {
    ...
    @Override
    public void notify(String message) {
        Email notification = new Email(this.email, message);
        email.send();
    }
}

public abstract class Student extends Notifiable {
    ...
    @Override
    public void notify(String message) {
        ContactMethod method = this.getPreferredContactMethod();
        method.notify(this.contactInformation);
    }
}

And now any class or methods that sends notifications can simply use the Notifiable interface name.

public class EmergencyNotice {
    private UniversityRoster universityRoster;
    
    ...
    
    public notifyAll(String message) {
        List<Notifiable> toBeNotified = new ArrayList<>();
        toBeNotified.addAll(universityRoster.getAllProfessors());
        toBeNotified.addAll(universityRoster.getAllStudents());
        toBeNotified.stream().forEach(x -> x.notify(message));
    }
}

But we do this without polluting or changing the existing unrelated Student abstract class, or trying to force the Student abstract interface on to our Professor class.

Dependency Inversion

Entities should only depend on abstractions, not on concretions.

What this means is that in general, we want our entities to depend on Abstract behaviors, not on concrete implementations, when possible.

For example, rather than:

public class HumanResources {
    public void payUndergradTA(UndergradStudent underGrad) {
        TAPayment payment = new TAPayment();
        double payAmount = payment.calculatePay(underGrad);
        dispersePayment(underGrad, amount);
    }

    public void payUndergradTA(GraduateStudent grad) {
        TAPayment payment = new TAPayment();
        double payAmount = payment.calculatePay(grad);
        dispersePayment(grad, amount);
    }
    ...

    public void payUndergradTA(ProfessionalTA proTA) {
        TAPayment payment = new TAPayment();
        double payAmount = payment.calculatePay(proTA);
        dispersePayment(proTA, amount);
    }
    ...
}

In this case, HumanResources is dependent upon UndergradStudent, GraduateStudent and ProfessionalTA. Because of this dependency, changes to any of those classes could propogate to HumanResources. Instead, we should interact only with the abstraction when possible…

public class HumanResources {
    public void payTA(TA ta) {
        TAPayment payment = new TAPayment();
        double payAmount = payment.calculatePay(ta);
        dispersePayment(ta, amount);
    }
}

The key of the dependency-inversion is that HumanResources no longer is aware of any concrete implementation information. It is only dependent on the interface TA. The advantage of this is that, so long as the interface TA is stable, HumanResources, Student, UndergradStudent, GraduateStudent, and ProfessionalTA can all be independently modified. For example, changes to the implementation of UndergradStudent will not necessitate any changes to any other class provided that UndergradStudent continues to implement the interface TA. Further, any new types of TA we create will not necessitate any changes to HumanResources or TAPayment, so long as it implements the TA interface.

In this way, HumanResources now depends on the interface TA. Additionally, UndergradStudent, GradStudent, and ProfessionalTA are bound to the interface TA, dependent upon implementing it. This is a dependency-inversion, as by simply adding an abstraction between HumanResources and our concrete TA implementors, we have removed the dependency from HumanResources to the concrete implementors completely.


Previous submodule:
Next submodule: