Microservices, or Microservice Architecture (MSA) is an architectural style that structures an application as a group of services that are maintainable, testable, loosely coupled, and can be deployed indenpendently. They must do “one thing” and do it well. Great!, but how do we achieve the previous statement? Let’s focus on a microservice (service) itself.
Due the fact that microservices must be able to deploy themselves indenpendently we can treat them as (actually they are) standalone applications. We can take advantage of it by applying the same architecture you would to a monolithic software. This way you can apply your SOLID principles, your favourite architecture and strategies you prefer, separation of concerns, etc.
In this post I bring you my approach to a SOLID NodeJs application written in TypeScript. Its goal is to manage the user accounts of an application. It performs operations such as Sign-in, Sign-up and session token refresh. It implements a rich way of horizontal and vertical separation and very well defined boundaries. Horizontal and vertical separation of concerns are very important for microservices (As we will see later).
The service is devided into 4 funcional layers:
- Main: Service start point.
- API: Listen to requests.
- Business: Brain of the application.
- Data: Data handling.
- Com: Inter-services communications
Database and Gateway layers are actually servers and are out of the scope of this post. The direction of the arrows means dependency or visibility where business layer is at the top level of abstraction. Global and Common modules (see the code) are external dependencies included in the code.
This layer holds the start point of the service and performs common microservice’s tasks like requesting configuration to the config server, registering itself in the discovery service, starting the REST server in order to listen to incoming requests, connect to a database (if any) and any other possible task it may require for startup. This layer also houses the dependency injector.
Request controllers, data validators, validation models (usually own layer models), and model mappers are located here. It’s goal is to receive requests, validate their content and forward it to the business layer. The API layer communicates with the business layer through the use-cases objects injected in the controllers.
This is the core of the application. Its goal is to interconnect the application’s extremities, perform the logic and despatch results to its respective destination. Data flows through RxJS pipes. Layer’s models and data repositories interfaces are also placed here.
This layer will handle the data extracted from the source and sends it back to the upper layer. Business layer communicates with data thought its interfaces abstracting it from data origin. Layer models such as entities or schema (in case you are using MongoDB) and entity mappers are placed here. It also holds the interfaces to communicate with data persistence, data caching and data cloud frameworks.
The Rubik Cube
Vertical separation of concern
By achieving a well defined vertical separation we isolate functionalities from each other making them easy to move to another service once you decide your service has grown too much. Separating each operation’s flow with well defined boundaries we isolate each functionality from the rest. By doing this we will be able to move any specific operation to a different service with the least effort. Maybe in production, login operations take place 10 or maybe 20 times more than register ones and the load balancer is instantiating tons of user-manager service but only a very few instances handle login requests.
Horizontal separation of concern
By achieving a well defined horizontal separation of concern (module separation) we isolate horizontal layers in a way they might be used (naturally) to export them to different services but also as a requirement for component-level principles such as ADP, SAP, REP, etc. (See Package Principles). Splitting your application in separated layers with well designed boundaries you isolate those layers making them independendent from each other. This independency plays a great role in terms of Microservices, it gives you the freedom to move any of those layers to a service apart. Consider the following case: You decide to split your token refresh and token validation functionalities in your user-manager service to their own service. Since token refresh and token validation features perform calls to obtain user entities, data layer can then be wrapped in its own service in order to provide user entities to whoever performs the request.
The communication module connects the message broker (or any sort of communication system you prefer) with the app’s business. Its job is to expose controllers to handle any incoming request and directly call use cases. Also provides an implementation for business communication interfaces.
If you wonder what the networkService object is, it belongs to a standalone npm module called user-manager-bridge, it’s an object of type UserRequestReceiver. Any other service that wants to communicate with the user-manager service just needs to install this module and call the exposed methods. We won’t get deep into this topic because it’s out of the scope of this post. I have plans of writing a whole post talking about it.
All errors will flow from origin to the caller (mostly the controller). The caller will handle the error and provide a proper response to the client. Errors are also logged by a logger object hosted in the MAIN layer and injected into all error prone components. Besides, if you wish to have error logging centralized, the injected logger’s implementation could also broadcast critical errors through AMQP to any error service or to any error logging solution like Sentry. For this example we use Winston for logs.
I have published the code on Github in a separated repository in case you want to review it here. In order to run the code you just need a MongoDB server running in localhost. In the code you will find the following directory structure:
The code is structured in a way that every folder represents an independent module.
- api: Holds all presentation classes (models and controllers).
- business: All business classes (models, usecases)
- com: This module handles the communication with other services
- data: Data handling classes.
- global: Global code among services such as constants.
- main: Main app entry point. Holds dependency injector, configuration settings, etc.
Clean architecture works just as well at any level. The reason is that Clean Architecture doesn’t care how components are deployed. Indeed, a system with a good Clean Architecture doesn’t know which deployment option it’s using. Once your system is abstracted from the details (lower level details: system input output, network communication, etc.) the system doesn’t care how or where the data comes from. Your business logic lives in ignorance, it only knows where to get the data, what to do with it and where to forward it.
“The job of good system architects is to create a structure whereby the components of the system – whether Use-cases, UI components, database components, or what have you – have no idea how they are deployed and how they communicate with the other components in the system”. Robert C. Martin.