Beginner's Guide to Java: OOD | SrishCodes

Beginner's Guide to Java: OOD | SrishCodes

Hi everyone, welcome back to SrishCodes where I teach you interesting tech concepts that you should know about as a current or aspiring software developer. Now you may have heard of Object Oriented Programming (OOP). Today I will be sharing with you about the SOLID concepts of Object Oriented Design (OOD) in Java. Basic understanding of OOP will be useful for this article. I strongly believe this is a topic every developer should learn and follow.

SOLID Principles

The SOLID principles are a fundamental set of concepts that ensure that OOP is executed correctly. SOLID was initially promoted as a set of related principles by Robert C. Martin in this paper in 2000. These concepts were later built upon by Michael Feathers, who introduced us to the SOLID acronym. They have since become a standard in the field of software engineering, with considerations for maintaining and extending as the project grows.

The SOLID principles of OOP are:

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

Understanding, and more importantly following these principles ensure that OOP applications are readable, testable, scalable, and maintainable. This article focuses on introducing each principle and explaining how using SOLID principles in your code can make you a better developer.

Single Responsibility Principle

Single-responsibility Principle (SRP) states:

A class should have one and only one reason to change, meaning that a class should have only one responsibility.

This means that only one potential change in the software should be able to affect the class. Some benefits of following this principle include:

  • Testing - A class with one responsibility will have much lesser test cases
  • Lower coupling - Less functionality in a single class will have fewer dependencies
  • Organization - Smaller, well-organized classes can be organized in a more logical manner compared to monolithic ones

Let's go through an example:

public class Book {

    private String name;
    private String author;
    private String text;

    //constructor, getters and setters

    // methods that directly relate to the book properties
    public String replaceWordInText(String word){
        return text.replaceAll(word, text);
    }

    public boolean isWordInText(String word){
        return text.contains(word);
    }

    void printTextToConsole(){
        // our code for formatting and printing the text
    }
}

However, the last method printTextToConsole() violates the single responsibility principle we outlined earlier. Instead, we should implement a separate class that deals only with printing our texts:

public class BookPrinter {

    // methods for outputting text
    void printTextToConsole(String text){
        //our code for formatting and printing the text
    }

    void printTextToAnotherMedium(String text){
        // code for writing to any other location..
    }
}

Now we can use both classes for a single responsibility each and organize and test our code in a more logical manner. We can even create a Printer interface that is implemented by BookPrinter for even better code quality.

Of course this does not mean that we should write only function in a class. In fact, doing this also makes the code very confusing and unreadable. Create logical classes that make code easy to understand and expand in future. The only way to achieve this is to practice regularly and learn from experienced developers.

Open Closed Principle

Open-closed Principle (OCP) states:

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. Of course, the one exception to the rule is when fixing bugs in existing code. Hence, coding to an interface is an integral part of SOLID. Let's use an example:

public class Guitar {

    private String make;
    private String model;
    private int volume;

    //Constructors, getters & setters
}

If we now want to add fireworks to our guitar, following this principle we should extend the Guitar class as follows:

public class GuitarWithFireworks extends Guitar {

    private String fireworksColor;

    //constructor, getters & setters
}

This way, we can ensure that our existing application won't be affected.

Liskov Substitution Principle

Liskov Substitution Principle (LSP) is named after the name of Barbara Liskov. She introduced this principle in 1987. It is a particular definition of subtyping relation, called behavioral subtyping.

I find this principle the most complicated one. So here is my attempt to explain it. Liskov Substitution Principle states:

If class A is a subtype of class B, we should be able to replace B with A without disrupting the behavior of our program.

This means that every subclass or derived class should be substitutable for their base or parent class. That was a lot of words. Let's learn this using an example:

public interface Car {

    void turnOnEngine();
    void accelerate();
}

Now let's introduce classes that implement this interface:

public class MotorCar implements Car {

    private Engine engine;

    //Constructors, getters + setters

    public void turnOnEngine() {
        //turn on the engine!
        engine.on();
    }

    public void accelerate() {
        //move forward!
        engine.powerOn(1000);
    }
}
public class ElectricCar implements Car {

    public void turnOnEngine() {
        throw new AssertionError("I don't have an engine!");
    }

    public void accelerate() {
        //this acceleration is crazy!
    }
}

This might seem like it looks correct, but in the second example the ElectricCar class does not have an engine. This means that we are changing the behaviour of the program. Not having an engine is a violation of Liskov Substitution Principle, hence we need to change the interface to take into account the possibility of the Car not having an engine at all.

Following the LSP allows easy extension of program behavior because subclasses can be inserting into working code without causing undesired outcomes. If you look closely, this principle is an extension of the Open Closed Principle and it means that we must make sure that new derived classes are extending the base classes without changing their behavior.

The following are the conditions to avoid LSP violation.

  • Method signatures must match and accept equal no of the parameter as the base type
  • The return type of the method should match to a base type
  • Exception types must match to a base class

Interface Segregation Principle

Interface segregation principle states:

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.

In simple terms, this means that larger interfaces should be broken up into smaller ones and hence many client-specific interfaces are better than one general-purpose interface. Once again, let's go through an example:

public interface BearKeeper {
    void washTheBear();
    void feedTheBear();
    void petTheBear();
}

This interface looks okay but the interface is too large, and can be split into three separate interfaces as shown below:

public interface BearCleaner {
    void washTheBear();
}

public interface BearFeeder {
    void feedTheBear();
}

public interface BearPetter {
    void petTheBear();
}

We can then implement two separate classes that implement these interfaces:

public class BearCarer implements BearCleaner, BearFeeder {

    public void washTheBear() {
        //I think we missed a spot...
    }

    public void feedTheBear() {
        //Tuna Tuesdays...
    }
}

public class CrazyPerson implements BearPetter {

    public void petTheBear() {
        //Good luck with that!
    }
}

Dependency Inversion Principle

Dependency inversion principle states:

Entities must depend on abstractions, not on concretions. It states that the high-level module must not depend on the low-level module, but they should depend on abstractions.

This principle allows for decoupling, as shown in the example below:

public class Windows98Machine {

    private final StandardKeyboard keyboard;
    private final Monitor monitor;

    public Windows98Machine() {
        monitor = new Monitor();
        keyboard = new StandardKeyboard();
    }

}

However, by declaring the StandardKeyboard and Monitor with the new keyword, we've tightly coupled these three classes together. Instead, here is a better way to handle this:

public interface Keyboard { }

public class Windows98Machine{

    private final Keyboard keyboard;
    private final Monitor monitor;

    public Windows98Machine(Keyboard keyboard, Monitor monitor) {
        this.keyboard = keyboard;
        this.monitor = monitor;
    }
}

public class StandardKeyboard implements Keyboard { }

Now our classes are decoupled and communicate through the Keyboard abstraction. We can follow the same principle for the Monitor class.


And that was all for this now! Make sure to leave a comment and follow me for more content. Until next time

Examples courtesy of Baeldung.