Tired of Messy Code? Master the Art of Writing Clean Codebases
You've conquered the initial hurdle, learning to code and landing your dream job. But the journey doesn't end there. Now comes the real challenge: writing good code. This isn't just about functionality; it's about crafting elegant, maintainable code that stands the test of time.
Navigating a poorly designed system feels like being lost in a foreign city with no map. These systems are often clunky, inefficient, and frustrating.
We can change this by designing better systems that are user-centered, efficient, effective, simple, and flexible.
As developers, we can write code that's DRY and modular by using functions, variables, classes, and comments.
Let's design systems that work for people, not the other way around.
Importance of Codebase Structure
Imagine a city with tangled streets, unclear signs, and hidden dangers. That's what poorly structured code feels like: confusing, frustrating, and riddled with vulnerabilities.
Now imagine a well-designed city: clear pathways, informative signs, and easy navigation. Well-structured code is like this, promoting efficiency, security, and a smooth development journey.
The choice is clear: build code that empowers, not hinders. Let's craft code cities where developers thrive.
The Codebase Challenge
Imagine you're a sales executive armed with a new app designed to streamline your workflow and maximize lead conversion. The app, powered by the SmartReach API, offers real-time insights into your prospects, potentially unlocking significant bonuses.
But something feels off. The code provided lacks clarity and structure, raising concerns about its accuracy and functionality. Analyzing the code becomes a confusing task, hindering your ability to use the app effectively.
Instead of grappling with cryptic lines, let's break down the code in a clear and structured manner. This will allow us to identify potential errors and ensure the app functions smoothly, maximizing your productivity and bonus potential.
class ApiCall {
def getData(pn: Int, v: Int) = Action.async(parse.json) { request =>
ws.url(s"https://api.smartreach.io/api/v$v/prospects?page=$pn")
.addHttpHeaders(
"X-API-KEY" -> "API_KEY"
)
.get()
.flatMap(r => {
if (r.status == 200) {
Res.Success("Success!", (response.json \ "data").as[JsValue])
} else {
Res.ServerError("Error!",new Exception((response.json \ "message").as[String]))
}
})
}
}
Prepare to witness a coding transformation! Today, we'll journey through refactoring a sample sales app written in Scala but applicable to coding in general. Our goal? To transform it from a headache-inducing mess to a clear, readable masterpiece.
Imagine code as a city: would you prefer a chaotic sprawl or a well-organized paradise? We're aiming for the latter, where navigation is effortless and modifications painless.
Think of writing code like composing a symphony. Do you want a cacophony of notes or a harmonious blend that resonates with anyone who listens? Clarity is key!
Join me as we break down the refactoring process, explaining each step and demonstrating its positive impact. Let's create code that inspires and empowers, together!
Codebase Refactors
Naming Conventions and Code Comments
We're all about quick messages these days, but that doesn't work as well when it comes to writing code.
Using clear names for things like variables, classes, functions, and objects is super important. Think about your friend who might check or change your code. You'd want to keep them on your good side, right?
Oh, and don't forget to jot down some notes in your code. It might not seem like a big deal, but when you or your coworker are trying to fix things, those notes will be a big help.
Let's take a look at how our code turns out after we follow these rules
class ProspectsApiGet {
def getProspectListFromSmartReach(
page_number: Int,
version: Int
) = Action.async(parse.json) { request =>
//public API of SmartReach to get the prospect list by page number
//smartreach.io/api_docs#get-prospects
ws.url(s"https://api.smartreach.io/api/v$version/prospects?page=$page_number")
.addHttpHeaders(
"X-API-KEY" -> "API_KEY"
)
.get()
.flatMap(response => {
val status = response.status
if (status == 200) {
//if the API call to SmartReach was success
/*
{
"status": string,
"message": string,
"data": {
"prospects": [*List of prospects*]
}
}
*/
val prospectList: List[JsValue] = (response.json \ "data" \\ "prospects").as[List[JsValue]]
Res.Success("Success!", prospectList)
} else {
// error message
//smartreach.io/api_docs#errors
val errorMessage: Exception = new Exception((response.json \ "message").as[String])
Res.ServerError("Error", errorMessage) // sending error
}
})
}
}
Mistakes were hiding in the code if you were watching closely! The parsing for the prospect wasn't quite right.
But by giving more context with comments and using better names, we sidestepped those errors!
Codebase Organization and Modularization
Everything's all in one place now. Our code's in good shape, and the app is straightforward. Now that the code works well and is easier to read, let's tackle the next challenge.
Think about introducing a filtering system here. That'll make things way more complex. What we really need is a more organized code structure.
To make it modular, we'll split this code into smaller chunks. But how do we figure out where each chunk belongs?
Directory Structure
A directory serves as a home for your code, essentially a folder that keeps everything together. Create a new folder, and voila, you've got your directory!
In this directory, you can store your code files or create additional sub-directories. For our case, we're opting for sub-directories. We'll split our code into four parts: models, DAO (Data Access Object), Services, and Controller.
It's worth noting that the directory structure might vary based on your company's preferences or the specific needs of your application. Feel free to tailor it according to what suits your company or app best.
Model
When we talk about models in coding, we're essentially talking about the framework we use to structure and manage data. For instance, in our context, the Prospect model acts as a blueprint, outlining the specific characteristics and behaviors that represent a prospect within our system.
In this model, we define the attributes that a prospect possesses—maybe their name, contact details, or any other relevant information. It's not just about storing data; it's about organizing it in a way that makes sense for our application to interact with and manipulate effectively.
case class SmartReachProspect(
sr_prospect_id: Long, //id of the prospect in SmartReach database
p_first_name: String,
p_last_name: String,
p_email: String,
p_company: String,
p_city: String,
p_country: String,
smartreach_prospect_category: String // Prospect category in SmartReach
// can be "interested", "not_interested", "not_now", "do_not_contact" etc
)
Data Access Object (DAO)
This object, aptly named the Data Access Object (DAO), acts as a bridge for fetching data—be it from a database or a third-party API.
It's crucial to steer clear of adding complex logic into these files; they should focus solely on handling input and output operations. When we talk about IO operations, it refers to interactions with external systems where the likelihood of failures is higher. Therefore, implementing safeguards here is crucial to handle unexpected issues.
In the Scala programming language, we leverage Monads, particularly Futures, to effectively manage and handle potential failures. These tools aid in capturing and managing failures that might occur during IO operations.
The primary goal within the DAO is to retrieve data from its source and then organize it into the appropriate model for further processing and utilization.
class SmartReachAPIService {
def getSmartReachProspects(
page_number: Int,
Version: Int
)(implicit ws: WSClient,ec: ExecutionContext): Future[List[SmartReachProspect]] = {
//public API from SmartReach to get the prospects by page number
//smartreach.io/api_docs#get-prospects
ws.url(s"https://api.smartreach.io/api/v$version/prospects?page=$page_number")
.addHttpHeaders(
"X-API-KEY" -> "API_KEY"
)
.get()
.flatMap(response => {
val status = response.status
if (status == 200) {
//checking if the API call to SmartReach was success
/*
{
"status": string,
"message": string,
"data": {
"prospects": [*List of prospects*]
}
}
*/
val prospects: List[SmartReachProspect] = (response.json \ "data" \\ "prospects").as[List[SmartReachProspect]]
prospects
} else {
//error message
//smartreach.io/api_docs#errors
val errorMessage: Exception = new Exception((response.json \ "message").as[String])
throw errorMessage
}
})
}
}
Service
Here lies the heart of our system—the business logic resides within this layer. Here, we'll implement our filtering mechanism, showcasing how effortlessly we can introduce additional functionalities into this section of the codebase.
This segment orchestrates the core operations and rules that drive our application. It's where we define how data should be processed, manipulated, and transformed based on our business requirements. Adding new features or logic here becomes relatively straightforward, allowing us to expand and enhance the system's functionalities with ease.
class SRProspectService {
val smartReachAPIService = new SmartReachAPIService
def filterOnlyInterestedProspects(
prospects: List[SmartReachProspect]
): List[SmartReachProspect] = {
prospects.filter(p => p.prospect_category == "interested")
}
def getInterestedProspects(
page_number: Int,
version: Int
)(implicit ws: WSClient,ec: ExecutionContext): Future[List[SmartReachProspect]] = {
val allProspects: Future[List[SmartReachProspect]] = smartReachAPIService.getSmartReachProspects(page_number = page_number, version = version)
allProspects.map{list_of_prospects =>
filterOnlyInterestedProspects(prospects = list_of_prospects)
}
}
}
Controller
At this level, we establish a direct connection with our APIs—this layer acts as the gateway.
It serves as the interface, receiving requests either from the front-end or third-party users through our API. Upon receiving these calls, we gather all necessary data and subsequently handle the response after processing.
It's crucial to maintain a separation of concerns; thus, we steer clear of implementing logic in this stratum. Instead, this layer focuses on managing the flow of incoming requests and directing them to the appropriate service layer where the actual processing and business logic takes place.
class ProspectController {
val prospectService = new SRProspectService
def getInterestedProspects(
page_number: Int
) = Action.async(parse.json) { request =>
prospectService
.getInterestedProspects(page_number = page_number, version = 1)
.map{ intrested_prospects =>
Res.Success("Success", intrested_prospects)
}
.recover{ errorMessage =>
Res.ServerError("Error", errorMessage) // sending error to front end
}
}
}
Our revamped codebase boasts improved cleanliness and enhanced manageability, facilitating more efficient refactoring endeavors.
Moreover, we've established distinct markers for incorporating logic, executing database operations, or seamlessly integrating novel third-party APIs. These clear demarcations streamline the process of expanding functionality and accommodating future enhancements within our system.
Testing and Quality Assurance
Testing may seem repetitive, yet its importance cannot be overstated, particularly when wielded adeptly. No need to fret about coding anew; we're steering clear of that path.
Let's get deeper into the guiding principles for constructing a robust Spec file.
1. Coverage is Key: When constructing Specs for a specific function, it's crucial to ensure that these Specs touch upon every line of code housed within that function. Achieving comprehensive coverage guarantees that all paths and scenarios within the function are scrutinized during testing.
2. Fail-First Testing: The primary role of Spec files is to examine the behavior of our code under varying circumstances. To achieve this, it's pivotal to encompass an array of test cases, especially those that simulate potential failure scenarios. Ensuring robust error handling necessitates testing for all foreseeable failure instances.
3. Embracing Integration Tests: While Unit Tests are adept at evaluating the logical aspects of our code, they might inadvertently overlook potential issues related to Input/Output operations. To address this gap, integrating and conducting thorough integration tests become indispensable. These tests simulate real-world scenarios, verifying the behavior of our code when interacting with external systems or resources.
And, if getting into test case writing with practical examples intrigues you, I can guide you through an in-depth blog on this subject. Just say the word!
Collaboration and Teamwork
Let's transition to the heavy hitters in our toolkit.
Here's a golden rule to remember: when you hit a roadblock for over thirty minutes, reach out for help. The people around you are pivotal for your exponential growth.
Creating a robust codebase isn't a solo mission—it's a team effort. Despite the outward appearance of our profession as introverted, collaboration forms the backbone of coding. This collaboration extends beyond company boundaries.
Platforms like GitHub, LeetCode, and StackOverflow, among others, illustrate the active and social nature of our coding community. Remember, if you encounter an issue, chances are someone else has faced it, too. Always remain open to seeking assistance; someone out there has likely tackled the same problem before.
Best Practices for Code Documentation
Solid documentation is a game-changer, whether it's for your public API or internal code. Today, though, let's zoom in on the coding aspect.
Ensuring top-notch documentation practices isn't just about easing the onboarding process—it's about gifting your team a treasure trove of clear insights into the codebase. This clarity turbocharges the learning process, benefitting everyone involved.
Pre-Coding, Research
This is ground zero for all the essentials to kickstart a project—right from your system designs to crucial links pointing to relevant documents.
In the pre-coding documentation, you'll find not just the new table structures and modifications but also valuable links to third-party documentation. Remember, thorough preparation lays the groundwork for success; it's like winning half the battle!
During Coding
This is the place where we infuse our code with comments and embed explanations within the code itself. These annotations serve as a guide, unraveling the intricacies and intentions behind the code's functionality.
Post Coding
This is the hub for instructions on utilizing the code effectively and guidelines for future alterations. It's the compass that navigates users through employing the code and outlines the pathways for making modifications down the line.
Conclusion
Great code adheres to solid naming conventions, maintains modularity, follows the "Don't Repeat Yourself" (DRY) principle, ensures readability, and is thoroughly tested. Writing such code demands a blend of documentation, collaboration, and a generous supply of coffee for those intense coding sessions.
If you have any thoughts or insights on how your organization implements these codebase structure principles, feel free to share—I'm all ears!