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.
Let’s go through some of the top design principles of software engineering.
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:
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."
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.
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.
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:
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 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:
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.
According to this principle, we should be able to change the behavior of a class without modifying it.
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.
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.
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 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.
Here are some other software engineering principles to follow in order to create effective, reliable, and high-quality software:
Enjoy learning, Enjoy oops!