Applying SOLID principles to an existing application
This is the last part of the SOLID Series. We are going to refactor the CLI application built in the first part. To follow on with this part, you must have read the previous tutorials:
Discovering Defects
To refactor the CLI application, we need to first point out the sections that are not adhering to the SOLID principles. You can get the code for the CLI application here.
Looking at the cli
function in src/cli.js
export function cli(args) {
const operation = parseArguments(args);
const store = require('./store.json')
if (operation.type === 'bill') {
const currentUser = store.find(item => item.id === Number(operation.userId));
if (!currentUser) {
throw new Error(`No user with id ${operation.userId}`)
}
if (currentUser.canWithdraw && currentUser.balance >= Number(operation.amount)) {
currentUser.balance -= operation.amount;
currentUser.canWithdraw = !(currentUser.amount === 0);
fs.writeFile(`${__dirname}/store.json`, JSON.stringify(store), function(err) {
if (err) {
throw new Error(err.message);
}
console.log(chalk.greenBright(`Successfully billed ${currentUser.name} ${operation.amount}`));
})
} else {
throw new Error(`Unable to bill ${currentUser.name}`)
}
}
}
As seen above, the cli
function performs more than one functionality. It has more than one reason to change. This goes against the first of the SOLID principles — The Single Responsibility Principle.
Here are the functionalities performed by the cli
function.
- Fetching users from the store.
- Billing users.
- Updating users.
- Writing users to store.
Refactoring…
We are going to move these functions to a different module. In the src directory, create a UserRepository.js file. This file will house functions that work on the user — Fetching users, updating, and storing users. Add the following to the newly created UserRepository.
const users = require('./store.json');
export function getUserById(id) {
let user = users.find(user => user.id === id);
if (user) {
return user;
}
throw new Error(`No user with id ${id}`);
}
We are moving the functionality of fetching a user from the store down to the UserRepository module.
The getUserById
accepts an id — number, and then returns a user with that id.
Modify the cli function to use getUserById
in fetching the current user.
Import getUserById
at the top of cli.js so it will be available in that file.
import {getUserById} from './UserRepository';
Change
const currentUser = store.find(item => item.id === Number(operation.userId));
To:
const currentUser = getUserById(operation.userId)
The cli
function should now be looking like:
export function cli(args) {
const operation = parseArguments(args);
const store = require('./store.json')
if (operation.type === 'bill') {
const currentUser = getUserById(operation.userId)
if (currentUser.canWithdraw && currentUser.balance >= Number(operation.amount)) {
currentUser.balance -= operation.amount;
currentUser.canWithdraw = !(currentUser.amount === 0);
fs.writeFile(`${__dirname}/store.json`, JSON.stringify(store), function(err) {
if (err) {
throw new Error(err.message);
}
console.log(chalk.greenBright(`Successfully billed ${currentUser.name} ${operation.amount}`));
})
} else {
throw new Error(`Unable to bill ${currentUser.name}`)
}
}
}
Next up, we need to move the billing functionality away from the cli
function.
However, billing the user means updating the user’s balance, hence, we need a function in the UserRepository
to take care of updating the user.
Add the following to UserRepository.js
import * as fs from 'fs';
...
export function updateUser(userId, {balance, canWithdraw}) {
return new Promise( (resolve, reject) => {
let user = getUserById(userId);
user.balance = balance;
user.canWithdraw = canWithdraw;
fs.writeFile(`${__dirname}/store.json`, JSON.stringify(users), function(err) {
if (err) {
reject(err.message);
} else {
resolve();
}
});
});
}
Line 1 imports fs, which is used for working with files in javascript. We will use it to update the user data to the store.
We create an updateUser
function to handle the functionality of updating the user data.
This function takes in two arguments. First is a number which will be used to reference the id
of the user. The second parameter is an object and we are using object destructuring to extract balance
and canWithdraw
from the object.
We return a promise which fails if we are not able to update the store with the new user details, and we resolve the promise if everything works as expected.
Create a bill.js file in the src directory. This file will handle all matters concerning billing the user.
import {updateUser} from './UserRepository';
import chalk from 'chalk';
export function billUser(user, amount) {
if (canBillUser(user, amount)) {
let balance = user.balance - amount
updateUser(user.id, {
balance: balance,
canWithdraw: !(balance === 0)
}).then(res => {
console.log();
console.log(chalk.greenBright(`Successfully billed ${user.name} ${amount}`));
console.log(chalk.greenBright(`Amount left: ${balance}`));
}).catch(err => {
console.log(chalk.redBright(err));
});
} else {
throw new Error(`Unable to bill ${currentUser.name}`)
}
}
export function canBillUser(user, amount) {
return (user.canWithdraw && user.balance >= amount);
}
First, we import updateUser
from UserRepository. We import chalk as well since we will be writing to the console.
The billUser
function handles the billing of the user. It takes a user object that must have been gotten from the UserRepository as the first parameter and the amount to be billed from the user as the second parameter.
Before we are able to bill the user, we need to check that the user is billable — validation.
The validation functionality is handled in the canBillUser
function. We simply check if the user’s balance is greater than the amount that we are trying to bill the user.
If the validation fails, we throw an error and exit the function.
If the validation is successful, we bill the user and update the user data using the updateUser
function.
If updating the user details works, we output a success message to the user. If something went wrong in the process, the error will be caught in the catch
section and displayed for the user.
Now, let us use the billUser
function in cli.js .
Modify the cli
function to look like this.
export function cli(args) {
const operation = parseArguments(args);
const store = require('./store.json')
if (operation.type === 'bill') {
const currentUser = getUserById(operation.userId)
billUser(currentUser, operation.amount);
}
}
We now have a simple and concise cli
function that is easy to understand, test and carries out the responsibility of controlling the flow.
Run the code to confirm that everything is still working as expected.
Conclusion
We have been able to refactor the CLI application using one of the SOLID principles as a guide. The improvement to the readability of the code is already noticeable. Our code is more modular, and now maintainable. Previously, the CLI application would have been almost impossible to test, but with this refactor, we can easily test the whole application. This is one of the major advantages of applying the SOLID principles to your application. Updates to the application can be found on this Pull Request
I hope you have seen the improvements that can be made to your codebase by using adhering to the SOLID principles. We refactored according to one of the principles. Now imagine how great the code would be if we had applied all other principles.