How to Structure Your Codebase?

Congratulations! You just learned to code and got your dream job. But what next? Do you know how to write “good code”? What are the practices that you need to follow? Or how to structure your code base?

If you have a driving license, you are familiar with the ups and downs of a government process. 

Just because a system works, it does not mean it’s good. It’s frustrating to go through the whole process of getting a license. I certainly wished for the process to be faster, less stressful, and easier to understand.

Now imagine the same environment created in your workspace because of your code. Your code must be readable, DRY(Don’t Repeat Yourself), and modular.

Importance of Codebase Structure

“There is code that makes you exhale a long, relaxed and proud “ahh” when you look at it. Write that.”

Daniel Ciocîrlan, Rock the JVM.
Source: LinkedIn

Poorly structured code can be difficult to read, debug, and maintain. It can also lead to bugs and security vulnerabilities. 

Well-structured code, on the other hand, is easy to understand, modify, and extend. It is also less likely to contain bugs and security vulnerabilities.

The Codebase Challenge

Let’s say you work for a sales company, and your company is using SmartReach to do your sales outreach and manage your prospects. 

Your company gives out bonuses based on how many prospects are interested. 

You are asked to make a new sales monitoring app for phones. On the App, a sales executive can track interested prospects in SmartReach and maximize their productivity. 

The App is making a GET call to SmartReach public API and giving us updates on our prospects. Let’s try to write a good bit of code and let’s see how to structure it. 

Look at this code below and try to understand it. Do you think there are any errors in it? 

class Api {
   def get(p: Int) = Action.async(parse.json) { request =>
      ws.url(s"https://api.smartreach.io/api/v1/prospects?page=$p")
  .addHttpHeaders(
    "X-API-KEY" -> "YOUR_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]))
    }

  })
   }
}

This is a sample code that I have written to do the same. 

We’ll work on this code and make it more readable and appropriate. So that when you read it, you don’t get a headache. 

We will write the code in Scala. Scala is a JVM-based language. It is both an object-oriented and Functional Programming language. 

But the things we will see today will apply to coding in general. Let’s get into the refactors!

Codebase Refactors

Naming Conventions and Code Comments

We live in an age of abbreviation, and it’s great for texting but not for coding. 

Descriptive names for variables, classes, functions, and objects are crucial. Think of your friend who will review the code or refactor it. You need them to stay your friend after they work on it, won’t you? 

Add comments while you are at it, and make your coworkers’ jobs easy! Adding comments might not seem important, but when you or your coworker is debugging it later, you’ll appreciate it. 

Let’s see how our code looks after we implement these principles.

class ProspectsApi {


   def getProspectsFromSmartReach(
                                   page_number: Int
                                  ) = Action.async(parse.json) { request =>

      //public GET API from SmartReach to get the prospects by page number
      //Docs - https://smartreach.io/api_docs#get-prospects
      ws.url(s"https://api.smartreach.io/api/v1/prospects?page=$page_number") 
  .addHttpHeaders(
    "X-API-KEY" -> "YOUR_API_KEY"
  )
  .get()
  .flatMap(response => {
    val status = response.status

    if (status == 200) { 
       //checking if the API call to SmartReach was success
       /*
        {
          "status": "success",
          "message": "Prospects found",
           "data": {
                    "prospects": [*List of prospects*]
                   }
          }
        */
        val prospects: List[JsValue] = (response.json \ "data" \\ "prospects").as[List[JsValue]]
        Res.Success("Success", prospects)
    } else {
      //getting error message
      //Docs - https://smartreach.io/api_docs#errors
      val errorMessage: Exception = new Exception((response.json \ "message").as[String]) 
      Res.ServerError("Error", errorMessage) // sending error to front end 
    }

  })
 }
}

If you were Paying attention, there were errors in the code! For example the parsing for the prospect was not correct.

Adding some more context (with the help of comments and a good naming convention), we avoided those errors!

Codebase Organization and Modularization

Everything is sitting together. Our code is looking good, and the application is pretty simple. Now that our code works and is much more readable, let’s move to the next issue.

Imagine adding a filter system to this code. It will make it look drastically more complicated. What we would need is a more modular codebase.

For modularization, we will break this code into smaller blocks, but how do we decide which block lives where?

Directory Structure

A directory is a folder where your code stays, so make a new folder, and there is your directory! 

A directory can have your code files, or it can have more sub-directories. In our case, we will go for sub-directories. We will divide our code into four parts: models, DAO (Data Access Object), Services, and Controller. 

Of course, your company or app can go for a different directory structure.

Model

Models are the objects that we create to represent  data. For example, let’s see how our Prospect model will look.

case class Prospect(
                     id: Long, //id of the prospect in SmartReach 
database
                     first_name: String,
                     last_name: String,
                     email: String,
                     company: String,
                     city: String,
                     country: String,
                     prospect_category: String // Prospect category in 
SmartReach
                     // can be "interested", "not_interested", 
"not_now", "do_not_contact" etc
                   )

Data Access Object (DAO)

As the name suggests, we are looking at an object that will give us data access. It could be calling your database or third-party API. 

We will avoid adding logic in these files since we want this layer to be about pure IO operations. 

IO operations are the calls you make to an outside entity or an outside entity calling your system. This is where you can always expect failures. So we need to have a safeguard here. 

In Scala, we get Monads that help us capture the failures. We are using Future here.

We will get the data from our source and put it into the respective model.

class SmartReachAPI {


   def getProspects(
                    page_number: Int
                   )(implicit ws: WSClient,ec: ExecutionContext): Future[List[Prospect]] = {

      //public GET API from SmartReach to get the prospects by page number
      //Docs - https://smartreach.io/api_docs#get-prospects
      ws.url(s"https://api.smartreach.io/api/v1/prospects?page=$page_number") 
  .addHttpHeaders(
    "X-API-KEY" -> "YOUR_API_KEY"
  )
  .get()
  .flatMap(response => {
    val status = response.status

    if (status == 200) { 
       //checking if the API call to SmartReach was success
       /*
        {
          "status": "success",
          "message": "Prospects found",
           "data": {
                    "prospects": [*List of prospects*]
                   }
          }
        */
        val prospects: List[prospect] = (response.json \ "data" \\ "prospects").as[List[Prospect]]
        prospects
    } else {
      //getting error message
      //Docs - https://smartreach.io/api_docs#errors
      val errorMessage: Exception = new Exception((response.json \ "message").as[String]) 
      throw errorMessage
    }

  })
 }
}

Service

This is the magic layer. 

This is where our business logic stays, so this is where we will add our filter. And you will see how easy it will be for us to add more logic in this part of the code.

class ProspectService {
  val smartReachAPI = new SmartReachAPI

   def filterOnlyInterestedProspects(
                                     prospects: List[Prospect]
                                    ): List[Prospect] = {
      prospects.filter(p => p.prospect_category == "interested")
   }
   
   def getInterestedProspects(
                              page_number: Int
                             )(implicit ws: WSClient,ec: ExecutionContext): Future[List[Prospect]] = {
     val allProspects: Future[List[Prospects]] = smartReachAPI.getProspects(page_number = page_number)

     allProspects.map{list_of_prospects => 
     filterOnlyInterestedProspects(prospects = list_of_prospects)

     }
   }
}

Controller

This layer is the point of contact for your APIs. 

It might get called from your front end or a third-party user (via your API). We will get all the data we need from the call, and after the processing, this is where we will send the response. 

We will avoid logic here too, logic always goes in the service layer.

class ProspectController {
  val prospectService = new ProspectService

  def getInterestedProspects(
                             page_number: Int
                            ) = Action.async(parse.json) { request =>
     
     prospectService
       .getInterestedProspects(page_number = page_number)
       .map{ intrested_prospects => 
          Res.Success("Success", intrested_prospects)
        }
       .recover{ errorMessage => 
          Res.ServerError("Error", errorMessage) // sending error to front end 

       }
  }
}

class ProspectController {Our code is now cleaner and easier to manage, allowing for more efficient refactoring. 

We also have clear points of reference for adding logic, making database calls, or integrating new third-party APIs.

Testing and Quality Assurance

Testing can seem laborious and repetitive, but it will be your friend if you know how to use it properly. Don’t worry; we will not write any more code files. 

Let’s see some principles we follow for a Spec file to be good.

  1. Coverage: When writing Spec for a given function, the first step is to make sure that our Spec touches all the lines of code in the function.
  2. Fail first testing: The Spec file is to check how our code works under different cases. It is crucial to cover all possible failure cases to ensure proper error handling.
  3. Integration Tests: Unit Tests can only cover logic, but there can be issues with IO! One way to cover that is to run integration tests.

 If you would like to read a blog on writing test cases with examples, let me know!

Collaboration and Teamwork

We have seen a lot of tools in our arsenal. Let’s move to the big guns. 

The people around you will be the ones helping you grow exponentially. Remember this as a thumb rule while working: call someone if you get stuck at something for more than thirty minutes.

It is the responsibility of the entire team, not just an individual, to create a solid codebase. Our profession might seem very introverted from the outside, but collaboration is the basis of coding. And this collaboration exists beyond companies. 

Places like GitHub, LeetCode, StackOverflow, and many more show how involved and social our community is. Remember, if you are facing an issue, you will find someone who has come across that issue before. Always be ready to ask for help.

Best Practices for Code Documentation

I believe that good documentation is crucial both for your public API and internal code. Since we are talking about coding here, let’s focus on that here.

But remember, adhering to best practices for code documentation will not only facilitate a smoother onboarding process but also provide you, and your team with clear and comprehensible insights into the codebase, accelerating your learning curve.

Pre-coding, Research 

This is where we place all the data needed to start the project, from your system design to all the links to relevant docs. 

Pre-coding doc will include your new table structure and changes and links to third-party documentation. Preparation is half the battle won!

During coding

This is where we add comments to the code and explanations in the code itself.

Post coding

This is where we add the directions on how to use the code, and how to make changes to it in the future.

Conclusion

Good code follows good naming conventions, has modularization, is DRY, readable, and is covered by test-cases. To write good code, we need documentation, collaboration, and a lot of coffee. 

I tried giving examples of these and how we follow them in the SmartReach codebase, but how you achieve these principles in your organization might differ. 

If you have any insights on these codebase structure principles, do let me know.

Loved it? Spread it across!
Scroll to Top