Popular Principles of Software Engineering

What is Software Engineering?

Software engineering is a set of principles, procedures, and methods to analyze user requirements and develop effective, reliable, and high-quality software. It is a set of best practices introduced by some famous industry experts that programmers should follow during software development. On another side, every development team needs to deal with several issues to write bug-free, readable, and maintainable code. So programmers need to follow software engineering principles to design large-scale software.

Advantages of software engineering principles

  • Reduces complexity of the development process.
  • Help software teams in avoiding critical errors and mistakes.
  • Help to achieve development goals efficiently.
  • Increase quality and productivity of the development.
  • Help large-scale teams to work in an organized manner.

Let’s go through some of the top design principles of software engineering.

Law of Demeter (The Principle of Least Knowledge)

According to the Clean Code book by Robert Martin, The law of demeter says that a method f of class C should only call: 1) Methods of class C 2) Methods of an object created by f 3) Methods of an object passed as an argument of f 4) Methods of an object held in an instance variable of C. The idea here is simple: Talk to friends not to strangers!

According to this principle, it's important to divide responsibilities among classes and encapsulate logic within a class or method. There are few key recommendations to follow in order to achieve this:

  • Keep software entities independent of each other.
  • Minimize coupling between different classes.
  • Achieve cohesion by grouping related classes in the same package or module.

The law of Demeter promotes independence and reduces interdependence among classes. Adhering to this principle makes our application maintainable, understandable and flexible. According to Robert Martin: "A module should not know about the innards of the objects it manipulates. In other words, objects should hide their data and only expose operations. This means that an object should not reveal its internal structure through accessors, but rather keep it hidden."

Avoid Premature Optimization

Optimization is necessary to build faster applications and reduce the consumption of system resources. But everything has its own time. If we do optimization at the early stages of development, it may do more harm than good. The idea is simple: developing the optimized code requires more time and effort. Even we need to verify the code's correctness constantly. So it is better to use simple but not the most optimal method in the first place. Later, we can estimate the method's performance and decide to design a faster or less resource-intensive algorithm.

Let's understand it from another perspective. While it's true that optimization can streamline development and reduce resource consumption, it's important to think about the potential drawbacks. Suppose we start off by implementing the most efficient algorithm, but then our requirements change. In this case, all of our efforts to create an efficient solution would be wasted, and the program would become difficult to modify. This is why it's often better to avoid premature optimization and focus on other priorities first. By doing so, we can ensure that our code is flexible and adaptable to changing needs.

Keep it Simple, Stupid (KISS principle)

The KISS principle (Keep It Simple, Stupid) originated in the 1960s when the U.S. Navy made a valuable observation about the way systems function. They noticed that complex systems tend to perform poorly, while simple systems tend to work well. This is because complexity can lead to a poor understanding of the system and an increase in bugs. By following the KISS principle, we can design systems that are easier to understand and more reliable.

The idea of the KISS principle: Software code should be easy to understand and flexible when it comes to modifying or adding new features. In other words, we should aim to avoid unnecessary complexity in our software development. This might seem like a no-brainer, but it's easy to get carried away with using fancy features and end up creating a lot of dependencies.

To avoid this, it's important to consider the usefulness of any new dependencies, frameworks, or features before implementing them. Additionally, our methods should be small and focused on solving a single problem. If there are many conditions, try breaking them down into smaller blocks of code. This helps to keep our code clean and reduces the likelihood of bugs. In simple words: simple code is easier to debug and maintain.

Don’t Repeat Yourself (DRY Principle)

The DRY principle (Don't Repeat Yourself) emphasizes the importance of avoiding redundant code. This helps to promote code reuse and makes it more maintainable, extensible, and less buggy. The DRY principle originated in the book "The Pragmatic Programmer" by Andy Hunt and Dave Thomas.

From another perspective, the DRY principle helps us avoid a common maintenance and modification challenge. When the same code appears in multiple places, making even a small change requires updating the code in all of those locations. If we miss one of these updates, it can lead to errors that require extra time and effort to fix. By following the DRY principle, we can avoid this potential pitfall and make our code more efficient and reliable. The recommended solution would be:

  • Avoid copy-pasting code in different places and try to reuse code whenever possible. If a code block appears more than twice, consider moving it to a separate method.
  • Have a single reference point or source of truth for each piece of data. This way, if we need to change any part of the data, we only need to make the change in one place instead of multiple locations.

You Aren't Gonna Need It (YAGNI Principle)

One of the challenges in software development is that we may think we'll need certain functionality in the future, but then our requirements change and that functionality becomes unnecessary. To avoid this problem, we can follow the YAGNI (You Ain't Gonna Need It) principle.

This principle advises us to only implement things when we actually need them, rather than adding functionality to solve potential future problems. By following YAGNI, we can avoid unnecessary complexity and stay focused on the current needs of the project. YAGNI is a core principle of the Extreme Programming (XP) software development methodology.

SOLID Principles

SOLID is a group of object-oriented design principles, where each letter in the acronym “SOLID” represents one of the principles. When applied together, these principles help developers create code that is easy to maintain and extend over time.

It consists of design principles that first appeared in Robert C. Martin’s 2000 paper entitled "Design Principles and Design Patterns". Let’s go through each SOLID principles one by one:

Single Responsibility Principle

The Single Responsibility Principle (SRP) states that each class or method should have a clear and well-defined responsibility, and that responsibility should be fully encapsulated by the class or method. This means that a class or method should have only one job and only one reason to change, so that if any part of the application needs to be modified, it will only affect one class or method. By following the SRP, we can create more modular and maintainable software systems.

Designing methods or classes with a single responsibility makes our code easier to understand, maintain, and modify. When we need to make changes to a particular functionality, we know exactly where to go in the code. Additionally, following the Single Responsibility Principle (SRP) can improve code organization and readability, and increase code reuse. By keeping our functions and classes short and focused, we can more easily reuse them in other parts of the codebase.

Open-Closed Principle

According to this principle, we should be able to change the behavior of a class without modifying it.

  • Open for an extension: We should add new features to the classes/modules without changing the existing code.
  • Closed for modification: Once the existing code is working, we shouldn’t change the existing code to add functionality or features.

From another perspective, the Open/Closed Principle (OCP) helps us avoid disrupting existing functionality when we need to add new features to our software. When we're in the process of building and testing new functionality, the last thing we want is to make changes to existing functionality that is already working well. Instead, the OCP encourages us to build new functionality on top of the existing functionality, rather than modifying it. This helps to maintain the integrity of the codebase and makes it easier to extend the software over time.

Liskov Substitution Principle

The Liskov Substitution Principle (LSP) was introduced by Barbara Liskov in a 1988 conference keynote address. It states that derived classes should be able to be replaced by their base class without affecting the correctness of the program. In other words, objects of a parent class should be interchangeable with objects of a child class.

To follow the LSP, we should make sure that an inherited class complements rather than replaces the behavior of the base class. This way, we can substitute the child class for the parent class and expect the same basic behavior.

Example Java Code

abstract class Shape {
    abstract double getArea();
}

class Rectangle extends Shape {
    private double width;
    private double height;

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

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

    public double getWidth() {
        return width;
    }

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

    public double getHeight() {
        return height;
    }

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

class Square extends Rectangle {
    public Square(double side) {
        super(side, side);
    }

    @Override
    public void setWidth(double width) {
        super.setWidth(width);
        super.setHeight(width);
    }

    @Override
    public void setHeight(double height) {
        super.setHeight(height);
        super.setWidth(height);
    }
}

public class Main {
    public static void main(String[] args) {
        Shape rect = new Rectangle(10, 20);
        System.out.println("Area of rectangle: " + rect.getArea());
    
        Shape square1 = new Square(10);
        System.out.println("Area of square: " + square1.getArea());
        
        Rectangle square2 = new Square(15);
        System.out.println("Area of square: " + square2.getArea());
    }
}

In the above example, implementation follows the Liskov Substitution Principle. The idea is simple: Square class extends the Rectangle class, which complements its behaviour, rather than replacing it. So when we create an instance of a Square class and assign it to a Rectangle variable, the program behaves as expected. In other words, we can use the Square object interchangeably with a Rectangle object. Similarly, we can use a Rectangle class object interchangeably with a Shape class object.

Interface Segregation Principle

The Interface Segregation Principle (ISP) was first defined by Robert C. Martin while consulting for Xerox. Xerox had developed a printer software that included various tasks such as stapling and faxing. However, as the software grew, it became increasingly difficult to make changes, and even small modifications would take an hour to deploy.

The issue was that almost all tasks used a single Job class, which became very large and included specific methods for various clients. This meant that a staple job would have knowledge of all the methods of the print job, even though it didn't need them. To solve this problem, the ISP suggests that clients should not be forced to depend on interfaces they do not use.

The suggested solution by Martin is called the Interface Segregation Principle. Instead of having one large Job class, a Staple Job interface or a Print Job interface was created that would be used by the Staple or Print classes, respectively. Therefore, one interface was designed for each job type, which was all implemented by the Job class.

So the Interface Segregation Principle states that a client should never be forced to depend on methods it does not use. We achieve this by making our interfaces small and focused. It would be best to split large interfaces into more specific ones focused on a particular set of functionalities so that the clients can choose to depend only on the functionalities they need.

Example Java Code

interface Document {
    String getContent();
    void setContent(String content);
}

interface PrintableDocument extends Document {
    void print();
}

interface SaveableDocument extends Document {
    void save();
}

class TextDocument implements PrintableDocument, SaveableDocument {
    private String content;

    @Override
    public String getContent() {
        return content;
    }

    @Override
    public void setContent(String content) {
        this.content = content;
    }

    @Override
    public void print() {
        System.out.println("Printing document: " + content);
    }

    @Override
    public void save() {
        System.out.println("Saving document: " + content);
    }
}

class Client {
    public void usePrintableDocument(PrintableDocument document) {
        document.setContent("This is a printable document");
        document.print();
    }

    public void useSaveableDocument(SaveableDocument document) {
        document.setContent("This is a saveable document");
        document.save();
    }
}

public class Main {
    public static void main(String[] args) {
        Client client = new Client();
        TextDocument textDocument = new TextDocument();

        client.usePrintableDocument(textDocument);
        client.useSaveableDocument(textDocument);
    }
}

In the above example, we have defined a base Document interface, and two specialized interfaces PrintableDocument and SaveableDocument, which extend the Document interface. The TextDocument class implements both PrintableDocument and SaveableDocument, so it has both the print and save functionalities.

Here Client class uses the usePrintableDocument method to print a document, and the useSaveableDocument method to save a document. By using specialized interfaces, the Client class can choose to depend only on the functionalities it needs, without being forced to depend on methods it does not use.

Dependency Inversion Principle

Dependency inversion says that high-level modules should not depend on low-level modules but only on their abstractions. The interaction between the two modules should be thought of as an abstract interaction between them, not a concrete one. In simple words, It suggests that we should use interfaces instead of concrete implementations wherever possible.

interface EmailSender {
    void sendEmail(String to, String subject, String message);
}

class GmailSender implements EmailSender {
    @Override
    public void sendEmail(String to, String subject, String message) {
        // Code to send email using Gmail
    }
}

class YahooSender implements EmailSender {
    @Override
    public void sendEmail(String to, String subject, String message) {
        // Code to send email using Yahoo
    }
}

class UserService {
    private EmailSender emailSender;
    
    public UserService(EmailSender emailSender) {
        this.emailSender = emailSender;
    }
    
    public void sendWelcomeEmail(String to) {
        String subject = "Welcome to enjoyalgorithms";
        String message = "We are glad to have you as a part of our community.";
        emailSender.sendEmail(to, subject, message);
    }
}

public class Main {
    public static void main(String[] args) {
        EmailSender emailSender = new GmailSender();
        UserService service = new UserService(emailSender);
        service.sendWelcomeEmail("contact@enjoyalgorithms.com");
    }
}

In the above example, the UserService class depends on an abstraction (EmailSender interface) rather than on a concrete implementation (GmailSender or YahooSender). In the Main class, we have used UserService class with different implementations of the EmailSender interface, which provide greater flexibility and adaptability to changing requirements.

So, what is the reason behind this principle? The answer is simple: abstractions don’t change a lot. Therefore, we can easily change the behavior of our closed or open-source code and boost its future evolution.

  • It also allows programmers to work smoothly at the interface level, not the implementation level.
  • This decouples a module from the implementation details of its dependencies. The module only knows about the behavior it depends on, not how it is implemented. This allows you to change the implementation whenever possible without affecting the module itself.

Other best practices of software engineering

Here are some other software engineering principles to follow in order to create effective, reliable, and high-quality software:

  • Measure twice and cut once: Good project planning is crucial to producing a better result in software development. Before building functionalities, make sure to choose the right problem, solution approach, tools, and team, and define appropriate metrics to measure.
  • Consistency is key: Following a consistent coding style helps improve efficiency in understanding and reading the code. Remember: complex code might look better, readable code is always better!
  • Keep it general: Design your software to be free from unnatural restrictions and limitations, so that it can serve a wide range of customer needs.
  • Utilize open source options: There are many open-source options available, so it's important to avoid reinventing the wheel and wasting time building code that has already been written.
  • Stay up to date: To meet current technology trends and user requirements in the most advanced way, it's important to follow modern programming practices.
  • Understand your requirements: A well-defined requirement analysis process is essential for understanding user requirements and creating good software.
  • Define a project vision: Maintaining the project's vision throughout the development process is critical for success.
  • Document your work: Providing good documentation for each step of development helps other developers understand your code and avoids surprises or wasted time.
  • Use sensible logging: Make sure to include a way of logging or tracing code execution with different log levels (e.g., informational, warning, error).

Enjoy learning, Enjoy oops!

More from EnjoyAlgorithms

Self-paced Courses and Blogs