In a series of blog posts, I'm going to describe an opinionated Node.js microservice architecture. Let me tell you about the why, what and how of this series.
For the development of a microservice, there are plenty of frameworks and languages to choose from. Many of these options like Java with Spring Boot and Node.js with Nest.js come with a pre-defined architecture and a set of libraries. This allows for a quick start bootstrapping a service. There is one clear and defined way on how to structure the service. There is documentation of a solution for many of the common problems.
However, after a quick start, such frameworks can become a burden. Configuration- and annotation-heavy as they are, they take away choices you could express in code in exchange for hiding complexity. Anything away from the common path like adding your own custom authentication mechanism or introducing a different logging mechanism like JSON-logging requires more work and more complexity with the framework and might even not be possible at all.
For that reason, I avoid frameworks.
Instead, I'm defining an own loose architecture and choose and connect own library choices. It allows me to express solutions to problems close to their inherent complexity. It gives me the ability to exchange each library and each functionality if there is a need to.
However, this approach gives less guidance on how to structure an application. With multiple developers working on it, consistency can get lost if a desired structure is not defined. Also, there is no advantage of a quick start since you need to start for each service from scratch. That's where this opinionated architecture guide comes in.
In a series of blog posts, I'm describing an opinionated Node.js microservice architecture that my team successfully used for three microservices. Over the series of posts, we investigate a dedicated example microservice that I assemble out of the experiences with the three microservices. We talk about various aspects like organizing controllers, configuration management, database connection and querying, API testing, authentication, CI and CD workflows, Kubernetes runtime and more.
From the presented architecture example, you will be able to bootstrap your own production-ready microservice. You will be able to exchange individual libraries to accommodate for your own requirements. You will be able to choose a different architectural structure where you need it. You will be able to harness the full power of a Turing-complete programming language, not being limited by pre-determined choices of a framework.
The high-level architecture 🔗
On a high-level, this architecture consists of a Node.js application that is hosted as a Kubernetes service with replication.
It uses a PostgreSQL database for persistence and both normalized tables for structured data as well as JSON-columns for unstructured data. Locally, the database runs in docker. On production, the application connects with a managed database.
The service offers a REST-like HTTP API. The endpoints are secured with JWT authentication. They are described and validated with an OpenAPI specification.
The repository is version controlled in git and hosted on GitHub. Continuous integration and deployment are set up with GitHub Actions.
For testing, we focus heavily on unit tests for database queries, middleware, API endpoints and complicated logic.
The choice of libraries 🔗
In a framework-less approach, there are plenty of library choices to make. For the microservice that we are building, we use a fixed set of libraries.
Wait, isn't this guide then a framework?
Yes, if one is to copy the bare skeleton without understanding the copied code and uses it to build a service, then what you are reading is a framework. However, together with the explanations in these blog posts, you are able to adjust any parts to your liking. Modifying, exchanging or removing any functionality or library. Adding or removing any structure or layers. This guide is a composition of functionality. In this regard, it is different to a framework. There is no default behavior, therefore no overriding of default behavior and no hidden complexity.
In this fixed set of libraries, there are two libraries that are hard to exchange.
- Node.js: Node.js as the runtime environment is not exchangeable. If you would exchange this library for Deno (for example), this would impact the choice of each other library. Hence, in this opinionated architecture, Node.js can not be exchanged.
- Express.js: While it's not impossible to exchange Express.js in this architecture, much of the middleware is build for Express.js. It's not as heavy as Node.js, but exchanging Express.js requires significantly more effort than exchanging the other libraries.
Other libraries can be exchanged in a less expensive way but are used across various parts of the architecture.
- axios: This library allows us making HTTP requests.
- knex: With an SQL database as the persistence layer, we use Knex to have fluent schema migrations and build SQL queries. Knex is not an ORM. That makes it more lightweight than its ORM alternatives.
- pino: Logging with JSON output.
- jest: Environment for test execution.
- lodash/fp: Some utility functions, especially for collections.
Wait, what are we using for dependency injection?
Nothing. We are ruthlessly calling functions from other modules directly and are mocking these modules in tests.
Apart from these libraries, there are libraries that solve specific problems without being referenced all over the application. Therefore, they can be easily exchanged.
- prettier: Enforce consistent code styling.
- eslint: Find bugs earlier.
- express-openapi-validator: This library allows us to validate incoming requests against an OpenAPI specification.
- jsonwebtoken: Decoding and verifying a JWT token.
- testcontainers: Running a PostgreSQL database in test cases.
- supertest: Testing of HTTP endpoints.
- memoizee: Memoize (cache) return values of functions.
Beside these libraries there are more smaller libraries that provide glue code to connect different libraries or that are plugged in for aspects like compression or CORS.
What we are about to cover 🔗
The topics are not yet set in stone but we will roughly cover the following overarching topics and subtopics in that order. Some topics might end up in the same blog posts, some blog posts might cover several topic and some topics might be covered much earlier or later.
Building an API 🔗
We build a REST-like API, organize and test the endpoints and implement various middleware.
- Organizing endpoints
- Testing endpoints
- OpenAPI and input validation
- Configuration management
Working with a database 🔗
Without a persistence technology, the API can't store data over the lifetime of the application. We setup a database, build and test queries for it and develop the database schema over time.
- Local database
- Schema migrations
- Database queries
- Testing database queries
Sending HTTP requests 🔗
For many applications, it is crucial to be able to talk to other services. We setup Axios and make authenticated calls to services.
- Sending HTTP requests
- Connection pooling
- Logging HTTP requests
- Axios error handling
- Authenticating HTTP requests
Scheduled jobs 🔗
Some tasks either can not be executed synchronously with a request (if some effects are happen at specific times like delay notifications). Other tasks should not be executed synchronously because they are time-intensive. We architecturally setup a batch job that can run on a schedule.
- Application subcommands
- Asynchronous batch jobs
- Idempotent cron jobs with at-least-once delivery
- Knex database pagination
Working in a team 🔗
Great products are developed within a team. The team members might have different machines and environments, they require tooling to get ready quickly and to reset a broken state. Continuous integration and deployment get changes into production immediately with a pull request.
- Developer configurations
- Continuous integration and deployment
- Evolving architecture over time
Advanced topics 🔗
Here we will cover various advanced topics that might not be necessary for each microservice.
- Running it in Kubernetes
- Feature Flags
- Scheduled and batch jobs
- Incoming asynchronous communication
What we are building 🔗
Across these blog posts, we are looking at different aspects of the same application. Since these series is more about how to technically build such an application rather than tackling a compelling problem, we are building an application for a well-known problem.
Because applications for todos are widely known, we can focus on the technical aspects. Let me describe the requirements of such a todo application.
A todo is an entity with a short name, an assignee, a due date and an ID. The application should have a REST endpoint to manage todos.
POST /todos: Creates a todo
PUT /todos/:id: Changes the todo with the ID
DELETE /todos/:id: Deletes the todo with the ID
GET /todos: Returns a list of all todos
GET /todos/:id: Returns the todo with the ID
There is an additional endpoint that can only be called from users with admin tokens to prevent accidental deletions.
DELETE /todos: Deletes all todos
A reasonable time after a todo is delayed, the assignee should be notified. There is a pre-existing notification service, that offers an endpoint
POST /notification to do that.
That's it. Nothing fancy, but it covers the different technical aspects.
Way forward 🔗
At the time of this introduction being written, the implementation of the application itself and the related posts are still mostly on the horizon. This means that it's possible to incorporate feedback. If you are having any input on additional topics that you think should be covered or would like to see some topics covered earlier, let me know.
Become a GitHub sponsor to access the code of this post as a GitHub repo.