The Philosophy of Software Design - The Way We Build at Shipsy
Take a sneak peek into how we solved the Great Problem Decomposition Challenge for everyday development at Shipsy.
Shipsy is a SaaS-based smart logistics management platform. We aim at solving the logistics challenges and operational pain points with intuitive and robust software solutions.
Being an agile organization with an intense focus on innovation entails designing and creating future-proof technological solutions that scale, evolve, and stay relevant.
However, delivering across such expectations requires an impeccably robust codebase, which is readable, maintainable, and intuitive for years down the lane.
We aimed at creating a codebase that can serve as a reference for the new developers and “explains itself” even when the original developer is not around. This is also called future-proofing, and here is how we energized the efforts at Shipsy.
Problem Statement: Can Software Designing Be Future-Proof?
Future-proofing has many different faces and definitions based on the business use cases, such as:
- Preserving code readability and maintainability even after the original developer has left the organization
- Ensuring that code is not clunky for future usage, extensions, or upgrades
- Developing a system design that is effortless relevant for every stakeholder
In our pursuit, we zeroed down on the fundamentals - problem decomposition, as outlined by the industry stalwart John Ousterhout.
Any computer software is one big problem decomposition challenge - the number of modules, functionalities of these modules, documentation, dependencies, and the overall complexity of the functional system.
As the clients suggest changes or ask for more features, there is hardly the time to play by the rules. So, code refactoring or best practices tend to take the back seat, especially in startups.
Then, how can we make our code future-proof?
Solution: Reduce System Complexity
System complexities affect developer productivity in many ways, even if it is not apparent from the very beginning. Understanding the working of a piece of code, lots of efforts for implementing small changes, and localizing all the points where a single change reflects - are some symptoms of complex systems.
As the software complexity increases, it becomes more vulnerable to bugs and delays in development.
Software complexities are of three types:
- Change Amplification - Any simple change requires changes in many places, as shown below:
Bad Example:
// file1.js
if (direction === 'UP') {
// ....
}
// file2.js
if (direction === 'UP') {
// ....
}
.....
Good Example:
enum Direction {
UP = ‘UP’,
DOWN = ‘DOWN’,
LEFT = ‘LEFT’,
RIGHT = ‘RIGHT’
}
// file1.js
if (direction === Direction.UP) {
// ....
}
// file2.js
if (direction === Direction.UP) {
// ....
}
- Cognitive Load - Developers need to carry a lot of information to complete the task. This increases the chances that they might miss something leading to bugs and delays in development.
As shown in the above example, the shared enum variable holds the direction and each page references that variable.
- Unknown Unknowns - There's important information you need to know before making a change, but it is not obvious where to find it or even if it is needed.
As shown in the following example, the shared enum variable has the “string” type. However, few files use the “number” type.
enum Direction {
UP = 'UP',
DOWN = 'DOWN',
LEFT = 'LEFT',
RIGHT = 'RIGHT'
}
// file1.js
if (direction === Direction.UP) {
// ....
}
// file2.js
if (direction === Direction.UP) {
// ....
}
// file3.js
// direction holds numbers instead of string
// for e.g up -> 1, down -> -1 left -> -1, right -> 1
if (direction === Direction.UP) {
//
}
Unknown unknowns are the most critical type of complexity, as in this case, you don’t even know that there is an underlying vulnerability or risk in your code.
Generally, in agile organizations, such as startups, software development is tactical, meaning that during development, the main focus is on faster deliveries and accomplishing the intended functionality via shortcuts. As there is no specific focus on long-term goals, there is generally no planning (strategic development) for code refactoring.
Ideally, software development must be strategic, keeping the long-term goals, such as maintainability, in mind. However, the more tactical a developer becomes, the more time they take in developing software, as shown in the following graph:
Hence, we started to look beyond the “working code” and approached code refactoring strategically, such as specific projects that are well matured. This helped us balance the strategic and tactical development goals and steered us towards effortless yet agile system development.
Here are three things that help us reduce system complexities consistently.
Modular Design
Addressing complexities, one at a time.
We create small and independent modules that reduce complexity by ridding dependencies.
However, there is a catch - independent doesn’t mean more modules. We implement independence in our modular design by ensuring that the implementation details in the service class of one module are not visible to another. Also, we use encapsulation so that the service class shows fewer implementation details for layers of abstraction.
Below, we share key considerations for concise and strategic modular design:
- Use default class for common behavior
- Avoid shallow modules as they increase the number of modules, which yet again adds to the overall complexity, as invoking any one of them is not easy.
Further, the shallow modules tend to be complex because they include functionalities from other modules as well.
Take a look at the following examples for a better understanding.
Bad Example:
export abstract class Inquiry {
abstract createInquiry(organisationId: string, params: CreateInquiryDto);
abstract sendInquiry(organisationId: string, params: SendInquiryDto);
abstract searchInquiry(organisationId: string, params: SearchInquiryDto);
abstract searchByReferenceNumber(organisationId: string, params: SearchInquiryDto);
abstract updateDeadline(organisationId: string, params: UpdateInquiryDeadlineDto);
abstract getInquiryDetailsForShipper(organisationId: string, params: FetchInquiryDetailsDto);
abstract getInquiryDetailsForFF(organisationId: string, params: FetchInquiryDetailsDto);
abstract fetchInquiry(organisationId: string, params: FetchInquiryDetailsDto);
abstract updateInquiry(organisationId: string, params: UpdateInquiryDto);
}
Deep modules work the best for light and clean interfaces, as most of the information is hidden, even if there is a lot of functionality in a method.
Good Example
export abstract class Inquiry {
abstract create(organisationId: string, params: CreateInquiryDto);
abstract send(organisationId: string, params: SendInquiryDto);
abstract search(organisationId: string, params: SearchInquiryDto);
abstract update(organisationId: string, params: UpdateInquiryDto);
abstract fetch(organisationId: string, params: FetchInquiryDetailsDto);
abstract delete(organisationId: string, params: DeleteInquiryDto);
}
Code Documentation
Code documentation or commenting is essential to make the code more understandable and readable for future references and correlations.
While good commenting can overcome the unknown unknowns and cognitive load challenges, over-commented code makes things complex.
Below, we share some key pointers to keep in mind for code documentation.
Good comments:
- Legal comments
- TODO comments
- Explain the intent of the coder regarding some important implementation, as shown in the following screenshots
/**
* @summary dump inquiry and bid details to rate_master when bid is sent for an inquiry
* @description
* 1. find port and carrier details to set db internal identifiers
* 2. separate charges with and without container type, size from bid charge details and
* prepare them into rate_master charge format
* 3. check if revised bid is sent
* 4. for each Inquiry container type,size insert/update rate master object
* @param {string} organisationId [Organization identifier]
* @param {Users} user [user details]
* @param {Inquiry} inquiry [inquiry details]
* @param {Bids} bid [bid details]
* @param {OptionsType} options [extra properties]
*/
function addInquiryBidInRateMaster(
organisationId: string,
user: Users,
inquiry: Inquiry,
bid: Bids,
options: OptionsType = {}
) {
}
Bad comments:
- They are noise comments that offer redundant information, such as explaining the names of functions even when they are easily understood without comments.
- Commented out code creates confusion and brings down the code readability.
- The parameter definition comments used in a codebase or mandated comments also affect code readability.
// Don’t repeat the code
// get time diff
let timeDiff = currentTime - queryStartTime;
// remove milliseconds
const milliseconds = Math.round(timeDiff % 1000);
timeDiff /= 1000;
const seconds = Math.round(timeDiff % 60);
// remove seconds from the date
timeDiff = Math.floor(timeDiff / 60);
// get minutes
const minutes = Math.round(timeDiff % 60);
// remove minutes from the date
timeDiff = Math.floor(timeDiff / 60);
// get hours
const hours = Math.round(timeDiff % 24);
// remove hours from the date
Function and Variable Naming
Every good function or variable name is a combination of three things: Prefix+Action+Context.
Prefix - It is applicable when code is returning boolean values, such as:
is
- current state of the context, for exampleisMember
orisAdmin
has
- current context possesses a certain value, for examplehasPermission
should
- positive conditional statement coupled with an action, for exampleshouldUpdateEntity
Action - It is the verb part of your function name, such as:
get
- accesses data, for examplegetStatusCount
set
- sets variable with value, for examplesetUserRole
reset
- reset variable with an initial value, for exampleresetItems
fetch
- requests data usually network requests, for examplefetchPosts
remove
- removes something from somewhere, for exampleremoveFilter
delete
- completely remove the existence, for exampledeletePost
compose
- creates new data from existing data, for examplecomposeDisplayAddress(place, area, pincode)
handle
- handles action, for examplehandleButtonClick
Context - It is the expected data type that a function operates on, such as:
Things to keep in mind during function and variable naming:
- The name should be intuitive and descriptive
- Avoid contractions or abbreviations that are confusing
- Avoid context duplication
- Pay attention to singular and plural names
Bad Example:
const isExtraServicesSelected = lodash.difference(inquiry.origin.services, originServices).length > 0;
Good Example:
const hasExtraServicesSelected = lodash.difference(inquiry.origin.services, originServices).length > 0;
Bad Example:
const orgModules = orgConfig.modules;
Good Example:
const organisationModules = organisationConfig.modules;
Bad Example:
class country {
this.country_name = ‘’
}
Good Example:
class country {
this.name = ‘’
}
Our previous post on clean coding covers the function and variable naming practices with detailed examples and can be read for a better understanding.
Results: Paving the Way Towards Code Excellence
Future-proofing the entire codebase is a gradual and continuous process.
At Shipsy, we created a robust, reliable, and thorough engineering handbook for driving clean coding practices. With a renewed focus on better design and maintainable code, we are moving towards a well-established development organization culture that motivates all of us to work smarter and better.
Explore careers at Shipsy to be a part of our consistently improving and innovation-oriented developer community.
Acknowledgments and Contributions
As an effort towards consistent learning and skill development, we have regular “Tech-A-Break” sessions at Shipsy where our team members exchange notes on specific ideas and topics. Contributions: Sahil Arora, Viraj Shah