The Ground Rule:
1) Maintainabilityis always the first priority when making any decision. (Please refer to following section for detail)
4) Horizontal dependency should be minimise
5) Although you don't write test now, but your code should be testable in following manner:
- Method with heavy logic should be separately test
- All dependency should be able to be mock/stub/spy
- Combining above, it should be able to test all different logic flow in that method
6) Single responsibility is mandatory on method level and also try to apply when you are designing a class/module
7) Third party libraries are welcome, but please value the usability and trade off before you introducing an extra dependency. Also, make a prediction of how wide that dependency will spread through your application, how many effort will you take to remove or change to another similar dependency
8) For node server, consider any highest domain object or say conceptually independent component as a isolate island, a self sufficient module, a plug and play item for your next application
Picking Web Framework:
For developing backend project with node.js, we prefer to use well known lightweight framework as the foundation, like Express/Koaor provide similar API.
The reason of using Express/Koarather than other 'so convenient'/'super ultra'/'blazing fast'/'amazingly reduce your work'/'crazily speed up development' superb framework, is because the maintainability.
But it come to last is still a team and project wise decision, whichother frameworks are actually welcomed, but the team should make a fare estimation to see if the framework can meet the certain criteria. For example, the learning curve, if the framework is difficult to learn, but it's definitely help the project with it's other pros, and team are willing to commit overcome this cons by architecturally abstract away the framework detail, provide a good and easy to extend and maintain interface, so colleague can easily join the project, than this is totally welcome.
Maintainabilityin this context included:
- Readability, how teammate can understand you and provide helpAll naming convention, coding styleAll parameter passing and the number of implicit state(e.g this.shouldContinueBaseOnConfigSetByAnotherMethod)Understanding overall flow of a single function call's inherency, which in simple word, explicit routing
- How well it can work with debugger
- Able to fix an issue on desire level
- Documentation of your work, but normally, we are talking about their works (the dependencies) here...
- And learning curve for your project of course
The main idea to organise our project (or everyones) better, is to isolate them. it does not just help us to reduce the risk of facing error, it also help us to speed up the bug fixing process.
Isolated components (module, what so ever) are also more easy and strong enough to be re-used. So we should always try to consider we are writing anode_module(if it help you to understand the concept better), we will then ask ourselves, how easy user to use our module, how difficult our user can extend our feature, and how worse can a user wrongly use our module.
This methodology help us to write better and stronger code, and allow that strong and good code piece to live in more and more projects.
Dependency control. No matter the dependency is from outside world or inside world, control it.
We should always keep in mind that adding dependency is always a lot more easier than removing them. But most of the time, they are also useful so we cannot omit them.
The proper way we should do is not to either use them or don't use them, but control them. The art of control the dependency, is to eliminate the cost to replace/remove/switch them (always keep this in mind).
Another thing is to control internal dependency. Don't import concrete class, instead, import `idea`. Also, try making as few as common/util/helper as possible since those common/util/helper since those file normally be used by many of class, and turn out the common can be not that common. After all you class adopting one concrete implementation and few of the might want to change, or one but is found in one of those class using this file when the others don't, it will be so hard to tell you should or should not or how to fix it.
We will talk about the base of every project, and share some ideas around implementation topic.
For node projects, we should all align in one basic standardin order to work well with morden CI pipeline and development environment. While we mainly use docker to deliver our product, therefore, the way we structure our project, may/may not benefit us from docker build time optimisation and the easiness of setup project on Jenkins.
To achieve that, a very basic and common folder structure come to:
- ./YOUR_PROJECT - app // or src, lib whatever the main application resist in) - package.json // So we can separately copy in early build stage - package-lock.json // See if you want to commit since it can lead to a flight... - .XXXignore - .XXXrc - Dockerfile // If provided - docker-compose.yml // If provided - readme.md - .some-ide-config // if some config share across the team can save other teammate's setup time, keep it!
Inside the ./app - the main war zone, we will talk more about this.
I will show an example, it's not mandatory, even not a main stream way, but a good example to explain the idea.
Consider we have the following structure:
./app/ - index - module/ - moduleA/ - model/ - data-access - controller - managerA - index - moduleB/ - model/ - data-access - controller - managerB - index
And we have some rules:
- Any file in moduleA can not directly "require" other module out of passing by it parent. (all require begin with relative '../' should be prohibited), therefore the no matter what other modules are, it will still live it's own way. Treat everything outside is a dark hole.
- Index, as module's only entrance, it's responsibility is to bootstrap the module, including initialise it's internal classes, receive external dependency and mount the resource provide for external use
- Data-access class (for example i call it data provider, call what ever you want) is the only one concern and touch the db. All data persistence logic should be hide inside. No other class will touch that specific Schema or Model. It contain only week logic for access the data warehouse
- Manager or what ever you call will be used for strong business logic. It's not always required in some simple mainly CRUD module. But when you see you controller grow, and data access layer contain logic that is too heavy (touching two data provider with certain rule). It might be a good time to create a manager
- Controller is the highest level of your application, it only resolve the routes, req's data dispatching, and control the logic flow in-order to get a valid response. Which means, it should not be logic heavy. It's the consumer of data-providers or the managers. (One of the reason why it should care about routes is, lot of time, the routes is actually implicitly declare resource be used in a controller, like the params)
- Application only need to bootstrap itself and do necessary configuration. After that, mount the module to be used and manage the application wise dependency.
Then we will have a graph like:
App ---> ModuleA ---> Index ---> data provider | |-> controller |-> ModuleB |-> manager | ...
And we can see, all component are vertically isolated. When a module's implementation detail changes, as long as the api it exposed doesn't change, the other component won't be affected. And since in this architecture, we eliminate the number of api got exposed(most of the time, the data access layer, and when the logic is so light, the api should be quite stable).
Turn out, we are now have an internally flexible, externally strict foundation for us to work on.