Design Principles Every Software Developer Should Know
(13 min read)Design paterns keep our code bases maintainable and understandable. This post explains few design patterns that every software developer should know.
-
Dry (Don't Repeat Yourself)
Avoid code duplication using this pattern.
A bad example
function addTodo(name: string, isCompleted: boolean) {
// code to add todo
console.log(`Adding todo - name: ${name} | isCompleted: ${isCompleted}`);
}
function deleteTodo(id: number) {
// code to delete todo
console.log(`Deleting todo - id: ${id}`);
}
In this example, we are logging the actions in the console which is duplicated. If we decide to use other library for logging, then we have to change a lot of code which is not a good practice.
We will use an example of a Todo application to explain these patterns.
A good example
function todoLogger(action: string) {
console.log(`Action: ${action}`);
}
function addTodo(name: string, isCompleted: boolean) {
// code to add todo
todoLogger(`Adding todo - name: ${name} | isCompleted: ${isCompleted}`);
}
function deleteTodo(id: number) {
// code to delete todo
todoLogger(`Deleting todo - id: ${id}`);
}
Here we are using the todoLogger
function to log the actions. If we decide to use other library for logging, then we have to only change the todoLogger
function.
-
KISS (Keep It Simple, Stupid)
This principle is used to keep our code simple and easily understandable.
A bad example
function toggleTodoStatus(todo: {
id: number;
name: string;
isCompleted: boolean;
}) {
if (todo.isCompleted) {
todo.isCompleted = false;
} else {
todo.isCompleted = true;
}
}
Here, we are toggling the status of the todo using if-else. There are two branches in the if-else which becomes very lengthy.
A good example
function toggleTodoStatus(todo: {
id: number;
name: string;
isCompleted: boolean;
}) {
todo.isCompleted = !todo.isCompleted;
}
Here, we are toggling the status of the todo using ternary operator which is more readable.
-
YAGNI (You Ain't Gonna Need It)
This principle is used to avoid unnecessary code. You should not add codes that you think you might need in the future because in most of the cases you don't need it.
For e.g. you might think you need feature to track the status of the todo. But in most of the cases you don't need it. When you need it, you can always write it at that moment.
A bad example
// not required at this moment
function trackTodoStatus(todo: {
id: number;
name: string;
isCompleted: boolean;
}) {
// code to track the status of the todo
}
function toggleTodoStatus(todo: {
id: number;
name: string;
isCompleted: boolean;
}) {
// code to toggle the status of the todo
trackTodoStatus(todo);
}
A good example
function toggleTodoStatus(todo: {
id: number;
name: string;
isCompleted: boolean;
}) {
// code to toggle the status of the todo
// have removed the tracking code
}
-
Solid Principles
These principles are mostly used in Object Oriented Programming.
- Single Responsibility Principle
In this principle, a class should have only one reason to change.
A bad example
type Todo = {
id: number;
name: string;
isCompleted: boolean;
};
class TodoService {
todos: Todo[] = [];
addTodo(name: string, isCompleted: boolean) {
// code to add todo
}
deleteTodo(id: number) {
// code to delete todo
}
renderTodos() {
// code to render todos
}
// ... other methods
}
Here TodoService
class has two reasons to change - one when we need to add methods related to data persistence and other when need to change the rendering logic.
A good example
type Todo = {
id: number;
name: string;
isCompleted: boolean;
};
class TodoService {
todos: Todo[] = [];
addTodo(name: string, isCompleted: boolean) {
// code to add todo
}
deleteTodo(id: number) {
// code to delete todo
}
// ... other methods
}
class TodoRenderer {
todos: Todo[] = [];
renderTodos() {
// code to render todos
}
// ... other methods
}
- Open/Closed Principle
In this principle, a class should be open for extension but closed for modifications.
For e.g. if we have new requirement to add a priority status to our todos, we can do as below.
A bad example
class Todo {
id: number;
name: string;
isCompleted: boolean;
priority: string;
constructor(id: number, name: string, isCompleted: boolean) {
this.id = id;
this.name = name;
this.isCompleted = isCompleted;
}
// have added new feature directly in the `Todo` class
setPriority(priority: string) {
this.priority = priority;
}
}
What we have done is we added a priority field in our existing Todo
class. This violates the Open/Closed Principle.
A good example
class TodoBase {
constructor(
public id: number,
public name: string,
public isCompleted: boolean
) {}
// ... other methods
}
class PriorityTodo extends TodoBase {
constructor(
public id: number,
public name: string,
public isCompleted: boolean,
public priority: string
) {
super(id, name, isCompleted);
}
}
Here, we have created a new class PriorityTodo
which extends the TodoBase
class. This is a good example of the Open/Closed Principle.
- Liskov Substitution Principle
We should be able to substitute a base class with its subclass.
For e.g. in open/closed principle, we can replace the Todo
class with PriorityTodo
class without breaking our application.
// ... codes from open/closed principle
const todos: TodoBase[] = [
new Todo(1, "Todo 1", false),
new PriorityTodo(2, "Todo 2", false, "High"),
];
- Interface Segregation Principle
A class should not depend on interfaces that it does not use.
A bad example
interface TodoManager {
addTodo: (name: string, isCompleted: boolean) => void;
deleteTodo: (id: number) => void;
renderTodos: () => void;
}
class TodoService implements TodoManager {
addTodo(name: string, isCompleted: boolean) {
// code to add todo
}
deleteTodo(id: number) {
// code to delete todo
}
renderTodos() {
// code to render todos
}
}
Here, we have created a class TodoService
which implements TodoManager
interface. The interface TodoManager
contains two types of methods - one related to persistence and other related to rendering. This violates the Interface Segregation Principle.
A good example
interface TodoManager {
addTodo: (name: string, isCompleted: boolean) => void;
deleteTodo: (id: number) => void;
}
interface TodoRenderer {
renderTodos: () => void;
}
class TodoService implements TodoManager, TodoRenderer {
addTodo(name: string, isCompleted: boolean) {
// code to add todo
}
deleteTodo(id: number) {
// code to delete todo
}
renderTodos() {
// code to render todos
}
}
Here, we have created a class TodoService
which implements TodoManager
and TodoRenderer
interface. Now, the TodoService
class only depends on TodoManager
and TodoRenderer
interface. If we don't want the rendering methods in the TodoService
class, we can remove TodoRenderer
interface from TodoService
class.
- Dependency Inversion Principle
A high level module should not depend on low level modules. Both should depend on abstractions.
For e.g. TodoService
class should depend on the low level data persistence class TodoRepository
using an abstraction (Dependency Injection).
interface TodoRepository {
save(todo: Todo): void;
}
class TodoService {
constructor(private todoRepository: TodoRepository) {}
addTodo(name: string, isCompleted: boolean) {
this.todoRepository.save(new Todo(name, isCompleted));
}
// ... other methods
}
-
Separation of Concerns
Keep different concerns separate.
For e.g. presentational logic should not be mixed with the business logic otherwise the app will become unmaintainable in no time.
class TodoModel {
constructor(public name: string, public isCompleted: boolean) {}
// ... this class should only have code related to data manipulation
save() {
// ... code to save the todo
}
}
class TodoLogger {
// ... this class should only have code related to logging
trackTodoStatus(todo: TodoModel) {
// ... code to track the status of the todo
this.logTodoStatus(todo);
}
logTodoStatus(todo: TodoModel) {
// ... code to log the status of the todo
console.log(`Todo name: ${todo.name} | isCompleted: ${todo.isCompleted}`);
}
}
class TodoController {
// ... this class should only have business logic
addTodo(name: string, isCompleted: boolean) {
const todo = new TodoModel(name, isCompleted);
todo.save();
todoLogger(todo);
}
}
class TodoView {
// ... this class should only have presentational logic
todos: TodoModel[] = [];
renderTodos() {
// ... code to render todos
}
}
-
Modularity
An improvement to the above pattern is to use modules. In this case, we can separate concerns using modules.
For e.g. TodoModel
and TodoLogger
should be in separate modules. Each module should be responsible for a specific task. We can create a library for a particular task and can be maintained by the team.
If you have a very small team, maintainability of separate modules would be very difficult. Separating concerns in modules is quite challenging and requires a lot of time.
-
Encapsulation
You need to hide the implementation details of a class. To do this, you can use encapsulation.
class TodoLogger {
private logTodoStatus(todo: TodoModel) {
// ... code to log the status of the todo
console.log(`Todo name: ${todo.name} | isCompleted: ${todo.isCompleted}`);
}
trackTodoStatus(todo: TodoModel) {
// ... code to track the status of the todo
this.logTodoStatus(todo);
}
}
class TodoController {
constructor(private todoLogger: TodoLogger) {}
addTodo(name: string, isCompleted: boolean) {
const todo = new TodoModel(name, isCompleted);
todo.save();
todoLogger.trackTodoStatus(todo);
}
}
The user of the class should not be able to access the implementation details of the class. The user should have no concern if it logs to the console or some other external storage.
Here, TodoController
does not care about the implementation details of TodoLogger
. It only cares about the trackTodoStatus
method.
-
Compositon Over Inheritance
There are a lot of disadvantages of using inheritance. One of them is that the child class is tightly coupled to the parent class. This means that the child class is not reusable. Also, the child class has to implement all the methods of the parent class. If the parent class is very complex, then the child class will become very complex as well.
In this case, we can use composition. Composition is a technique that allows us to create a class that is a composition of other classes.
For e.g. TodoService
should be a composition of TodoRepository
and TodoLogger
using dependency injection.
interface TodoRepository {
save(todo: Todo): void;
// ... other methods
}
interface TodoLogger {
trackTodoStatus(todo: Todo): void;
// ... other methods
}
class TodoService {
constructor(
private todoRepository: TodoRepository,
private todoLogger: TodoLogger
) {}
addTodo(name: string, isCompleted: boolean) {
const todo = new Todo(name, isCompleted);
this.todoRepository.save(todo);
this.todoLogger.trackTodoStatus(todo);
}
}
-
Cohension and Coupling
Cohension refers to how closely related two classes are. Coupling refers to how tightly related two classes are.
For e.g. Todo
and PriorityTodo
are tightly related. PriorityTodo
is tightly coupled to Todo
class. Whereas TodoController
is loosely coupled to TodoLogger
.
class Todo {
constructor(
public id: number,
public name: string,
public isCompleted: boolean
) {}
}
class PriorityTodo extends Todo {
constructor(
public id: number,
public name: string,
public isCompleted: boolean,
public priority: string
) {}
// ... other methods
}
class TodoLogger {
trackTodoStatus(todo: Todo) {
// ... code to track the status of the todo
}
}
class TodoController {
constructor(private todoLogger: TodoLogger) {}
addTodo(name: string, isCompleted: boolean, priority: string) {
const todo = new PriorityTodo(1, name, isCompleted, priority);
this.todoLogger.trackTodoStatus(todo);
}
}
-
Inversion of Control (IoC)
IoC means that high level modules should not depend on low level modules. Both should depend on abstractions. It is similar to Dependency Inversion Principle that we discussed in the Solid principles.
-
Fail-Fast
This means all the checks should be handled at the beginning of the code.
For e.g. addTodo
function should only be called if the todo
is valid.
function addTodo(name: string, isCompleted: boolean) {
// validate the todo
const isValid = isTodoValid(name, isCompleted);
if (!isValid) {
throw new Error("Invalid todo");
}
// ... code to add todo
}
-
Principle of Least Astonishment
The code should be as simple as possible. User should not be surprised by addTodo
function. The addTodo
function should only add the todo if it is valid and should not toggle the status of the todo.
A bad example
function addTodo(name: string, isCompleted: boolean) {
// ... code to add todo
const isValid = isTodoValid(name, isCompleted);
if (!isValid) {
throw new Error("Invalid todo");
}
// ... code to toggle the status of the todo
toggleTodoStatus(todo);
}
A good example
function addTodo(name: string, isCompleted: boolean) {
// ... code to add todo
const isValid = isTodoValid(name, isCompleted);
if (!isValid) {
throw new Error("Invalid todo");
}
// code to add the todo and there should not be any code to toggle the status of the todo or any other surprises.
}
-
Convention Over Configuration
In order to keep our code simple and maintainable, we can use convention over configuration.
For e.g. we can provide default values to our properties if they are not provided by the user.
function addTodo(name: string, isCompleted = false) {
// ... code to add todo
}
Instead of making isCompleted required, we can provide a default value as false as whenever a todo is created it is not completed. If we want to opt to configuration for such cases, it would become more complex.
-
Idempotency
Idempotency refers to a property that is always the same. This is very useful when we want to add a todo and toggle the status of the todo.
For e.g. addTodo
function should always add the same todo no matters how many times it is called. toggleTodoStatus
function should always toggle the status of the todo no matter how many times it is called.
function addTodo(name: string, isCompleted: boolean) {
todo = new Todo(name, isCompleted);
new TodoRepository().save(todo);
}
function toggleTodoStatus(todo: {
id: number;
name: string;
isCompleted: boolean;
}) {
todo.isCompleted = !todo.isCompleted;
}
-
Orthogonality
Orthogonality refers to the fact whenever a part of the code is changed, it should not affect other parts of the code. It is similar to the Separation of Concerns
principle or the Single Responsibility Principle
.
-
Avoid Premature Optimization
You should write a code that works as fast as possible. You can always optimize it later. However, if the code that you are writing needs to be fast, then it is better to write optimize it first.
For e.g. if you have to get all todos to show on a table which has pagination, then it is better to write it first and then optimize it later. But if you want to generate a report for all todos it is better to write it in a optimized way in the beginning itself.
-
Code for the Maintainer
You should have empathy towards the maintainer when you are writing a code. You should keep yourself in the shoes of the maintainer. You should write good documentations and update the documentation as and when needed.
-
Testability
You should write code that is easy to test. A code that does not follow any pattern or rule should not be written. You can follow the principles/patterns that we discussed earlier to write a testable code.
-
Use Meaningful Names
A code is maintainable if it has meaningful names. A meaningful name is a name that describes the purpose of the code. For e.g. addTodo
function should be called addTodo
and toggleTodoStatus
function should be called toggleTodoStatus
.
If you call addTodo
as add
and toggle
then it is not maintainable. The name is vague and it takes a lot of thinking to understand the purpose of the code.
There are few rules to follow when naming a code.
-
It is better understood when a function is named with a verb. For e.g.
addTodo
function should start withadd
andtoggleTodoStatus
function should be calledtoggle
. -
It is better understood when a variable is named with a noun. For e.g.
todo
variable should be calledtodo
instead oft
ort1
. -
It is better understood when singular and plural form of a noun is used for single and multiple objects. For e.g.
todos
should be calledtodos
andtodo
should be calledtodo
. -
Similary for a boolean value. For e.g. in order to track the completion status of a todo we can name the variable as
isCompleted
instead ofcompleted
. -
Use Patterns, but Don't Overuse Them
The patterns that we discussed are very useful in writing maintaiable and easily understandable code. But we should be pragmatic about how much and what principles we need depending on the project and the team we are working with.