Writing scalable code
A junior developer's guide to write code that doesn't piss off the next developer
Foreword
I've been meaning to write this since a long time, but haven't been able to.
What follows are my opinions and tips to establishing and maintaining a code-base that's very easy grow without falling into Technical Debt.
If you're starting a new project or are already working on one, you must read this.
What is this Technical Debt?
Definition
Before you understand why you should write scalable code, you should understand what not writing scalable code incurs - the Technical Debt.
Here's a snippet from Wikipedia:
In software development, technical debt (also known as design debt or code debt) is the implied cost of additional rework caused by choosing an easy (limited) solution now instead of using a better approach that would take longer.
If this seems technical gibberish to you, lemme demonstrate this with an example.
An example
Imagine Mr. Phillips wants to start a tech startup. His company relies on some app, and the functionality it provides that bridges customers to some service providers.
Mr Phillips dons the cape of a CEO, and sets out to hire developers, designers and a marketing team, which he succeeds in.
Next, he discusses with the developers what his idea is, and tells them that they need to come up with a POC to showcase to the investors, in a matter of a week!
The developers hasten their work, sacrificing quality code for quantity, and manages to deliver the app within the week. Mr Phillips shows it to the investors, and they are onboard!
The developers are now asked to keep adding new features at a pellmell speed, and the marketing department attempts to market the app.
Huge success! People love the app. The developers keep adding features, and users keep giving feedback.
Suddenly, after a few months, Mr Phillips realises that the dev team's output has drastically fallen consistently over the last few months.
He yells at the devs, inquiring why are they slowing down. In response, they say that without having maintained a good documentation, and comments in code, developers are needing to spend hours trying to switch contexts and recall them, before working to extend the code.
Mr Phillips, being the LinkedIn freak that he is, fires his devs, and hires new ones, and having given them access to the repo, he asks them to start working.
The new dev team looks at the source code, and asks for the documentation.
Mr Phillips: "Why do you need a documentation? Are you people not senior devs, with years of experience?"
Devs: "Because your code-base is just a giant blob of spaghetti, and it'd take us weeks to decipher all of it without a reference, also called, a documentation."
The new devs would struggle to implement a new feature. With every new feature they added, the code-base got bigger and bigger. Each time the developers implemented something new, it became harder and harder to recall things into context to implement the next feature.
Mr Phillips now has a technical-debt. The code-base inhibits itself.
What causes technical debt?
You see, it's much harder to read code than write it. Any developer can tell you that. Why's that? This is because by the time you write code, you've already formed a mental context of what needs to be done, and the code is simply a representation of your thought.
However, reading is different. First, you need to bring read the code, then trace the execution path over and over, then understand the context in which the developers wrote it, and then finally form an overall idea. Now imagine this multiplied by 10, because that's usually the number of places (files) that developers import stuff from, such as state, constants, functions, etc.
Therefore, to not fall into the technical debt, which is just a result of the above fact, all you need to make sure is that reading code is easier. However, this, in itself, is not so straightforward to devs who have never even thought about this.
Code is NOT self-explanatory
Most developers believe that code is self-evident or self-explanatory. However, what they completely miss out on, is that being the authors of that code themselves, it's self-explanatory TO THEM! What if some new developer looks at it?
There's this brilliant experiment called Tappers and Listeners that demonstrates how actually difficult it is to convey the context of a communication to someone who has no initial context.
The experiment, if you don't wanna read that article, is as follows:
You take a song that you know everyone knows, and just tap out its beat, have some listeners listen to it and try to guess the song from the taps. You'd soon understand how difficult it is to succeed. The song plays clear in your head from listening to the taps alone because you know which song you've chosen. Your listeners lack this context.
So how to not fall into this debt?
And now, for the most important part of this article - how to NOT fall into technical debt. If you've been reading what I wrote above, and really felt the problem, you're now ready to accept long-term solutions (and the only solutions) to the problem that most people just label as "time-consuming" and discard.
In my opinion, these are the ways you can make sure your code is easily readable to everyone:
Comments
The first thing that makes code readable is some explanatory text that accompanies the code, known as 'comments'.
Comments exist so that you don't have to read code to understand what it does. Believe me, this cuts down the time needed to gain a perspective of the code so much, that what a commented code can tell you in 10 seconds, an uncommented one would need minutes to tell you that.
Here's a demo of the idea.
Below, you'll see an uncommented version of a function we wrote for our submission Cazzpay. I won't tell you what it does. You don't need to know a specific language to try forming a brief idea of what a code does. Try figuring out what this does. Take your time:
function someFunc(
uint256 _liquidityToWithdraw,
uint256 _minCzpToReceive,
uint256 _minEthToReceive,
uint256 _deadline
) external returns (uint256 czpReceived, uint256 ethReceived) {
address pairAddr = factoryContract.getPair(
address(czpContract),
address(wethContract)
);
require(pairAddr != address(0), "PAIR DOES NOT EXIST");
IUniswapV2Pair(pairAddr).transferFrom(
msg.sender,
address(this),
_liquidityToWithdraw
);
IUniswapV2Pair(pairAddr).approve(
address(routerContract),
_liquidityToWithdraw
);
(czpReceived, ethReceived) = routerContract.removeLiquidityETH(
address(czpContract),
_liquidityToWithdraw,
_minCzpToReceive,
_minEthToReceive,
msg.sender,
_deadline
);
emit WithdrawnLiquidityFromCzpAndOtherTokenPair(
address(wethContract),
msg.sender,
czpReceived,
ethReceived,
_liquidityToWithdraw
);
}
If you haven't worked on a DEX before, chances are, you have no idea what this does. And that's okay. That's human.
Now, have a look into how your perception of the code changes once things are commented:
/**
@notice Removes liquidity from a CZP-OtherToken pair
@notice Caller must approve this contract to spend the required LP-tokens BEFORE calling this
@param _liquidityToWithdraw Amount of liquidity to withdraw
@param _minCzpToReceive Minimum amount of CZP to receieve
@param _minEthToReceive Minimum amount of ETH to receieve
@param _deadline Deadline (unix secs) to execute this
@dev Emits event WithdrawnLiquidityFromCzpAndOtherTokenPair(address indexed otherTokenContractAddr, address indexed liquidityProviderAddr, uint256 czpAmtWithdrawn, uint256 otherTokenAmtWithdrawn, uint256 liquidityTokensSubmitted);
*/
function someFunc(
uint256 _liquidityToWithdraw,
uint256 _minCzpToReceive,
uint256 _minEthToReceive,
uint256 _deadline
) external returns (uint256 czpReceived, uint256 ethReceived) {
// Check if pair exists
address pairAddr = factoryContract.getPair(
address(czpContract),
address(wethContract)
);
require(pairAddr != address(0), "PAIR DOES NOT EXIST");
// Transfer LP token to this contract
IUniswapV2Pair(pairAddr).transferFrom(
msg.sender,
address(this),
_liquidityToWithdraw
);
// Approve router to spend LP token
IUniswapV2Pair(pairAddr).approve(
address(routerContract),
_liquidityToWithdraw
);
// Withdraw liquidity
(czpReceived, ethReceived) = routerContract.removeLiquidityETH(
address(czpContract),
_liquidityToWithdraw,
_minCzpToReceive,
_minEthToReceive,
msg.sender,
_deadline
);
// Fire event
emit WithdrawnLiquidityFromCzpAndOtherTokenPair(
address(wethContract),
msg.sender,
czpReceived,
ethReceived,
_liquidityToWithdraw
);
}
You would now probably have a clear idea of what this does, even if you don't know what a DEX is. You can follow the logic and compliment that with the comment to know what's happening programmatically (as in, the pseudocode). And this took you only seconds.
Documentation
Documentation is what you call the collection of arranged notes about an application/codebase/platform/etc that a developer can easily consult and browse for gaining either an overview or an in-depth understanding about something.
A documentation is usually a developer's first point of contact with a new codebase (or at least, it should be).
Most of you are probably self-taught developers. You learnt from online courses and tutorials, then went about making some project, and Googling for help when you needed it. Often, you might have found better answers and explanations in the actual documentation itself that the original developers created for devs like you.
As an example, take Vue.js's fantastic documentations. Their documentation is so well, I practically learnt Vue from reading their docs in a sequential manner alone! Many Vue developers continue to come back to their docs to consult something from time to time, because of how easy it is to find what you need.
Imagine if that didn't exist! Imagine that a fantastic UI framework like Vue existed, but there was no point of reference to consult to even start understanding what it does. Can you imagine the chaos? People wouldn't probably even use it that much.
Just like any other skill, the balance between the slope of the learning curve and the benefits of learning something determines whether some framework would be widely adapted or not. Don't believe me? Try looking around and find out how many codebases use Angular vs other frameworks like Vue.
When the learning curve is easy, developers are much more willing to take on complicated frameworks and platforms! Documentation help in making the learning curve favourable.
In addition to this, human brains aren't electronic storage devices. Our capacity to store might be infinite, but our capacity to recall certainly isn't. A good documentation gives joy when you need to consult something you need to brush up on.
Let's see this with a demo.
This is the repo for CazzPay. I want you to go and look through the code WITHOUT reading the whitepaper, and try figuring out what CazzPay does.
You can probably do it, but it'd take you hours to develop the intuition for it.
Now, go read our Whitepaper. It definitely isn't a documentation (didn't get time during the Hackathon), but reading it would give anyone a clear idea of what CazzPay does.
So if tomorrow, someone wants to fork our work and develop on it, they'll have a decent point of reference to know what CazzPay is all about. (Our whitepaper is lacking. Just treat it as a POC whitepaper).
Architecture
This one is highly contextual.
Architecture here refers to how your codebase is structured. This structure can refer to many things, such as - directory structure of your project, or indentation and other formatting in your code.
Directory structure
When multiple developers work on the same codebase for a long time, this is a problem that is bound to happen if a proper architecture is not decided.
Imagine a web-app, where you have all images in a /assets/img
folder. Some developer might come tomorrow, and start putting images in /img
folder, if he does not know about the already existing one. Then, some other developer might put it in /assets/image
.
This may sound stupid, but it happens in lots of codebases, often leading to multiple copies of the same files and/or convulated directory structure, making it harder to search and use something.
There are many approaches to laying down a strong, unified directory structure. If your framework is agnostic of the directory structure, I'd strongly recommend the Atomic Design way of doing it. Of course, keep in mind, that you may have to adapt it for your project, making adjustments wherever a better optimised way may be possible.
Here's a simple rule I follow when I'm creating my directory structure: modules that need each other would be closer in the directory tree than those that wouldn't.
What it means is that, you try to optimise your imports, for example, by keeping commonly used stuff in a different folder, and less commonly used stuff in a higher up folder, and so on.
As an example, say a module M requires modules A and B, but does not require C, which might be needed by other modules. Further, A and B are needed only by M. It'd make more sense to put M, A, B together, than have A and B placed globally, and having M import from project root. On the other hand, since C is needed by other modules, it makes more sense to place in globally.
Formatting
Newbie developers might be led to believe that incomprehensible, complicated blocks of code / one-liners are cool, and shows mastery over a language.
However, it's quite the opposite. If you're truly skilled, your work would be simple. If not, you'll make it complicated. Same for code as well.
Aside from a complicated fashion of coding, some developers also don't pay much attention to visual formatting of the code, including indentation, spacings, etc. Remember, since the aim is to make your code as easily readable as possible, you cannot ignore this.
Code that is written in a simple manner is easily understandable and modifiable. Plus, it's pleasant to stare at something lucid and clear than convulated.
There's no fixed rules for formatting, since most languages would ignore extra spacings and stuff if they're not a part of the language. For instance, you can write all of JS in a single line if you wanted to, and it'd work the same. But, you'd bang your head on the wall every waking second of your life trying to work on it.
The solution is simple: write code in the way that even glancing at it makes you easily understand what the code tries to do. Here's some generic advises:
- Avoid long lines / one-liners. Split up complex functionality into multiple lines. That's why lines exist.
- Group together blocks of code that perform something as a whole. (and comment them).
- Use recognisable, meaningful variable names. This largely depends on the context and what names you've already used before. Different languages have different accepted conventions. Google and read them.
Type-support
This one is a bonus for untyped languages.
Certain languages, like C, Dart, etc, have type-support not only build-in, but is part of the mandatory syntax as well. These languages often, thus, come with an extensive toolset that leverages this type-support, and often displays helpful comments, advise, techniques, tips, etc, about a part of code when you hover over the code in an editor like VSCode.
However, some languages, like JS, has no mandatory type-support.
My advise to you would be, to use type-support features wherever possible, even if that means spending some more time defining those types. For example, with JS, you might wanna migrate to TS, and write some types for the classes you're using.
What this will do, is tell developers exactly what a certain variable is holding / a function is accepting (or returning), and then your comments/docs can further elaborate.
Sometimes, this is very useful and indispensable, especially in large codebases with hundreds of possible data-types.
Conclusion
This concludes my solutions to write scalable code.
There's no silver bullet to make your code better; each of the solution I talked about here supports one another, to make an ecosystem of healthy code.
Remember, that besides a good user experience, a good developer experience is needed too, to keep any app in a constantly optimised development run.
Following these solutions requires you to allocate some resources to it - time and energy, separately than the actual coding/testing phase. Often, there's a pressure to complete features in a deadline. It becomes difficult, in those situations, to constantly having to think about good coding practises.
This is exactly where you need to find the sweet spot between fast development and healthy development.