Tweede golf has built quite a few big web applications over the last ten years. One of our specialties being the development of Symfony applications, some of these applications have become massive, with a lot of separate functionality baked into a single monolith. For now, this situation is being contained as we've been strict about minimizing technical debt. In practice, however, it's extremely hard to completely avoid accumulation of technical debt, which is one of the reasons we have started looking into introducing microservice architectures into our projects.
'What's wrong with a good old monolithic application?', one might ask. Initially, not that much, actually. Especially in the initial phase of a software project, when the context and requirements and separate domains might not be entirely clear, monolithic applications provide the flexibility to refactor large parts of a project in a relatively easy way. Monoliths are quite easy to test.
After a couple of months of development, the upsides of monolithic applications become less apparent. Refactoring code becomes progressively harder to do, and updating dependencies might introduce a lot of bugs that are hard to anticipate.
As the user base of an application becomes larger and more is asked from the hardware running the application, scaling up becomes a necessity. Monolithic applications being complex beasts, scaling vertically is the only choice. One can only get so far by renting a higher-tier server.
Monoliths are fragile. A small bug anywhere in the system, even some barely used non-critical part of it, might cause the complete application to fall over and introduce downtime. This can potentially affect many, many users, and deploying a patch or update can be hard..
And then there's deploying database updates. Monolithic applications often store a large portion of their data in a single relational database. When persistent data is to be updated, the service might be unavailable for a significant amount of time. Users might lose their work, or worse, their interest, as a result.
Microservices and miniservices
Microservice architectures are developed to tackle these problems. Classic microservices typically handle only a very small isolated task within the application, following the mantra of 'Do one thing, and do it well'. They're stateless and loosely coupled to one another. There might be a microservice for user authentication, one for sending e-mails and another for rendering thumbnails. Implemented properly, these systems are easy to scale horizontally: just identify bottleneck services and launch more instances accordingly. They are loosely coupled, enabling isolation of bug impact to single domains of the applications and can be updated without introducing downtime.
|Monolith vs. microservices|
Doing one thing, and doing it well is no universal solution, though. Integration and end-to-end testing a system with many microservices is rather difficult, even though testing a single microservice is simple. Deployment of microservices demands specialized knowledge about containerization and orchestration, or, if you're not into that, other complex deployment techniques. For a large project, this may introduce a considerable amount of overhead.
Enter miniservices: a balance between monoliths and microservices. The classic microservice being fine-grained, we think that the domains within which the services operate should not be smaller than necessary. For example, we won't make a distinction between microservices generating HTML and another rendering that HTML into a PDF. Miniservices collect the data, and render the page in one go. However, the PDF rendering miniservice will not be doing any user authentication. This keeps the system as a whole maintainable, flexible as well as scalable.
|Microservices vs. miniservices|
How do you decide whether you should set up a monolith or a miniservice system? We've been looking into that and have come up with a couple of guidelines that can be used to find out whether miniservices are a good fit for your project. Here are some of the good things miniservice architectures may bring to your project:
Whenever an application has a set of dependencies, these always resolve to a tree of all the packages that need to be installed. When any dependency does not keep track of progress in the ecosystem, those dependencies become stale. In the case of strict semantic versioning, this means that the monolith as a whole is stuck on these stale dependencies. This is a problem due to either missing new functionality from new versions of your libraries, or missing security patches. In that case, either the dependency needs to be replaced with some equivalent functionality, the functionality needs to be removed, or the functionality needs to be split out to another application. Miniservices enable splitting up the list of dependencies. This keeps the amount of stale or complex dependencies contained.
Miniservices can be developed independently from each other. As domains are separated clearly, having established API contracts, teams only have to worry about the implementation of the tasks within their domain.
Independence of technology
Monoliths depend on a single core technology, typically a single stack such as PHP+Symfony or Node. However, applications have varying requirements of their technology, depending on the required functionality. PHP is fine to define CRUD operations, but PHP is not sufficient when implementing graphics operations or processing large volumes of data. Following the creed pick the best tool for the job, mini-services enable a disjoint set of technologies to be employed for a project. For small, highly specialized functionalities, programming languages such as C++, Rust or Go can be employed to leverage their performance or correctness characteristics.
At Tweede golf we sometimes quickly prototype software, with the intention to implement a proper alternative later when adequate funding is secured. Often these decisions stem from business- or maintainability considerations. This is nearly impossible to do neatly when the project is implemented as a monolith, but trivial when the mini-service approach is taken.
Certain problems are solved in multiple projects and don't need a tailor-made solution. These problems include rendering PDF files, thumbnailing images and sending e-mails. Currently, each project team solves these problems separately. These solutions can be developed once and deployed individually as a miniservice, but maintained commonly by a distinct team.
The costs of the development of these functionalities can be shared across projects. In order to develop and maintain such a miniservice very specific knowledge might be required. Not every team has the luxury or budget of properly building out that functionality. A prime example of this is maintaining a mailserver, which is error-prone and difficult work, but which can easily be shared between projects.
Development and release cycle duration
Even though a set of miniservices as a whole is harder to deploy than a monolithic application, a large benefit comes from being able to individually deploy parts of your project. It is no longer required to always build and deploy the entire project. This can enable saving time in your development and release cycle. Also, the release cycle becomes simpler and therefore easier to maintain.
Tracing the operations of a monolithic application involves intensive logging in all parts of the application. A fatal error might cause an execution log to be incomplete or even absent in some cases. This makes post-mortem inspection and debugging of a running application hard. An application built on a miniservice architecture exposes obvious interfaces in the system that are both accessible and uniform, namely the APIs between the miniservices. Logging communications or communication metadata between miniservices leaves a transparent trace of system operation.
Resource usage, like memory or CPU, is hard to pin down on certain parts in a monolith, while individual mini-services make it straightforward to track. This enables more transparent tracking of excessive resource usage or other resource-related problems.
A monolith's failure often propagates through the complete system. A miniservice architecture makes it more natural to isolate faults within their respective miniservice without unnecessarily impacting other parts of the system. The miniservice architecture forces the developer to implement error handling and fault tolerance when interacting with other parts of the system via an API.
When implementing functionality that is prone to failures, like generating a PDF document, it is hard to handle all possible errors within a monolith PHP application. Especially when some of the possible errors cause the PHP thread to crash. Isolating this service makes it easier to recover from these errors. A fatal error could just cause some parts of the application being unavailable, while others can still keep working.
Like microservices, miniservices aren't perfect. Below are some downsides to consider.
End-to-end testing a miniservice architecture is hard. Running every service in the system locally can become a pain. If not properly set up, developers need to check out the correct code from source control and ensure all dependencies are of the correct version. Tools like docker-compose can be of great help here, though.
In a system consisting of many miniservices, sometimes it's quite difficult to find out the cause of errors in a production setting. It's hard to recreate the state the system was in when it failed. Therefore, extensive logging should be built-in through all parts of the system.
When multiple development teams are working on the same application, they must agree on API contracts beforehand and whenever an API is updated. To streamline these communications, a cross-project lead is required.
Type system and IDE hints
As not all code can reside in a single repository, developers cannot easily take advantage of type systems and IDE hints for inter-service communications. Especially when multiple languages are used, defining entity models might cause some discrepancies.
Maintaining multiple software stacks introduces a higher cost. Not every team will be able to contribute to all parts of the project. There's a need for knowledge about orchestrating many containers in a cloud setting, using tools such as Kubernetes.
Miniservice based applications are great. They're robust, scalable and multiple teams can work on them simultaneously. They are a good fit for certain large projects. But setting up such an application is not easy. Companies need to be mature as communication between teams and individual team members is vital. Setting up a good workflow for working on the projects needs to be thought out well. Also, specialist knowledge is required to get the system running smoothly.
'I like miniservices! But how do I start?'. My next blog post contains an incomplete how-to-migrate-from-monolith-to-miniservices to get you started.