Building A Clean And Readable Codebase - Key Takeaways
Glimpses from Shipsy's clean coding playbook that guides its developers towards maintainable and readable code.
At Shipsy, we view our code as a reflection of our values, as a community of developers.
However, every developer leaves a distinct mark on their code because of a subjective approach towards coding. While this doesn’t escalate into concern when the developer is the only one working on a particular project, when we bring more developers and future-proofing of the codebase into the picture, things change.
Right from naming conventions to code structure and functions to modules - the understanding and perception of “How to write code” changes from developer to developer. This can bring down code readability and affect the overall value an organization can draw from its assets.
Also, we aim at making our code assets to be more standardized, global, neat, and perfectly readable for future-proofing.
Hence, we follow a set of well-cultivated code practices that help us keep our code readable for facilitating an inclusive engineering culture.
Below, we share the key takeaways from our clean coding session to energize similar efforts across the entire industry.
Why Clean Code?
When it comes to coding, there are two situations that no developer can deny having encountered:
One, where the developer begins with a crystal clear understanding of all the names, comments, references, etc. As the code progresses and assumes a mammoth stance, or becomes complex, the developers tend to lose their grip over the understanding of all the confusing names and variables.
The second one is when a developer doesn’t necessarily play by the rules when it comes to coding.
While this developer might be agile and resourceful, he leaves the organization with a code puzzle that you can take forever to solve or make sense of.
It is normal for an organization to have different types of software codebases and update, edit, add, or remove code lines as the need arises. Also, organizational coding is a collaborative approach where more than one person or team is working simultaneously, or alone.
Generally, highly experienced developers or code architects create the code wireframe of the conceptual software system. These code architects also define the coding standards and various architecture standards that need to be followed during the process.
The developer team then works on this wireframe keeping the standards and software development specifications in mind.
As a number of developers are working on the different components of the software, it is inevitable for the code to have distinct peculiarities in terms of naming conventions, variables, code calls, parameters, etc.
This leads to inconsistencies, redundancies, and clunky code that is hard to read, debug and maintain keeping the future requirements in mind.
Further, every developer can make certain assumptions:
- This function name is so clear and intuitive, anyone can tell what is being done here
- Commenting on every function will make my code more readable and clearer
- I can clearly use this function call to bypass a whole page of standard routine for making my job easier
- I can assist whenever there is a problem; after all, I am not leaving the organization anytime sooner
But last-minute fixes, debugging, demand escalation, and an ever-expanding set of client demands affect the code readability.
The code is now a mess with:
- Variable names that no longer make sense
- A chain of comments that seem to convey a different meaning to every other developer
- A number of functions that look redundant or “not required”
All of them affect the code readability severely and make code upgrades, remodels, changes, and reuse extremely difficult.
The impact of bad coding practices goes beyond readability as it brings down productivity as shown below:
Bootstrapping The Change
Our main focus is on the usual suspects:
- Variables
- Functions
- Formatting
- Comments
No matter how clean of a code we start with, we eventually end up with messy modules that keep hiding inside the millions of code lines. While this might not be that irksome to some, they can wreak havoc when it comes to refining or editing the code in the light of new client demands.
Many times, these revisions happen in the absence of the original developer, or after sizable time has passed since the project delivery. In that case, code readability and standardization become extremely significant for the successful completion of these revisions.
This is also important from the organizational perspective as internal applications and operations also need a clean codebase for staying future-proof even when the employees leave the organization.
Hence, we targeted the above-mentioned four key areas to build a clean and readable codebase, and below we share the highlights for the same.
Clean Coding: 4 Key Considerations for an Easy yet, Significant Initiation
Given below, are the glimpses from our playbook for driving a clean code practice that aims at creating a global codebase.
Variables
#1 - Meaningful Variable Names
Make sure that the variable names are intention-revealing and meaningful. The basic rule of thumb is to check whether a name requires a comment to reveal its intention.
If yes, then you need to change it.
Bad Example:
const hawbNo = NON_NEGOTIABLE_FIELD_MASTER_CODES.HOUSE_AWB_NUMBER;
const date = moment(booking.sailingDate).format("YYYY/MM/DD");
Good Example:
const houseAWBNumber = NON_NEGOTIABLE_FIELD_MASTER_CODES.HOUSE_AWB_NUMBER;
const sailingDate = moment(booking.sailingDate).format("YYYY/MM/DD");
#2 - Avoid Misinformation
Misinformative names always create problems and cost you your time and productivity. Inconsistent spelling is also misinformation.
Here the function “getById” returns carrier code in the key “id”.
Hence, anybody who is calling this function will have no idea about this and will surely end up with lots of errors.
Bad Example:
class CarrierService extends CarrierInterface {
async getById( carrierId: string | null, .... ) {
let filters: KeyWithNullableValue = {
code: carrierId, // here carrierId is attached to the code. even function name is getById
};
functionLogic()
return {
id: carrier.code, // here it passes code in the key "id"
name: carrier.name,
}
}
}
Good Example:
class CarrierService extends CarrierInterface {
async getById( carrierId: string | null, .... ) {
let filters: KeyWithNullableValue = {
id: carrierId,
};
functionLogic()
return {
code: carrier.code,
name: carrier.name,
}
}
}
#3 - Avoid Noise Words and Non-Distinguishable Names
Further, it is important to make a meaningful distinction by avoiding the noise words, to distinguish between variables like data or info.
You might be tempted to change one name in an arbitrary manner when you need to use the same name to refer to two different things in the same scope.
However, avoid misspellings at all costs as it degrades the code readability.
Some examples of non-distinctive names are:
booking or bookingData
money or moneyAmount
user or userInfo
#4 - No Generic Names
Avoid using generic names, such as sum, total, count, etc. and add prefixes to them for boosting code readability.
Bad Example:
async inquiryBidsCount (....) {
let count = 0;
functionLogic();
return count;
}
Good Example:
async inquiryBidsCount (....) {
let bidsCount = 0;
functionLogic();
return bidsCount;
}
#5 - Don’t Describe Constants in Comments
Instead of describing a constant in comments, give a proper name to it.
Bad Example:
if (request.processingCount == 5) { // 5 is the max retry count
doStuff();
}
Good Example:
const MAX_RETRY_COUNT = 5;
if (request.processingCount == MAX_RETRY_COUNT) {
doStuff();
}
#6 - Use Prefixes Smartly
Sometimes prefixes are a necessity and not an option.
For example, the address has many components:
- firstName
- lastName
- Street
- City
- Country
Now, if you use only “State”
variable in a method, some other person reading the code might not get the idea that it was a part of an address. You can remedy the situation by adding the “address”
prefix to these names, which will always convey the right idea.
Functions
#1 - Limit the Arguments
Try to use the minimum number of arguments. In our codebase, we use options as one argument. Always try to pass all optional arguments in it.
Bad Example:
async createBookingRequest(carrierCode?: string, ... , options: OptionsType<{}>) {
if (carrierCode) {
//functionLogic()
}
//functionLogic()
return;
}
Good Example:
In the above example, carrierCode is optional, so we can pass this in options as well. This will decrease the number of arguments without changing anything in the code.
async createBookingRequest(... , options: OptionsType<{carrierCode?: string}>) {
const { carrierCode } = options;
if (carrierCode) {
//functionLogic()
}
//functionLogic()
return;
}
#2 - Naming Conventions
For creating a clean code, we adhere to the naming conventions for variables, when it comes to Functions as well.
#3 - Structure of A Function
Keep the functions small and stick to the “one-task-per-function” dictum to ensure the conciseness of functions.
Bad Example:
async createShippingInstruction(isDraft: boolean, ...) {
if(isDraft) {
// validations for draft request code
// functionLogic()
} else {
// validations for non draft request code
// functionLogic()
}
return;
}
Good Example:
async createShippingInstruction(isDraft: boolean, ...) {
if(isDraft) {
await validateDraftShippingInstructionRequest();
// functionLogic()
} else {
await validateNonDraftShippingInstructionRequest();
// functionLogic()
}
return;
}
#4 - Avoid using lots of conditionals
Bad Example:
const myFunc = (dep: string) => {
const myVar = (() => {
switch(dep) {
case 'a':
return 'aahoo';
case 'b':
return 'deadman';
default:
return 'spooky';
}
})();
return myVar;
};
const myFunc = (dep: string) => {
let myVar = 'spooky';
if (dep === 'a') {
myVar = 'aahoo';
}
if (dep === 'b') {
myVar = 'deadman';
}
return myVar
};
const requestsHandler = (requestType: string) => {
if (requestType === 'booking'){
return this.getBookingObject();
} else if (requestType === 'si'){
return this.getSIObject();
} else if (requestType === 'vgm'){
return this.getVGMObject();
}
};
Good Example:
const myFunc = (dep: string) => {
const map = {
a: 'aahoo',
b: 'deadman',
// ... goes on ...
};
const myVar = dep ? map[dep] : 'spooky';
};
const requestsHandler = (requestType: string) => {
const map = {
booking: this.getBookingObject,
si: this.getSIObject,
vgm: this.getVGMObject,
}
return map[requestType]();
};
#5 - Favor Functional Programming
Favor functional programming over imperative programming for making code testing easier.
Bad Example:
const programmerOutput = [
{
name: "Uncle Bobby",
linesOfCode: 500
},
{
name: "Suzie Q",
linesOfCode: 1500
},
{
name: "Jimmy Gosling",
linesOfCode: 150
},
{
name: "Gracie Hopper",
linesOfCode: 1000
}
];
let totalOutput = 0;
for (let i = 0; i < programmerOutput.length; i++) {
totalOutput += programmerOutput[i].linesOfCode;
}
Good Example:
const programmerOutput = [
{
name: "Uncle Bobby",
linesOfCode: 500
},
{
name: "Suzie Q",
linesOfCode: 1500
},
{
name: "Jimmy Gosling",
linesOfCode: 150
},
{
name: "Gracie Hopper",
linesOfCode: 1000
}
];
const totalOutput = programmerOutput.reduce(
(totalLines, output) => totalLines + output.linesOfCode, 0);
#6 - Avoid Side-Effects
Ensure that there are no side-effects of a function, such as writing to a file, modifying global variables, and sharing state between objects without structure
Otherwise, in the future, if someone needs to update or edit the code, such side effects can lead to serious outcomes.
Bad Example:
// Global variable referenced by following function.
// If we had another function that used this name, now it'd be an array and it could break it.
let name = "Ryan McDermott";
function splitIntoFirstAndLastName() {
name = name.split(" ");
}
splitIntoFirstAndLastName();
console.log(name); // ['Ryan', 'McDermott'];
Good Example:
function splitIntoFirstAndLastName(name) {
return name.split(" ");
}
const name = "Ryan McDermott";
const newName = splitIntoFirstAndLastName(name);
console.log(name); // 'Ryan McDermott';
console.log(newName); // ['Ryan', 'McDermott'];
#7 - Miscellaneous Pointers:
- As we cannot always avoid “switch” statements, try to keep them in a low-level class and never repeat them
- Use descriptive names to convey what the function does
- Remove all instances of duplicate code
Formatting
#1 - Variable Declaration
Declare variables as close to their usage as possible. If some variable is used in loops or in conditional statements. They must be either inside that scope or just above that part of the code.
Bad Example:
async getActiveBookingCount() {
let activeBookingCount = 0;
// functionLogic()
bookings = getBookings();
bookings.forEach((booking) => {
if(booking.is_active) {
activeBookingCount++;
}
});
return activeBookingCount;
}
Good Example:
async getActiveBookingCount() {
// functionLogic()
bookings = getBookings();
let activeBookingCount = 0;
bookings.forEach((booking) => {
if(booking.is_active) {
activeBookingCount++;
}
});
return activeBookingCount;
}
#2 - Keep Calling and Callee Functions Close
If a function calls another function, keep those functions vertically close in the source file. Ideally, keep the caller right above the callee; wherever possible. We tend to read code from top-to-bottom, like a newspaper. Because of this, make your code read that way.
Bad Example:
aysnc getBidDetails(...) {
}
async getBidDetailsForFF() {
return await getBidDetails( ..., {...options, bidsView: 'FF'});
}
async getBidDetailsForShipper() {
return await getBidDetails( ..., {...options, bidsView: 'SHIPPER'});
}
OR
async getBidDetailsForShipper() {
return await getBidDetails( ..., {...options, bidsView: 'SHIPPER'});
}
aysnc getBidDetails(...) {
}
async getBidDetailsForFF() {
return await getBidDetails( ..., {...options, bidsView: 'FF'});
}
Good Example:
async getBidDetailsForShipper() {
return await getBidDetails( ..., {...options, bidsView: 'SHIPPER'});
}
async getBidDetailsForFF() {
return await getBidDetails( ..., {...options, bidsView: 'FF'});
}
aysnc getBidDetails(...) {
}
Comments
Writing a description or summary of the functions is a good thing. But writing comments as make-up for bad code is not.
Like if some function or variable name is not good and we just write some comment to cover that thing. In this case, we always have to use the correct name so that it's self-descriptive.
Bad Example:
// Check to see if the employee is eligible or not for full benefits
If ((employee.flags & HOURLY_FLAG) && employee.age > 65)) {
}
Good Example:
If (employee.isEligibleForFullBenefits()) {
}
Below, we share some pointers to keep in mind for good and bad commenting practices.
Good or necessary comments:
- Legal comments
- Explanation of intent: Basically sometimes we have to give some useful information about the implementation of the decision we took there.
- Clarification: Sometimes we get in a situation where we have to clarify about the return object or we are using any third-party things. Like defining EDIFACT segments' names by comments.
- TODO comments
Bad Comments:
- Redundant comments or noise comments, such as defining the function or variable even when you can understand them easily by their names.
- Mandated comments, all the parameters’ definition comments that we used in our code base.
- Commented out code always creates confusion and degrades the code readability.
Bonus Tip - Error Handling
Thrown errors are a good thing!
They mean the runtime has successfully identified when something in your program has gone wrong and it's letting you know by stopping function execution on the current stack, killing the process (in Node), and notifying you in the console with a stack trace.
Hence, we never ignore the caught errors.
Doing nothing with a caught error will mean you never reacted or fixed it. Logging the error to the console (console.log)
isn't much better as oftentimes it can get lost in a sea of things printed to the console.
If you wrap any bit of code in a try/catch
it means you think an error may occur there and therefore you should have a plan, or create a code path, for when it occurs.
Bad Example:
try {
functionThatMightThrow();
} catch (error) {
console.log(error);
}
Good Example:
try {
functionThatMightThrow();
} catch (error) {
// One option (more noisy than console.log):
console.error(error);
// Another option:
notifyUserOfError(error);
// Another option:
reportErrorToService(error);
// OR do all three!
}
Establishing a Clean Coding Culture: Every Step Is Crucial
While it is easy to elaborate on the clean coding practices on paper, implementing them at an organizational level is a game of patience and time.
At Shipsy, we facilitate a clean coding culture by making it an essential part of our engineering onboarding process.
We also follow the Engineering Guide that we have built from scratch. We keep on updating it with every project to keep every developer on the same page.
Finally, we ensure that our entire community stays abreast of all the latest developments and clean coding practices by organizing regular tech sessions where we share, learn, and innovate in a sustainable manner.
Such organizational measures and initiatives leave no room for discrepancies or a lopsided codebase.
Fostering a clean coding culture at the organization level is an ongoing process and we hope that our inputs and endeavors energize similar efforts across the global Dev community.