Ep.05: Express (Basic app, Node Backend Frameworks Comparison)
Teolin Codreanu
Software Developer | Microservices / APIs in Node.js, JavaScript, TypeScript, Nest.js / Koa / Express etc
This article is part of an in-depth comparison series of the top Node frameworks. We'll cover all the important aspects: market share, learning curve, ecosystem, security and more. To compare them properly, we will build the exact same app in all these frameworks (plus Vanilla Node), observe all the steps along the way and then benchmark them as we progressively add more functionalities.
Table of contents
You thought this day would never come and we'd be preparing ad infinitum. But lo and behold, we're finally getting started. And what better place to start than the father of all frameworks, Express.
Now, if you never started a project from scratch, there are a few ways to go about it. Since I created a todo-template repository on Git already, I used the convenient 'Use Template' feature to start a new one, cloned it locally with 'git clone', renamed the project in package.json, and installed express and nodemon ('npm install express nodemon'). We need nodemon as well because we don't want to restart our app with every single change.
A brief intro for Express is in order. It describes itself as a "fast, unopinionated, minimalist web framework". Well, what's unopinionated? It means that it does not impose any architectural model, pattern, or structure. Basically, you are free to do anything. Which is great if you are in a team of one! Add more people though, and you're gonna start needing some Advil for those headaches because where there are two developers, there will be 3 opinions.
What about minimalistic? Compared with other frameworks, Express provides very little out of the box. Any other functionality you need, you're going to need to create or install a pret-a-porter package. Fortunately, there are lots to choose from. Unfortunately, there are lots to choose from! For any need you'll have a myriad of options, and it's up to you to figure out which is best for your project, which ones will disappear into oblivion after a year, or have security issues, or will throw you into dependency hell, or...
Analysis (Express v4.19.2)
Architecture
Since we're going to compare this with other frameworks, we will borrow the structure from one of the more opinionated frameworks, Nest.js. This will allow us to have a clear separation of concerns:
src
|--- app.js
|--- modules
| |--- database
| | |--- database.service.js
| | |--- users.json
| | |--- tasks.json
| | |--- lists.json
| |--- users
| | |--- users.controller.js
| | |--- users.service.js
| |--- tasks
| | |--- tasks.controller.js
| | |--- tasks.service.js
| |--- lists
| | |--- lists.controller.js
| | |--- lists.service.js
Obviously, this is not the only way you can organise your code to ensure a clear separation of concerns. Instead of splitting it by feature, into modules, you could split it by concern (controllers, services, configuration), or any other way you like it. Why is that important? Well, many simple and practical reasons. Imagine you open a project with 30-40 features, and you are currently working on one of them, say, the invoicing feature. If files are organised by type, you will head to the controllers or routes folder, as that is your entry point. You will not immediately know whether the invoicing feature relies on files other than a service file, so you need to scour the DTO (data transfer objects) or DAO (data access object) folders, the entities or models folders, maybe some validation folder, some test folder and the list of possibilities is vast. If it's organised by feature, you have everything in one single place, immediately visible, easy to navigate. Most of the time you will work on one specific feature of your app, you will rarely work on all controllers at once, or on all your models or DTOs, it's just not like that in real life.
Oh, come on, get going already...
Great, we can finally start writing code. What I love most about Express is that you can write a lot in a few lines of code. Also, you can write using functions only, classes only, or both. Oh, by the way, by the time you read this article, you'll probably notice some difference between the screenshots and the latest version in the GitHub repo, as it will continue to evolve. That's normal. Over time, I will add all sorts of features we don't need and yet have in the basic app.
The entry point to an Express app is usually index.js or app.js. Since we have 3 modules, our app.js will look like so:
I won't bother using .env yet, since our only secret so far is the public port, so...
It's dead simple: we create the app, call app.use() to add the routes and app.listen() to fire up the server, and we're good to go.
Ah, yes, I forgot to mention app.use(express.json()) - with that we set the Content-Type to 'application/json' (otherwise it will default to plain/text). FYI: app.use can add all sorts of middleware later on.
Routing (controllers)
To keep things simple, we'll create one controller/service (Users), then replicate it for Tasks and Lists so that we tick the first requirement from our MVP laid out in the Planning Phase (create/retrieve/update/delete Tasks, Lists, Users). Up to this point they are identical, save for the name.
So, a barebones controller, with no authorisation, validation, sanitization, etc could look like this:
It creates a router, then adds the basic routes to create a new user (post), retrieve a user by ID (get), update a user (put), and delete one (delete). For each route, we call a function, and they would look like this:
领英推荐
In each controller function, we simply call the service with the same name and return a success or failure message. As is, it is barely a wrapper for the service, but you could easily add authorisation, input sanitisation, validation, input/output transformation, swagger/documentation etc. Or you could do some of these via middleware, if they are the same everywhere. But we won't address that now. This means that our routes are very fragile: if any of the parameters or payloads are not 100% as we expect them to, it will crash and burn!
The other 2 controllers are identical for now, just replace 'users' with 'tasks' or 'lists' and you got them.
Mark the task as completed
This is a good opportunity to use PATCH, since we only update one item. This is fairly straight forward. We add a new route to our otherwise identical tasks.controller.js:
Then we add the corresponding function to the tasks.service.js:
Nothing special here, pretty self-explanatory.
Lists
The last requirement, “group tasks in lists” added is there to help us portray entity relationships when we’ll use an actual database. The current implementation already covers that, because we structured the json files like we would structure the schema of their equivalent tables.
Lists look like this:
The listIds in this file act like a foreign key for the tasks table:
Therefore we can match tell that task T1 belongs to the “Grocery List”, because task T1 has a listId: L1 as a foreign key, and L1 is the “Grocery List”. The same principle applies to the users, so we know who owns each task by correlating (joining) the tasks table and users table via the userId.
Business logic (service providers)
Great, now we need a service provider that actually does the job. So, for our next trick, here's how one would look like:
Ok, a few notes. The weird const nextUserId = U${1 + Number(usersData.lastUserId.slice(1))} is there so we don't need to use a proper unique id generator, like uuid. Let's say the last userId created was "U14" - it will increment to "U15". That's all.
The delete user.passWord lines are there to remove the password from the response. Really bad solution, I know, but we would never actually have this problem to begin with in reality, as passwords are never stored anywhere
Repository (database provider)
Each of the four functions gets the data from the disk (later on we'll replace this with getting data from a real database), performs the requested operation and saves it back. Absolutely inefficient nightmare as a solution for storage, never do that in production, but it will do for learning purposes.
This calls our fake database, which simply reads and writes files to disk. There's no point going through that, it has no learning value. It does work, however. If you're curious, have a look at the fake database folder in the repo.
Later on, when we'll replace that with a real database, we'll observe a similar pattern: a database.service will instantiate a database client, we import or inject it in a feature service and then simply call the relevant method of that client for our desired database operation.
But does it work?...
Don't take my word for it, clone the repository from GitHub and try it yourself! Be advised: by the time you read this article, there may be more than one version of the app inside that repo, as I plan on benchmarking it, adding more features, then bench again and so on.
Well, that being said, we have a fully functional Express API as per our requirements. It was quite enjoyable to write, pretty simple and straightforward. My guess is that it should take an average developer around 1-3 hours to build this from scratch, without any support. I would say it has a minimal learning curve, but it's very easy to make mistakes, the framework does not hold your hand at all, and you're free, but on your own.