JS Code extensibility in practice
Takeaways
Extensibility is two things:
- “modification stability buffer”
- ease of refactoring
To refactor easy
- never export defaults
- good naming
- DRY
- always remove “dead code”
- test coverage: unit testing & integration
- deprecated flow
Preconditions
Here I’m going to touch only code that is mutating over time. Obviously if we can predict the code is “static” it is not worth time thinking about maintainability of it much. This essay is focused on Java Script applications though the core principles are applied to every language.
Extensibility as a function of the time
The code is extensible within certain timespan. Sooner or later it is going to be refactored. This is inevitable property of mutating code. One of course can try to foresee all possible future use cases and design and develop some piece of code (let say a module) to work on those use cases as well as on current ones.
But that would mean unnecessary time wasting since we don’t know if those future use cases will be in-demand, since business requirements are changing along the way.
So we know that for some time our code is extensible enough. Precisely speaking it is the number of refactorings it can sustain rather than pure time in weeks or months.
Mutation stability buffer
You can think of it as an “array resizing” in memory fixed languages (e.g. in Java). Lets say we’ve allocated an array with size 10 and populated it with 5 items. We have 5 more to add — our buffer. When we reach the limit, the size has to be increased (double, half the size, etc, depending on the case). Same goes with code extensibility. We develop a module that is easy to mutate for some time. Then after certain amount of modifications (adding features, fixing bugs), modification of the module in question becomes harder and harder: no DRY code, difficult to test, hacks and other nastiness. At this point we decide to refactor the piece of code and then maintain it with ease within next modification cycle, i.e. adding a “mutation stability buffer”. It can be measured in number of new features it can sustain without substantial refactoring.
Refactoring as a constant part of the process
So we’ve detected that mutating code will have to be refactored ones in a while. Then the question comes how to make the process easy and cheap. First, i would say if you afraid to refactor your code — you don’t own it. Then a number of rules can be applied to reduce an error factor. Some of them are applied for JS only but the rest can be used in any language.
Never add default export (ES6 modules). I personally work in WebStorm and it is lot easier to rename symbols (e.g. functions), folders, files, move folders files, use hinting when importing types, etc. Might be IDE related. But also, when exporting as a symbol (no default) you control how it is imported. Of curse one could use alias “as”, but that would be an extra work and developers are usually lazy to think on such small things. Whiles if it is a default export, then user creates its own name on the fly, which can be totally different from the real one. Like “cow” is imported as “camel”. Nevertheless these animals have some things in common (like chewing hobby), the meaning is different. And the meaning coming from naming contributing a lot to “mutation stability”.
Good naming. Some say good naming is half success of the program. The explanation lies in the way we think. If the cow is imported as camel, one might think he or she can easily come and softly palm it, though with camel this might end up being showered in spit. And in reality, when good naming infiltrates the code and glues it, the application can be seen as a whole, a mentally shaped world where you don’t have to think much on small things, because overall conventions make understanding application structure much easier. So, before naming anything, be it: variables, functions, classes, folders, modules — always spend some time on it beforehand. I personally use thesaurus to find different synonyms and antonyms. Also, my own view — naming should be short. Not too short, not long — but balanced, as everything genius in nature.
DRY. This principle is well know. I would add that however there are some exceptions to the rule, most of the time it should be followed strictly.
Cut off dead code. My personal habit — the time the piece of the code is not used — remove it; hard, with no regret.
Testing. There is a saying: “running application without testing like skydiving without parachute”. I couldn’t agree more. Testing is what gives that very confidence in your code. Testing brings ownership on your code. The refactoring becomes a joyful process with no fears. TDD testing is a great methodology. I used to did not follow it strictly. Now i would say — always fail first. Event if you don’t have anything written yet. First fail function test — then only create a function and pass the test. Otherwise chances are to have “falsy tests”. The test pyramid gives some insight on the matter of possible efforts spent on testing. From the behavior driven practices we could catch the idea to cover in order of importance of the part of the code. For example in react-redux application, i would always test any reducer, any utility function. This comes with no effort and is not worth thinking on importance. Then it comes to integration tests. First of all, these should be convenient to write. If you catch yourself on the thought that writing the test is tedious — the testing infrastructure itself requires rethinking and refactoring. Testing should be always fun keeping in mind how this will help you in the future. And then, even if we’re comfortable with our testing environment, integration testing is more time consuming and test cases should be selected based on the so called “business value”. The BDD methodologies (like Given, When, Then amigos) can give you some insight on how to choose wisely.
Deprecated flow. There is a chance you can reconsider your code and decide to refactor it drastically. The structure can be changed, the naming undergoes major rethinking, etc. Let’s say you have some module named A. First, rename it to A_deprecated. At this point everything should be working as before, no breaks. Normally this is only a matter of renaming a folder in IDE (if you followed the rule #1). Then recreate you A module from blank. Implement things you need. Some might be dropped, some copied from A_deprecated. Then refactor places where A_deprecated is used with A. When you’ve finished with the process and nothing is dependent on A_deprecated anymore we can safely remove it. The bigger refactoring is the more benefit this approach gives. Because this way we can work gradually and in parallel with ongoing main bug fixes and so on.