The ultimate guide to Software Design Principles - Part 1
Design principles make software flexible to change
What are software design principles?
Software Design Principles are a set of guidelines that helps developers to make a good system design.
On your journey of writing software, design principles act as a beacon to show you the right path which helps you write great software.
You might ask, do we need to follow these guidelines whenever I write code?
Not necessarily, you can write without following any guidelines.
But think about it, about 20 - 40% of the time is consumed writing software and the remaining time is consumed when software goes into maintenance. If your code is not well-written and well-designed then it is a nightmare for the maintenance team to maintain that dirty code. It takes huge effort and resources to maintain a piece of software. Our goal is to reduce efforts and increase productivity. It is only achieved if our code is well-designed. We need to follow some tried and proven methods used by many other experienced developers then we will surely achieve our goal of well-designed, maintainable, flexible, reusable, and extendable software.
So, Let's begin with our first design principle
YAGNI
You Aren't Gonna Need It: don't implement something until it is necessary.
This principle is not telling us to do something in code directly, rather it tells us how to code effectively.
It simply tells us that you should write code of things that are required at this particular moment, and don't write things that you believe to be needed in the later future.
For example: if you were told to validate the user email and password then you shouldn't validate the username, because we may never need it.
KISS
Keep It Simple, Stupid: most systems work best if they are kept simple rather than made complex.
The KISS principle is descriptive to keep the code simple and clear, making it easy to understand. After all, programming languages are for humans to understand, computers can only understand 0 and 1, so keep coding simple and straightforward.
Each method should only solve one small problem, not many use cases. If you have a lot of conditions in the method, break these out into smaller methods. It will not only be easier to read and maintain, but it can help find bugs a lot faster.
Let's see the code that violates this principle
public String weekday(int day) {
switch (day) {
case 1:
return "Monday";
case 2:
return "Tuesday";
case 3:
return "Wednesday";
case 4:
return "Thursday";
case 5:
return "Friday";
case 6:
return "Saturday";
case 7:
return "Sunday";
default:
throw new InvalidOperationException("day must be in range 1 to 7");
}
}
By using the KISS principle, we can make this code more simple and cleaner
public String weekday(int day) {
if ((day < 1) || (day > 7)) throw new InvalidOperationException("day must be in range 1 to 7");
string[] days = {
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday"
};
return days[day - 1];
}
Now, after following the KISS principle, the code becomes easy to read and understand.
TDA: Tell don’t ask
Tell-Don't-Ask is a principle that helps people remember that object orientation is about bundling data with the functions that operate on that data. It reminds us that rather than asking an object for data and acting on that data, we should instead tell an object what to do. This encourages moving behavior into an object to go with the data.
Let's clarify with an example. Let's imagine we need to monitor certain values, signaling an alarm if the value rises above a certain limit. If we write this in an "ask" style, the code would look like this
public class AskMonitor {
private int value;
private int limit;
private boolean isTooHigh;
private String name;
private Alarm alarm;
public AskMonitor (String name, int limit, Alarm alarm) {
this.name = name;
this.limit = limit;
this.alarm = alarm;
}
public int getValue() {return value;}
public void setValue(int arg) {value = arg;}
public int getLimit() {return limit;}
public String getName() {return name;}
public Alarm getAlarm() {return alarm;}
}
AskMonitor am = new AskMonitor("Time Vortex Hocus", 2, alarm);
am.setValue(3);
if (am.getValue() > am.getLimit())
am.getAlarm().warn(am.getName() + " too high");
"Tell Don't Ask" reminds us to instead put the behavior inside the monitor object itself (using the same fields).
public class TellMonitor {
private int value;
private int limit;
private boolean isTooHigh;
private String name;
private Alarm alarm;
public AskMonitor (String name, int limit, Alarm alarm) {
this.name = name;
this.limit = limit;
this.alarm = alarm;
}
public int getValue() {return value;}
public void setValue(int arg) {
value = arg;
if (value > limit) alarm.warn(name + " too high");
}
public int getLimit() {return limit;}
public String getName() {return name;}
public Alarm getAlarm() {return alarm;}
}
TellMonitor tm = new TellMonitor("Time Vortex Hocus", 2, alarm);
tm.setValue(3);
Many people find tell-don't-ask to be a useful principle. One of the fundamental principles of object-oriented design is to combine data and behavior so that the basic elements of our system (objects) combine both together.
Keep things DRY
Don't Repeat Yourself: Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.
This concept is advising us to divide code that does the same thing into small parts.
Sometimes we have multiple lines of code basically doing the same thing: like filtering an array with specified criteria, adding some things to an object, etc.
Usually, good practice is to get rid of it.
Example of not good DRY code is:
public class Week {
private List<Day> days;
public Week() {
this.days = new ArrayList<>();
this.days.add(new Day("Monday", 1));
this.days.add(new Day("Tuesday", 2));
this.days.add(new Day("Wednesday", 3));
this.days.add(new Day("Thursday", 4));
this.days.add(new Day("Friday", 5));
this.days.add(new Day("Saturday", 6));
this.days.add(new Day("Sunday", 7));
}
public void list() {
System.out.println(this.days);
}
private static class Day {
private String name;
private int order;
public Day(String name, int order) {
this.name = name;
this.order = order;
}
@Override
public String toString() {
return "Day{" +
"name='" + name + '\'' +
", order=" + order +
'}';
}
}
public static void main(String[] args) {
Week w = new Week();
w.list();
}
}
In this example, for adding days we are writing the same code multiple times. We can avoid that by creating a method and delegating this task to it. Also by manually typing the day name multiple times, we are making our code prone to error.
Example of proper class with good DRY code:
enum DayNames {
MONDAY("Monday"),
TUESDAY("Tuesday"),
WEDNESDAY("Wednesday"),
THURSDAY("Thursday"),
FRIDAY("Friday"),
SATURDAY("Saturday"),
SUNDAY("Sunday");
private final String name;
DayNames(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
class Day {
private String name;
private int order;
public Day(String name, int order) {
this.name = name;
this.order = order;
}
public Day(String name) {
this(name, 0);
}
public Day setOrder(int order) {
this.order = order;
return this;
}
public String getName() {
return name;
}
public int getOrder() {
return order;
}
}
class Week {
private ArrayList<Day> days = new ArrayList<>();
private Day addDay(String name, int index) {
Day day = new Day(name);
days.add(day);
day.setOrder(index);
return day;
}
public Week() {
int i = 1;
for (DayNames dayName : DayNames.values()) {
addDay(dayName.getName(), i);
i++;
}
}
public void list() {
System.out.println(days);
}
}
Week w = new Week();
w.list();
In this example, instead of typing manually each day, we have implemented a enum
with predefined day names and also introduced Day
class. Thanks to that we can extend it to add more features to this class in the future, like getDaylightTime
. Also, we’ve implemented addDay
a method to Week
the class which is doing pretty much the same thing, but now if anything changes we have to update only one place in the code instead of seven.
Separation of Concerns
Separation of concerns is a design principle for separating a computer program into distinct sections, such that each section addresses a separate concern. For example, the business logic and the user interface of an application are separate concerns. Changing the user interface should not require changes to the business logic and vice versa.
Let's see an example without SoC:
class User {
private String _id = "";
private String name = "";
private double balance = 0;
public User(String name) {
this._id = new Random().nextInt(100000) + "";
this.name = name;
}
public String getId() {
return _id;
}
public String getName() {
return name;
}
public double getBalance() {
return balance;
}
public void setBalance(double balance) {
this.balance = balance;
}
}
class AccountService {
private void log(String msg) {
System.out.println(new Date() + " :: " + msg);
}
public User[] transfer(User user1, User user2, double amount) {
// validate amount
if (amount <= 0) {
log("amount 0, nothing changed.");
return new User[]{user1, user2};
}
// validate if user1 have enough
if ((user1.getBalance() - amount) < 0) {
log("user " + user1.getId() + " did not have enough funds.");
return new User[]{user1, user2};
}
//get from user1
user1.setBalance(user1.getBalance() - amount);
// add to user2
user2.setBalance(user2.getBalance() + amount);
log("User " + user1.getId() + " now has " + user1.getBalance());
log("User " + user2.getId() + " now has " + user2.getBalance());
return new User[]{user1, user2};
}
public User updateBalance(User user, double amount) {
user.setBalance(user.getBalance() + amount);
log("User " + user.getId() + " now has " + user.getBalance());
return user;
}
}
public class Main {
public static void main(String[] args) {
AccountService aService = new AccountService();
User u1 = new User("john");
u1 = aService.updateBalance(u1, 1000);
User u2 = new User("bob");
u2 = aService.updateBalance(u2, 500);
System.out.println(aService.transfer(u1, u2, 250));
}
}
We have AccountService
that is responsible for multiple things: logging, validating, and operating user balance. Also TDA
is not implemented.
We should separate validation and create an external logger to use in the future in other modules.
Let's see the same example with proper SoC implemented:
/**
* VALIDATORS
*/
class StringLengthValidator {
static boolean greaterThan(String value, int length) {
if ( value.length() > length) {
return true;
} else {
throw new Error("String is not long enough.");
}
}
}
class UserBalanceValidator {
static boolean haveEnoughFunds(User user, int amount) {
return (user.getBalance() - amount) > 0;
}
}
/**
* INTERFACES
*/
interface IUserAccount {
String _id;
String name;
int balance;
}
/**
* CLASSES
*/
class User implements IUserAccount {
String _id = "";
String name = "";
int balance = 0;
User(String name) {
this._id = this._generateRandomID();
this.setName(name);
}
private String _generateRandomID() {
return Double.toString(Math.random()).substring(7);
}
String getId() {
return this._id;
}
User setName(String name) {
StringLengthValidator.greaterThan(name, 2);
this.name = name;
return this;
}
int getBalance() {
return this.balance;
}
User setBalance(int amount) {
this.balance = amount;
LoggerService.log("User " + this.getId() + " now has " + this.getBalance() );
return this;
}
}
class LoggerService {
static String log(String message) {
message = (new Date()) + " :: " + message;
System.out.println(message);
return message;
}
}
class AccountService {
Object transfer(User fromUser, User toUser, int amount) {
if (!UserBalanceValidator.haveEnoughFunds(fromUser, amount)) {
LoggerService.log("User " + fromUser.getId() + " has not enough funds.");
return new Object[]{fromUser, toUser};
}
fromUser.setBalance(fromUser.getBalance() - amount);
toUser.setBalance(toUser.getBalance() + amount);
return new Object[]{fromUser, toUser};
}
User updateBalance(User user, int amount) {
user.setBalance(user.getBalance() + amount);
return user;
}
}
class Main {
public static void main(String[] args) {
AccountService aService = new AccountService();
User u1 = new User("john");
User u2 = new User("bob");
u1 = aService.updateBalance(u1, 1000);
u2 = aService.updateBalance(u2, 500);
System.out.println( aService.transfer(u1, u2, 250) );
}
}
Programming to an Interface
This design principle guides us to make use of abstract types, not concrete ones. “Program to interfaces” actually means a program to a supertype like an interface or abstract class in Java. We are implementing polymorphism by programming to a supertype so that the actual runtime object isn’t locked into your code.
Let's see an example with implementation using a concrete class:
public class Monitor {
public void display () {
System.out.println("Display through Monitor");
}
}
public class Computer {
private Monitor monitor;
Computer() {
this.monitor = new Monitor();
}
public void display () {
this.monitor.display();
}
}
Computer comp = new Computer();
comp.display(); // o/p: Display through Monitor
Here our computer class has a monitor attached to it, which is acting as an output device. In a perfect world, everything looks absolutely fine and works well. But, the only constant in the real world is CHANGE. What if tomorrow we want to attach a projector that acts as a display unit for the computer? I have to change the entire implementation because our computer is tightly coupled with the monitor. To make the computer loosely coupled with the monitor, we need to program to an interface rather than with concrete implementation.
Let's see an example with implementation using an interface:
interface DisplayModule {
public void display();
}
public class Monitor implements DisplayModule {
public void display () {
System.out.println("Display through Monitor");
}
}
public class Projector implements DisplayModule {
public void display () {
System.out.println("Display through projector");
}
}
public class Computer {
private DisplayModule dm;
public void setDisplayModule(DisplayModule dm)
{
this.dm = dm;
}
public void display () {
dm.display();
}
}
Computer comp = new Computer();
DisplayModule dm1 = new Monitor();
DisplayModule dm2 = new Projector();
comp.setDisplayModule(dm);
comp.display();
comp.setDisplayModule(dm1);
comp.display();
So here we have created an interface called displayModule, and all display equipment must implement that interface and provide its own implementation of the display operation.
In the computer class, we have created a has-A relation (composition) called displayModule
so that the display module can change frequently as per our needs,
Remember to always code through an interface so you can change your strategy at runtime with actual implementation. Figure out the variable parts of your problem statement and make it abstract so we can change the strategy further.
Favor composition over inheritance
In some situations inheritance creates serious issues, it can go out of control. This further led to a class explosion, where we end up creating a hell lot of classes.
Let's assume you own a pizza shop. You want to write a system to manage all your pizzas. So, you start out with a base class:
abstract class Pizza { }
Now, of course, customers are asking for tomato sauce, and you end up with
class TomatoSaucePizza extends Pizza {}
And of course, customers need cheese
class TomatoSauceCheezePizza extends TomatoSaucePizza {}
That’s great. But your customers are asking for more ingredients. Let’s start with pepperoni and mushrooms. We’ll end up with:
class TomatoSauceCheezePepperoniPizza extends TomatoSauceCheezePizza {}
class TomatoSauceCheezeMushroomPizza extends TomatoSauceCheezePizza {}
And, of course, some customers may want both, so you’ll need:
class TomatoSauceCheezePepperoniMushroomPizza extends TomatoSauceCheezePizza {}
Now, let's add in sausages onions, anchovies, and black olives.
Hmm. This is going to get complicated really fast, isn’t it?
Using inheritance, you are going to get a rather deep and wide inheritance model really quickly. Your properties aren’t going to cover all the possible situations that happen as more ingredients are added.
But there is a better way. How about we “compose” a pizza instead:
class Pizza {
private SouceType souce;
private CheezeType cheeze;
private List<Ingredient> ingredients;
public Pizza(SouceType souce, CheezeType cheeze, List<Ingredient> ingredients){
this.souce = souce;
this.cheeze = cheeze;
this.ingredients = ingredients;
}
public SouceType getSouce () {
return this.souce;
}
public CheezeType cheeze () {
return this.cheeze;
}
public List<Ingredient> ingredients () {
return this.ingredients;
}
}
That is a perfect example of why you should prefer composition over inheritance.
The composition version of our pizza is simple. It is more flexible, allowing for multiple cheese types, sauce types, and a practically infinite collection of ingredients.
It’s one class that does the job of the entire inheritance structure that we created above. Composition allows you to do in that one class what might take 2^n classes via inheritance. It also provides a vastly simpler, more testable codebase.
Encapsulate what varies
Considered as the foundational design principle, this principle is found at work in almost all design patterns.
This principle suggests, Identifying the aspects of your applications that vary and separating them from what stays the same. If a component or module in your application is bound to change frequently, then it’s a good practice to separate this part of the code from the stable ones so that later we can extend or alter the part that varies without affecting those that don’t vary.
Most design patterns like Strategy, Adapter, Facade, Decorator, Observer, etc follow this principle.
For example, Assume we are designing an app for a company that provides online service for household equipment. In our applications core we have this method processServiceRequest()
, the purpose of which is to create an instance of an OnlineService class based on the serviceType and process the request. This is a heavy-duty method as shown below
public void processServiceRequest(String serviceType) {
OnlineService service;
if(service.equals("AirConditioner"))
service = new ACService();
else if(service.equals("WashingMachine"))
service = new WMService();
else if(service.equals("Refrigerator"))
service = new RFService();
else
service = new GeneralService();
service.getinfo();
service.assignServiceRequest();
service.assignAgent();
service.processRequest();
}
Here, the type of service is a functionality that is bound to change at any time. We might remove some services or add new and every such change in implementations would require to change this piece of code.
So, by the guidelines of “Encapsulate what varies” we need to find the code which is bound to vary and separate it so that any error in the same would not affect the important piece of code.
We can remove the code that creates instances and create a class that would work as a factory class that is only there to provide the required type of instances. We are going to follow the Factory design pattern here and we will be having only one method getOnlineService()
in this class which would do our job
public class OnlineServiceFactory {
public OnlineService getOnlineService(String type) {
OnlineService service;
if(service.equals("AirConditioner"))
service = new ACService();
else if(service.equals("WashingMachine"))
service = new WMService();
else if(service.equals("Refrigerator"))
service = new RFService();
else
service = new GeneralService();
return service;
}
}
now we can refactor our previous code as
public void processServiceRequest(String serviceType) {
OnlineService service = new OnlineServiceFactory().getOnlineService(serviceType);
service.getinfo();
service.assignServiceRequest();
service.assignAgent();
service.processRequest();
}
Command Query Separation - CQS
CQS is a design principle devised by Bertrand Meyer, defined as :
A method is either a COMMAND that performs an action or a QUERY that returns data to the caller, but never both.
Queries → are used to return a result to the caller and don’t change the state of the system (are free of side effects)
Commands → Change the state of a system but do not return a value, or simply used it to perform an action
The precious idea in this principle is that it's convenient if you can clearly separate methods that change state from those that don't. This is because you can use queries in many situations more confidently, introducing them anywhere and changing their order. On another side, you have to be more careful with commands.
Now let’s take an example of CRUD Operations, CRUD is an acronym for CREATE
, READ
, UPDATE
and DELETE
. When using CQS, Create Update and Delete are considered to command since they mutate state, whereas Read is considered to be a query since it will never mutate state.
so let’s take this example below which violates the CQS principle.
public void saveUser(String name, int age) {
if (name == null || name.equals("")) {
throw new Error("Name is Invalid");
}
if (age < 15 || age > 95) {
throw new Error("Age is Invalid");
}
this.userRepository.save(new User(name, age));
}
We can simplify this code by breaking it into commands and queries:
Query:
public boolean validateUser(String name, int age) throws Exception {
boolean isUserValid = true;
if (name == null || name.equals("")) {
isUserValid = false;
throw new Exception("Name is Invalid");
}
if (age < 15 || age > 95) {
isUserValid = false;
throw new Exception("Age is Invalid");
}
return isUserValid;
}
Command:
public void createUser(String name, int age) {
this.userRepository.save(new User(name, age));
}
By doing so, we can reuse and test this method separately or compose them.
The Law of Demeter
This principle states that an object must not have knowledge of the internal details of the objects it manipulates, in concrete terms this means Don’t talk to strangers
The principle of Demeter's law (or principle of least knowledge) reduces dependencies and helps build components that are coupled for code reuse, easier maintenance and simpler testability.
The point of this principle (or law) is that each object should have only limited knowledge of other objects closely related to it.
Let's take the case of 3 entities in a system, an Employee
, a Company
and an Address
.
Employee A
belongs to a company B
and the company has an address C
. But for some reason, you want to retrieve the postcode of the company to which this employee belongs.
The following steps will show you how to define our classes with associated properties and methods, in order to implement Demeter's Law to retrieve the postcode of the company to which the employee is attached.
public class Employee {
private final String name;
private final Enterprise enterprise;
public Employee(String name, Enterprise enterprise) {
this.name = name;
this.enterprise = enterprise;
}
public String getName() {
return name;
}
public Enterprise getEnterprise() {
return enterprise;
}
public int getEnterprisePostalCode() {
return this.enterprise.getAddressPostalCode();
}
}
public class Enterprise {
private final int employeeNumber;
private final String domain;
private final Address address;
public Enterprise(int employeeNumber, String domain, Address address) {
this.employeeNumber = employeeNumber;
this.domain = domain;
this.address = address;
}
public int getEmployeeNumber() {
return employeeNumbers;
}
public String getDomain() {
return domain;
}
public Address getAddress() {
return address;
}
public int getAddressPostalCode() {
return this.address.getPostalCode();
}
}
public class Address {
private final String street;
private final int postalCode;
private final String city;
public Address(String street, int postalCode, String city) {
this.street = street;
this.postalCode = postalCode;
this.city = city;
}
public String getStreet() {
return street;
}
public int getPostalCode() {
return postalCode;
}
public String getCity() {
return city;
}
}
Once we have defined our classes, and we wish to retrieve the postcode of the company to which the employee belongs, we can apply the principle of Demeter's law as follows
public class Main {
public static void main(String[] args) {
final Address address = new Address("15 Rue des paresseux", 78400, "Paris");
final Enterprise enterprise = new Enterprise(200, "Technologies", address);
final Employee employee = new Employee("Roger Klunt", enterprise);
// Violate the Law of Demeter 🔥
System.out.println(
employee.getEnterprise().getAddress().getPostalCode()
);
// Don't violate the Law of Demeter 💖
System.out.println(employee.getEnterprisePostalCode());
}
}
Credits / References:
https://medium.com/@derodu/design-patterns-kiss-dry-tda-yagni-soc-828c112b89ee
https://dzone.com/articles/software-design-principles-dry-and-kiss
https://betterprogramming.pub/prefer-composition-over-inheritance-1602d5149ea1
https://bootcamp.uxdesign.cc/software-design-principles-every-developers-should-know-23d24735518e
Part 2: iamazizbohra.hashnode.dev/the-ultimate-guid..
Thanks for reading
I really hope you enjoyed reading it
Follow me: https://www.linkedin.com/in/imazizbohra/