Java microservices architecture by example: When a monolith doesn’t work
Microservices are not something new. However, this architectural approach continues to stir up interest. It claims to be a good choice for so much needed today heavy load handling and can already boast successful implementations by such big players as Google, Netflix, Amazon, and eBay.
In this article, we’ll explore a real-life example to understand the essence of microservices, the way their inner-system communication happens and the differences this architectural approach brings to the application.
A quick introduction to microservices
A microservices architecture is a particular case of a service-oriented architecture (SOA). SOA applies to the systems built with several independently deployable modules. What sets microservices apart is the extent to which these modules are interconnected. Microservices are even more independent and tend to share as little elements as possible. Every server comprises just one certain business process and never consists of several smaller servers.
Microservices become handy when one server is not enough, which happens, for example, if a server can’t handle heavy load intended for it, or if its memory use is too high. Microservices also bring a set of additional benefits, such as easier scaling, the possibility to use multiple programming languages and technologies, and others.
However, as the architecture description may sound quite vague in words, let us demonstrate how it works with a real Java-based application example. Why Java? Java is a frequent choice for building a microservices architecture as it is a mature language tested over decades and has a multitude of microservices-favorable frameworks, such as legendary Spring, Jersey, Play, and others.
So, let’s have a look at the example based on our real project - a mobile retail application that logs a user into their profile, takes orders, and sends email notifications (order confirmation, shipping updates, etc.) - and see what happens behind the scenes.
To make microservices’ distinguishing traits more explicit, we’re going to fresh up how things go with traditionally used monoliths.
What might happen with a monolith
A monolithic architecture keeps it all simple. An app has just one server and one database. The program consistently implements all business logic step by step, moving to the next stage only after the previous one is completed. All the connections between units are inside-code calls. When the user opens it to make an order, the system checks security, logs them in, takes their order, sends an e-mail order confirmation and only after all these steps are performed, the user will see the order done, and the session will be completed.
Nothing is wrong. The application works, there are just some problems you may face:
× Complete shutdown. If one part of a business logic server doesn’t work or is overloaded, the whole application may stop as it can’t proceed with the next operational stage. Back to our example, if, for some reason, notifications cannot be sent right away, the users can’t see their order successfully finished till this part of the business logic server becomes available again.
× Complicated updates. If you want to upgrade your application (introduce new technologies, add new features), even in case of minor changes, a development team will have to rewrite pretty much of it, then stop the old version for some time (which means lost clients and orders) and introduce a new one. Moreover, the development team will have to be very careful with newly introduced changes because they may damage the whole program. Also, the new parts should be necessarily written in the language of the initial system or at least in the one compatible with it.
× Frustrating UX. As a monolith continues to develop and grow and needs to deal with higher and higher load, it turns into a big and slow application with increasingly high latency, which will keep customers away as they won’t wait and may consider buying from competitors.
To tackle high load, you could duplicate the existing business logic server to get two identical servers and spread the load between them with a dynamic balancer that will randomly forward requests to the less loaded one of them. It means that, if, initially, one server processes, let’s say, 200K queries per second, which makes it too slow, now each of them deals with 100K QPS without experiencing overload. Nevertheless, it’s not a microservices architecture yet, even though there are several servers. What is more, two other problems remained unresolved: shutdowns and problematic updates.
This compels us to seek another solution.
What changes with microservices
And here we finally turn to our microservices-based example, where we designate each independent server to perform a certain business function. Here’s how we can manage to save the day with a microservices architecture.
Step 1. Split it
We split our application into microservices and got a set of units completely independent for deployment and maintenance. In our application, 2 user profile servers, 3 order servers and a notification server perform the corresponding business functions. Each of microservices responsible for a certain business function communicates either via sync HTTP/REST or async AMQP protocols.
Step 2. Pave the ways
Splitting is only the starting point of building a microservice-oriented architecture. To make your system a success, it is more important and still even more difficult to ensure seamless communication between newly created distributed components.
First of all, we implemented a gateway. The gateway became an entry point for all clients’ requests. It takes care of authentication, security check, further routing of the request to the right server as well as of the request‘s modification or rejection. It also receives the replies from the servers and returns them to the client. The gateway service exempts the client side from storing addresses of all the servers and makes them independently deployable and scalable. We also set the Zuul 2 framework for our gateway service so that the application could leverage the benefits of non-blocking HTTP calls.
Secondly, we've implemented the Eureka server as our server discovery that keeps a list of utilized user profile and order servers to help them discover each other. We also backed it up with the Ribbon load-balancer to ensure the optimal use of the scaled user profile servers.
The introduction of the Hystrix library helped to ensure stronger fault tolerance and responsiveness of our system isolating the point of access to a crashed server. It prevents requests from going into the void in case the server is down and gives time to the overloaded one to recover and resume its work.
We also have a message broker (RabbitMQ) as an intermediary between the notification server and the rest of the servers to allow async messaging in-between. As a result, we don’t have to wait for a positive response from the notification server to proceed with work and have email notifications sent independently.
Let’s now review the monolith problems we could come across and see what happens with them in our microservices-based application:
- If one server goes slow because of overload or crashes completely, the life won’t stop and, often, the user won’t even notice any braking. The system will either re-route the requests to its substitutes (as we have 2 user profile servers and 3 order servers) or proceed with its work and resume the function as soon as the server is recovered (in case of our notification server crashes). Maybe the client won’t get notifications right away, but at least they won’t have to look at the ‘pre-loader’ for ages.
- Now we can easily update what we need. As the units are completely independent, we just re-write the needed servers to add some new features (recommendation engine, fraud detection, etc.). In our example, to introduce an IP tracker and report suspicious behavior (as Gmail does), we create fraud detection server and slightly modify our user profile servers while the rest of the servers safely stays intact.
- Loosely coupled nature of our microservices architecture and its incredible potential for scaling allows tackling incidents with minimal negative effect on user experience. For example, when we see that some of our core features run slow, we can scale the number of servers handling it (as we did from the start with user profile and order servers) or let them go a little slow for a while, if the features are not vital (as we did with notifications). In some other cases, it makes sense to skip them at all in peak times (as it happens with pop-ups that, for a while, show only textual description with no image included).
The real-life example proves that, though not magic, microservices can definitely help when it comes to creating complex applications that deal with huge loads and need continuous improvement and scaling. However, like any other architectural approach, they are not entirely flawless, so figure out whether the above-mentioned traits are actually relevant to your future application, carefully sum up all pros and cons and get professional advice, because the ‘splitting-for-splitting’ may not only turn out to be useless but also have negative effects on your application.