SOLID is an acronym for five Object-Oriented Design Principles.
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.
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:
generateTranscriptDocument
uploadFinalGrade
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.
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.
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.
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, Student
s can be notified, but so can Professor
s. 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.
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.