Decomposing complex applications

So far in the chapter, we have mainly focused our analysis on the x-axis of the scale cube. We saw how it represents the easiest and most immediate way to distribute the load of an application, also improving its availability. In the following section, we are now going to focus on the y-axis of the scale cube, where applications are scaled by decomposing them by functionality and service. As we will learn, this technique allows us to scale not only the capacity of an application, but also, and most importantly, its complexity.

Monolithic architecture

The term monolithic might make us think of a system without modularity, where all the services of an application are interconnected and almost indistinguishable. However, this is not always the case. Often, monolithic systems have a highly modular architecture and a good decoupling between their internal components.

A perfect example is the Linux OS kernel, which is part of a category called monolithic kernels (in perfect opposition with its ecosystem and the Unix philosophy). Linux has thousands of services and modules that we can load and unload dynamically even while the system is running. However, they all run in kernel mode, which means that a failure in any of them might bring the entire OS down (have you ever seen a kernel panic?). This approach is opposite to the microkernel architecture, where only the core services of the operating system run in kernel mode, while the rest run in user mode, usually each one with its own process. The main advantage of this approach is that a problem in any of these services would more likely cause it to crash in isolation instead of affecting the stability of the entire system.

Note

The Torvalds-Tanenbaum debate on kernel design is probably one of the most famous flame wars in the history of computer science, where one of the main points of dispute was exactly monolithic versus microkernel design. You can find a web version of the discussion (it originally appeared on Usenet) at https://groups.google.com/d/msg/comp.os.minix/wlhw16QWltI/P8isWhZ8PJ8J.

It's remarkable how these design principles, more than 30 years old, can still be applied today and in totally different environments. Modern monolithic applications are comparable to monolithic kernels; if any of their components fail, the entire system is affected, which, translated into Node.js terms, means that all the services are part of the same codebase and run in a single process (when not cloned).

To make an example of a monolithic architecture, let's take a look at the following figure:

Monolithic architecture

The preceding figure shows the architecture of a typical e-commerce application. Its structure is modular; we have two different frontends, one for the main store and another for the administration interface. Internally, we have a clear separation of the services implemented by the application, each one responsible for a specific portion of its business logic: Products, Cart, Checkout, Search, and Authentication and Users. However, the preceding architecture is monolithic; every module, in fact, is part of the same codebase and runs as part of a single application. A failure in any of its components, for example, an uncaught exception, can potentially tear down the entire online store.

Another problem with this type of architecture is the interconnection between its modules; the fact that they all live inside the same application makes it very easy for a developer to build interactions and coupling between modules. For example, consider the use case when a product is being purchased: the Checkout module has to update the availability of the Product object, and if those two modules are in the same application, it's too easy for a developer to just obtain a reference to a Product object and update its availability directly. Maintaining a low coupling between internal modules is very hard in a monolithic application, partly because the boundaries between them are not always clear or properly enforced.

A high coupling is often one of the main obstacles to the growth of an application and prevents its scalability in terms of complexity. In fact, an intricate dependency graph means that every part of the system is a liability; it has to be maintained for the entire life of the product, and any change should be carefully evaluated because every component is like a wooden block in a Jenga tower: moving or removing one of them can cause the entire tower to collapse. This often results in the building of conventions and development processes to cope with the increasing complexity of the project.

The microservice architecture

Now we are going to reveal the most important pattern in Node.js to write big applications: avoid writing big applications. This seems like a trivial statement, but it's an incredibly effective strategy to scale both the complexity and the capacity of a software system. So what's the alternative to writing big applications? The answer is in the y-axis of the scale cube, decomposition and splitting by service and functionality. The idea is to break down an application into its essential components, creating separate, independent applications. It is practically the opposite of monolithic architecture. This fits perfectly with the Unix philosophy, and the Node.js principles we discussed at the beginning of the book, in particular "make each program do one thing well".

Microservice architecture is today, probably the main reference pattern for this type of approach, where a set of self-sufficient services replace big monolithic applications. The prefix micro means that the services should be as small as possible, but always within reasonable limits. Don't be misled by thinking that creating an architecture with a hundred different applications exposing only one web service is necessarily a good choice. In reality, there is no strict rule on how small or big a service should be. It's not the size that matters in the design of a microservice architecture; instead, it's a combination of different factors, mainly loose coupling, high cohesion, and integration complexity.

An example of microservice architecture

Let's now see what the monolithic e-commerce application would look like, using a microservice architecture:

An example of microservice architecture

As we can see from the previous figure, each fundamental component of the e-commerce application is now a self-sustaining and independent entity, living in its own context, with its own database. In practice, they are all independent applications exposing a set of related services (high cohesion).

The data ownership of a service is an important characteristic of microservice architecture. This is why the database also has to be split to maintain the proper level of isolation and independence. If a unique shared database is used, it would become much easier for the services to work together; however, this would also introduce a coupling between the services (based on data), nullifying some of the advantages of having different applications.

The dashed line connecting all the nodes tells us that, in some way, they have to communicate and exchange information for the entire system to be fully functional. As the services do not share the same database, there is more communication involved to maintain the consistency of the whole system. For example, the Checkout application needs to know some information about Products, such as the price and restrictions on shipping, and at the same time, it needs to update the data stored in the Products service, for example, the product availability when the checkout is complete. In the preceding figure, we tried to keep the way the nodes communicate abstract. Surely, the most popular strategy is using web services, but as we will see later, this is not the only option.

Note

Pattern (microservice architecture)

Split a complex application by creating several small, self-contained services.

Pros and cons of microservices

In this section we are going to highlight some of the advantages and disadvantages of implementing a microservice architecture. As we will see, this approach promises to bring a radical change in the way we develop our applications, revolutionizing the way we see scalability and complexity, but on the other hand, it introduces new nontrivial challenges as well.

Note

Martin Fowler wrote a great article about microservices that you can find at http://martinfowler.com/articles/microservices.html.

Every service is expendable

The main technical advantage of having each service living in its own application context is that crashes, bugs, and breaking changes do not propagate to the entire system. The goal is to build truly independent services that are smaller, easier to change, or even rebuild from scratch. If, for example, the Checkout service of our e-commerce application suddenly crashes because of a serious bug, the rest of the system would continue to work as normal. Some functionality may be affected, for example, the ability to purchase a product, but the rest of the system would continue to work.

Also, imagine if we suddenly realized that the database or the programming language we used to implement a component was not a good design decision. In a monolithic application, there would be very little we could do to change things without affecting the entire system; instead, in a microservice architecture, we could more easily re-implement the entire service from scratch, using a different database or platform, and the rest of the system would not even notice it.

Reusability across platforms and languages

Splitting a big monolithic application into many small services allows us to create independent units that can be reused much more easily. Elasticsearch (http://www.elasticsearch.org) is a great example of a reusable search service; also, the authentication server we built in Chapter 7, Wiring Modules, is another example of a service that can be easily reused in any application, regardless of the programming language it's built in.

The main advantage is that the level of information hiding is usually much higher compared to monolithic applications. This is possible because the interactions usually happen through a remote interface such as a web service or a message broker, which makes it much easier to hide the implementation details and shield the client from changes in the way the service is implemented or deployed. For example, if all we have to do is invoke a web service, we are shielded from the way the infrastructure behind is scaled, from what programming language it uses, from what database it uses to store its data, and so on.

A way to scale the application

Going back to the scale cube, it's clear that microservices are equivalent to scaling an application along the y-axis, so it's already a means for the distribution of the load across multiple machines. Also, we should not forget that we can combine microservices with the other two dimensions of the cube to scale the application even further. For example, each service could be cloned to handle more traffic, and the interesting aspect is that they can be scaled independently, allowing better resource management.

The challenges of microservices

At this point, it would look like microservices are the solution to all our problems; however, this is far from being true. In fact, having more nodes to manage introduces a higher complexity in terms of integration, deployment, and code sharing; it fixes some of the pains of traditional architectures but it also opens up many new questions. How do we make the services interact? How can we deploy, scale, and monitor such a high number of applications? How can we share and reuse code between services? Fortunately, cloud services and modern DevOps methodologies can provide some answers to those questions, and also, Node.js can help a lot. Its module system is a perfect companion to share code between different projects. Node.js was made to be a node in a distributed system such as those implemented using a microservice architecture.

Tip

Although microservices can be built using any framework (or even just the core Node.js modules), there are a few solutions specialized for this purpose; among the most notable, we have Seneca (https://npmjs.org/package/seneca), AWS Lambda (https://aws.amazon.com/lambda), IBM OpenWhisk (https://developer.ibm.com/openwhisk) and Microsoft Azure Functions (https://azure.microsoft.com/en-us/services/functions). A useful tool to manage the deployment of microservices is Apache Mesos (http://mesos.apache.org).

Integration patterns in a microservice architecture

One of the toughest challenges of microservices is connecting all the nodes to make them collaborate. For example, the Cart service of our e-commerce application would make little sense without some Products to add, and the Checkout service would be useless without a list of products to buy (a cart). As we already mentioned, there are also other factors that necessitate an interaction between the various services. For example, the Search service has to know which Products are available and must also ensure it keeps its information up-to-date. The same can be said about the Checkout service, which has to update the information about Product availability when a purchase is completed.

When designing an integration strategy, it's also important to consider the coupling that it's going to introduce between the services in the system. We should not forget that designing a distributed architecture involves the same practices and principles that we use locally when designing a module or subsystem, therefore, we also need to take into consideration properties such as the reusability and extensibility of the service.

The API proxy

The first pattern we are going to show makes use of an API Proxy (also commonly identified as an API Gateway), a server that proxies the communications between a client and a set of remote APIs. In a microservice architecture, its main purpose is to provide a single access point for multiple API endpoints, but it can also offer load balancing, caching, authentication, and traffic limiting, all features that prove to be very useful to implement a solid API solution.

This pattern should not be new to us; we already saw it in action when we built the custom load balancer with http-proxy and consul. For that example, our load balancer was exposing only two services, and then, thanks to a Service Registry, it was able to map a URL path to a service and hence to a list of servers. An API proxy works in the same way; it is essentially a reverse proxy and often also a load balancer, specifically configured to handle API requests. The next figure shows how we can apply such a solution to our e-commerce application:

The API proxy

From the preceding figure, it should be clear how an API proxy can hide the complexity of its underlying infrastructure. This is really handy in a microservice infrastructure, as the number of nodes may be high, especially if each service is scaled across multiple machines. The integration achieved by an API Proxy is therefore only structural; there is no semantic mechanism. It simply provides a familiar monolithic view of a complex microservice infrastructure. This is opposed to the next pattern we are going to learn, where the integration is semantic instead.

API orchestration

The pattern we are going to describe next is probably the most natural and explicit way to integrate and compose a set of services, and it's called API orchestration. Daniel Jacobson, VP of Engineering for the Netflix API, in one of his blog posts (http://thenextweb.com/dd/2013/12/17/future-api-design-orchestration-layer), defines API orchestration as follows:

"An API Orchestration Layer (OL) is an abstraction layer that takes generically-modeled data elements and/or features and prepares them in a more specific way for a targeted developer or application."

The generically modeled elements and/or features fit the description of a service in a microservice architecture perfectly. The idea is to create an abstraction to connect those bits and pieces to implement new services specific to the application.

Let's make an example using the e-commerce application. Refer to the following figure:

API orchestration

The preceding figure shows how the Store front-end application uses an orchestration layer to build more complex and specific features by composing and orchestrating existing services. The described scenario takes as example, a hypothetical completeCheckout() service that is invoked the moment a customer clicks the Pay button at the end of the checkout. The figure shows how completeCheckout() is a composite operation made of three different steps:

  1. First, we complete the transaction by invoking checkoutService/pay.
  2. Then, when the payment is successfully processed, we need to tell the cart service that the items were purchased and they can be removed from the cart. We do that by invoking cartService/delete.
  3. Also, when the payment is complete, we need to update the availability of the products that were just purchased. This is done through productsService/update.

As we can see, we took three operations from three different services and we built a new API that coordinates the services to maintain the entire system in a consistent state.

Another common operation performed by the API Orchestration Layer is data aggregation, in other words, combining data from different services into a single response. Imagine if we wanted to list all the products contained in a cart. In this case, the orchestration would need to retrieve the list of product IDs from the Cart service and then retrieve the complete information about the products from the Products service. The ways in which we can combine and coordinate services are really infinite, but the important pattern to remember is the role of the orchestration layer, which acts as an abstraction between a number of services and a specific application.

The orchestration layer is a great candidate for a further functional splitting. It is in fact very common to have it implemented as a dedicated, independent service, in which case it takes the name of API Orchestrator. This practice is perfectly in line with the microservice philosophy.

The next figure shows this further improvement of our architecture:

API orchestration

Creating a standalone orchestrator, as shown in the previous figure, can help in decoupling the client application (in our case, the Store front-end) from the complexity of the microservice infrastructure. This reminds us about the API Proxy; however, there is a crucial difference; an orchestrator performs a semantic integration of the various services; it's not just a naïve proxy, and it often exposes an API that is different from the one exposed by the underlying services.

Integration with a message broker

The orchestrator pattern gave us a mechanism to integrate the various services in an explicit way. This has both advantages and disadvantages. It is easy to design, easy to debug, and easy to scale, but unfortunately, it has to have a complete knowledge of the underlying architecture and how each service works. If we were talking about objects instead of architectural nodes, the orchestrator would be an anti-pattern called God Object, which defines an object that knows and does too much, which usually results in high coupling, low cohesion, but most importantly, high complexity.

The pattern we are now going to show tries to distribute, across the services, the responsibility of synchronizing the information of the entire system. However, the last thing we want to do is create direct relationships between services, which would result in high coupling and a further increase in the complexity of the system, due to the increasing number of interconnections between nodes. The goal is to have each service maintain its isolation; they should be able to work even without the rest of the services in the system or in combination with new services and nodes.

The solution is to use a message broker, a system capable of decoupling the sender from the receiver of a message, allowing us to implement a centralized publish/subscribe pattern, in practice an observer pattern for distributed systems (we will talk more about this pattern later in the book). The following diagram shows an example of how this applies to the e-commerce application:

Integration with a message broker

As we can see, the client of the Checkout service, which is the frontend application, does not need to carry out any explicit integration with the other services. All it has to do is invoke checkoutService/pay to complete the checkout and take the money from the customer; all the integration work happens in the background:

  1. The Store front-end invokes the checkoutService/pay operation on the Checkout service.
  2. When the operation completes, the Checkout service generates an event, attaching the details of the operation, that is, the cartId and the list of products that were just purchased. The event is published into the message broker. At this point, the Checkout service does not know who is going to receive the message.
  3. The Cart service is subscribed to the broker, so it's going to receive the purchased event that was just published by the Checkout service. The Cart service reacts by removing from its database, the cart identified with the ID contained in the message.
  4. The Products service was subscribed to the message broker as well, so it receives the same purchased event. It then updates its database based on this new information, adjusting the availability of the products included in the message.

The whole process happens without any explicit intervention from external entities such as an orchestrator. The responsibility for spreading the knowledge and keeping information in sync is distributed across the services themselves. There is no god service that has to know how to move the gears of the entire system; each service is in charge of its own part of the integration.

The message broker is a fundamental element to decouple the services and reduce the complexity of their interaction. It might also offer other interesting features, such as persistent message queues and guaranteed ordering of the messages. We will talk more about this in the next chapter.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.143.254.90