DEV Community


Posted on

Recommendations For Writing Better Code.

Hopefully, the title caught your interest and more so the choice to use the word recommendations. I wanted to emphasize that these are recommendations, not rigid rules, for writing better code. The word "rules" implies that what I am writing has some authority to govern every software project created from this moment forward and all projects should adhere to this list, and that is just unrealistic.

Let's back up

Software projects vary greatly in their nature, and each project operates within its own unique context. Technology is constantly evolving, introducing new paradigms and approaches. It's because of this that these recommendations aim to provide guidance that can assist you in writing more effective code. The goal is to lower the cognitive load, improve code readability, and facilitate easier maintenance and comprehension. It's important to keep in mind that blindly applying a random set of suggestions to an existing legacy monolith during your first week on a project may hinder rather than help achieve these objectives. Don't say I didn't warn you.

Cognitive Load

Cognitive load refers to the capacity of our brain's working memory and its ability to retain information or context without becoming overwhelmed. Remember the first time you encountered a codebase you had never worked in and attempting to debug an error. Navigating through the code, tracing its flow, and understanding its purpose requires you to hold various details in memory simultaneously. Cognitive load theory says eventually you will exhaust that working memory and not be able to keep all of that context in memory. I know I have felt that sudden confusion when trying to track through file after file to see where some function is included or digging to find what some random array of integers means. This is part of the reason I became such a huge note-taker when looking through new code.

The Recommendations

When reading these recommendations you will need to keep in mind the context of your projects and find a balance that makes sense for those projects. Again, what works in one project likely will not work in every project.

These recommendations will generally try to achieve the following goals:

  • Make your code easier to maintain and work in
  • Improve the readability of your code
  • Decrease the cognitive load required for new developers who inherit your code
  • Make it easier to debug and troubleshoot your code

Functions should be concise.

I can't sit here and tell you functions should be no more than four lines and that's it! That is not practical, it can be done, but it is not realistic. It adds way too much to the cognitive cost of your code which eventually makes your code harder to follow and work in. Instead, your functions should be concise units of code. A concise function presents information clearly and comprehensively, minimizing redundancy. This helps improve code readability and reduces the cognitive load required to understand the function's purpose and behavior.

Use meaningful, descriptive, and searchable variable and function names.

Choose variable and function names that provide context and accurately describe their purpose. Meaningful and descriptive names help other developers understand what the code is doing without needing to dive into the details. Does this mean you always need to write someSuperLongVariableName? No, keep in mind the context of your function or the unit of code you are working on, sometimes an abbreviation makes perfect sense in the context of the rest of the code. Giving functions and variables meaningful names helps with autocomplete as well, a developer may not have to spend time figuring out which function to use if the name accurately describes the purpose of the function. This also applies to making these names searchable, it's a lot easier to find the correct line of code for userPhoneUniqueId in a code base than just id.

Consistency in naming conventions is crucial, decide on a pattern of prefixes or patterns and stick to them across your code base.

Comments should add context.

I know, I know... "never use comments." Again, this is impractical, there are times when comments add important context or insight into the nuances of a code base for future developers. So, yes use comments but make sure they add context to the code and are not just describing the obvious. Add enough meaningless comments in your code and future developers will ignore those comments because of the cognitive cost of trying to add all of them into working memory. Make your comments important and offer clarity to future developers.

Use consistent error handling

This one I think often gets ignored, but having a consistent and predictable way of handling errors and exceptions makes both debugging and future development easier. This should include gracefully handling errors and developing a system or guidelines for warnings, notices, etc. Define clear guidelines for error handling, including how errors should be logged, reported, and propagated throughout the codebase. You should also avoid suppressing errors, there is nothing more frustrating than realizing the hour you just spent pointlessly debugging code was caused by errors being suppressed earlier in the code. This should be something set in your code styles that are enforced through code reviews.

Avoid overly complex conditionals and control flow

You can reach a point with complex conditionals and control flow that your code is almost impossible to follow. This makes debugging and maintaining that code harder as time goes on. Future developers will have to spend more time ensuring they understand the paths the conditional can take or the complexity of the control flow before actually writing or updating code. Conditionals and control flow should be as simple as they can be and abstracted to functions if need be. In some cases, you may reach a point where it makes sense to introduce a new design pattern to handle control flow or switch to assertions to handle conditionals. This all depends on the project, but this type of complexity creates huge amounts of technical debt as other developers move into the project and you move on from it.

Readability also suffers from complex conditionals and control flow, your code just gets harder to understand. Remember, simpler code is often easier to comprehend and work with.

Avoid deep nesting

This is similar to avoiding complex conditionals and control flow as it has negative impacts on readability and is harder for developers to quickly understand what is happening. You should avoid deeply nested loops and conditionals. This will make your code easier to maintain, easier to test, and more resistant to defects. The less complex the unit of code is the less likely it is to break when another developer comes and changes it. Deep nesting is a perfect example of an area that is hard for other developers to work with before spending time gaining context into the code. If you are nesting this deeply you probably need to refactor your code and abstract away some of the functionality into additional functions or look into alternative design patterns. Generally, I think at about 3 levels deep alarm bells should start going off that you may be reaching a level of complexity that is going to be difficult for other developers and it may be time to refactor. By 5 levels deep you need to start thinking about refactoring or optimizing your code. Breaking down complex logic into smaller, self-contained units not only enhances code maintainability but also promotes reusability and testability. Strive for flatter and more concise code structures to enhance the overall quality of your codebase.

Prioritize readability and avoid unnecessary complexity

You're never going to be able to remove all complexity from code, that is not possible. The idea here is to emphasize well-written and efficient code that is easily understood by all developers over the cool one-liner that will take all of their cognitive load to understand. That level of complexity is usually not necessary and not worth the saved line or two. Code that is easier to read is easier to maintain, complex optimizations should be made only if it offers a substantial performance increase.

Readability usually also goes hand in hand with using descriptive and meaningful names for variables and functions. It adds to the overall quality of your code and lowers the cognitive cost for new developers. Code is read more often than it is written, so spend the time making your code readable and understandable.

Separation of concerns

Another important principle in software development is the separation of concerns. This practice will enhance your projects maintainability and flexibility for future upgrades or replacements. For example, you should isolate view templates from business logic or controllers. This separation allows for independent maintenance and updates, making it easier to manage and evolve each component individually. Another example is using data models instead of inline database queries. By decoupling database operations from other code sections, you improve maintainability and enable efficient updates to the core functionality or dependencies without impacting other areas of the system.

Use consts or enums for magic values

This should go without saying, but it is way easier to understand a constant or an enum than a random integer or cryptic string. The cognitive load that comes with remembering an entire array of integers that all have additional meanings can make code impossible to efficiently work with. The readability and context improvements your code will gain from using constants or enums are worth the extra keystrokes every time.

Follow the principle of least astonishment

Write your code to behave in a way that is expected and logical. Code that is counterintuitive, surprising, or acts illogically adds to the cognitive load of other developers, is harder to maintain, and is harder to troubleshoot.

Use design patterns where it makes sense to

Overuse of design patterns can add complexity to your project where there doesn't need to be. If you are adding a design pattern to solve a one-off issue it may not be the best answer. An example would be adding a Command Pattern for a one-time save, this adds complexity without really any reward. If you are writing a text editor the Command Pattern might be a great way to reduce complexity and make your code easier to maintain and update. It just depends on the project itself. You should be aware of common design patterns and comfortable defining when they should be implemented. Most of the time this seems like something that is added on a refactor when you start abstracting away repeated functionality.

Encapsulate frequently reused code or code that causes side effects

Code that is repeated more than a few times is probably a good candidate to be abstracted away. Sometimes this is a utility class and sometimes it is a base class. That depends on the functionality and the project itself. Code that is creating something in your system, like files, should be abstracted into a library or isolated functionality so it can be heavily tested and shared throughout the code base.

Keep important services modular

At times you may need to break down your code into smaller, self-contained modules that facilitate easy testing, reusability, and updates. When appropriate, break down core functionality into modules that can be interacted with by other parts of your code through well-defined interfaces. This approach helps readability by having common solutions that are focused on a single responsibility, making them easier to learn and understand. An example of this would be creating an HTTP library or service for your app if you are making a lot of API calls. These modules are also easier to test effectively since they are defined in one place and not repeated throughout your code base. The last major benefit of keeping your code modular is that future updates to these modules only require you to update the module and not everywhere the feature is needed in your code base.

Don't repeat yourself... too much

Sometimes it is perfectly fine to repeat yourself to prioritize the readability of your code. It comes down to the project and the context of your code, but in some cases abstracting away some functionality into a function in a utility class because it is repeated a few times may make your code harder to maintain and learn. In some cases, this may add a level of complexity your code base doesn't need, just to say you followed DRY. So really, repeat yourself a few times and if you start getting to that point of repeating that unit of code more than 3 or 4 times start thinking about refactoring, if it makes sense with the context of your project. In some cases repeating something ten times may be perfectly fine. Think about using a random number built-in function for unique IDs, do you need to abstract that away? In some projects, it might be worth it, in others maybe not.

Classes and functions should have a single responsibility

This is more of a concept-based single responsibility and not a hyper granular abstract everything away into a series of miserable functions kind of single responsibility. Again, it depends on the project and context of your code so you need to find a balance in your project that works. Keeping classes and functions focused on a single concept makes it easier to work within those modules. These classes or functions can do multiple things, but they should all be related and critical to a core concept. For example, a function updating a user account might perform some validation or formatting of the user data, should this be broken out into multiple functions? I don't think it always should, it still is a critical part of updating that user account so it can happily belong in the same method. If that functionality becomes robust then it might be time to break it out into a validation or formatting function just for readability.

Type returns and function arguments

This helps from a defensive coding, readability, consistency, and resiliency standpoint. Using types helps define what your code is doing and is part of telling other developers what it should be doing as well. It adds context to your code that you may otherwise need to add comments for developers to understand. It protects your code from future changes that may break functionality as well.

Use testing to validate behaviors

Implementation tests have a serious role in making sure critical code units are working as expected and that should always be a part of testing. Alongside this, you should be validating the behaviors of your code from start to finish. This helps ensure that the code is functioning as expected as a whole. This style of testing focuses on the outcomes, giving your code a certain state will result in a specific outcome. This way you know the entire user creation workflow, login workflow, etc work from start to finish as an end user would go through it. I'm not talking about integration or UI testing, just testing modules as a whole behavior. Testing like this should cover as many paths/branches as possible and should increase the confidence of other developers when they make changes to that code.

That's a wrap

Well, that's it, those are my recommendations for writing better code that is easier to maintain, read, and inherit. Not all of these ideas will work everywhere in every project you should always prioritize readability and what works best for the project in front of you. In the end, if you only can utilize one of these ideas in a project it may just make it a little easier for the next developer.

Top comments (0)