All about microservices (part 1)

Tag icon
This is some text inside of a div block.

In this post, we'll share how we handle architectural decisions and design our microservices at Emi, in the hope that these experiences can be useful or serve as an inspiration to others. Let's get started!

Why microservices?

Services must be built and maintained by small teams, designed around business capabilities (not around horizontal layers such as access to data or messaging) and autonomous (deployed as an isolated service and modified independently). Some of its benefits are:

  • Organizational Alignment: Smaller teams are more productive and have more autonomy.
  • Ease of deployment: Isolates businesses, allowing for faster launches.
  • Resilience: It allows you to isolate a problem so that the rest of the system can continue to function.
  • Scalability: Different scalability policies and types of hardware can be established for each service.
  • Composability: Functionality can be consumed in different ways.
  • Optimization for replaceability: Easy to replace/refactor services.
  • Technological heterogeneity: Each service can use different programming languages, databases, tools, etc.

But nothing is free in this world, and microservices are no exception. This architectural pattern is accompanied by the same challenges as any distributed solution or architectural and organizational decision.

Why Node.js?

Node.js is a JavaScript execution environment, and JavaScript is one of the most used languages (mainly because it runs in all browsers). In the early stages of Emi, we decided to build most of our services using Node.js because:

  • It's easy to learn, and it has a very large community
  • Help with asynchronous programming (better performance and excellent for event-based architectures)
  • Its dynamic language offers faster coding speed, less protocol and good ease of testing
  • When there is a need to mix dynamic code with strongly typed code, you could use something like TypeScript

Challenges

Resilient communications between services, accelerating the creation of services, solving common problems such as configurations, error handling, the message consumer, and last but not least, distributed tracing, are some of the challenges inherent in microservice architecture.

At Emi, we have actively built and maintain npm packages to help those who work with microservices in Node.js solve these challenges.

Wait a minute, shouldn't equipment and services be autonomous?

Understood. Yes, by following a set of guidelines, each team can decide which language and libraries to use while complying with some architectural rules, so that each service is resilient, observable and scalable.

In the end, architectural rules and best practices are the basis for every service within a microservices ecosystem. These packages facilitate compliance with these rules, allowing us to move quickly, guided by a mentality aimed at creating value.

Resilient communications between services: timeout (maximum execution time) and reattempts

With so many services communicating with each other, it's critical that they can tolerate faults in other components they depend on. The two most common patterns for achieving this are timeout and reattempts.

In communication using waiting messaging (message queues), these are easy to implement because the infrastructure would probably handle them by default. But what about our HTTP communication? Well, the timeout is probably handled by the HTTP client library, but it's important that everyone is using it correctly. If the timeout value is always 5 minutes (or is it turned off, how does it Axios), you're likely to wait too long for inactive services, and your consumers will exhaust the timeout before it ends.

On the other hand, most HTTP clients don't usually handle reattempts. We have built an HTTP module that must be used by every service based on Node.js. This one uses Axios, for which we have modified some default values:

We have also used Axios-Retry, which intercepts failed requests and retries whenever possible using an exponential delay strategy:

Distributed tracking

Distributed tracing is crucial for understanding complex microservice applications. Without it, teams may not be able to identify when there are performance problems or other errors in their production environment.

Let's imagine this: Tasks often encompass several services. Each service handles a request by performing one or more operations, such as HTTP requests, database queries, and waiting for messages. That said, to make it easier to understand and debug an application's behavior, one should be able to track all requests and operations that belong to the same task.

We package our services with an express middleware that assigns each task a unique tracking identifier (UUID). This ID is intended to be added to the first controlled service that handles an external request (for example, Backend for Frontend, Public API) or where the request was initiated internally (for example, cron task). This middleware also adds the name of the initiator (service and component) and the identifier of the connected user as a context. Then, the tracking and reference data are passed to all the services that are involved in managing the operation, allowing the tracking data to be included in all log messages, metrics and API responses.

It's easy to read a tracking ID or generate a new one. But how do we pass this ID to each function in the call chain that our process will perform? There's no question that adding this ID as an argument in every function isn't an option. Some “instrumentation code” needs to be added to our business code to transparently track these traces. To solve this particular problem, we opted for the simplest approach and used Continuation-Local Storage (Hooked), a module that uses asynchronous hooks and allows you to set and obtain values that are linked to the life cycle of these chains of function calls 😉.

Here you can see an excerpt to give you an idea of how this module works:

Then, in your record formatter, you can read this tracking ID and then include it in the logs:

We maintain modules for registration, messaging, an HTTP client and middlewares for web APIs and Workers, and all are ready to send and/or read these tracking IDs.

It will continue...

Seems like a lot, right? It's okay to feel a little overwhelmed if this is your first time dealing with microservices. It's a difficult subject and, as with any difficult topic, it takes time and practice to master.

Next week, we'll cover other topics such as configurations, microservice templates, and linters (code analyzers). There's still a lot to learn; stay tuned!

And if you've come this far, it might be a good idea to take a look at the job offers that we currently have. We are looking for passionate and curious individuals to join our team!

Seguir leyendo