Comparing Java frameworks for cloud-native environments
In the not-too-distant past, two burgeoning alternatives to Spring Boot have emerged in the Java world, vying for a larger share of the pie. These alternatives are Quarkus and Micronaut, which, unlike Spring Boot, were written from the start with native images and cloud-native systems in mind. For these reasons, Spring Native was developed. Most articles on the web compare Quakus and Micronaut with Spring Boot, but not in terms of native images.
This article is an introduction to a series on these frameworks. There will be no shortage of benchmarks, regarding performance under different microservices workloads, in both imperative and reactive approaches. We will cover frameworks, charts, or even tutorials on migrating existing applications from Spring.
The driving force behind the creators' work
Spring has been used by many people and it is known for its reliability and ease of use, nevertheless, it still has some issues. One of these issues is that it uses a lot of runtime reflection, which can make the app take longer to start and use more memory.
As a result, Quarkus and Micronaut were developed to fill the void in cloud-native applications from the Java programming language perspective. To make these frameworks lightweight for microservices, it is essential to avoid runtime reflection. It was achieved by combining GraalVM, AOT code generation, and the use of native images, and the application startup time and their memory footprint were reduced.
Quarkus and Micronaut were both inspired by Spring Boot because there is no need to reinvent the wheel. Many of the commonly used annotations have equivalent counterparts in both Quarkus and Micronaut, though they may not be identical. This means that the learning curve for transitioning from Spring Framework to these frameworks is not steep.
As I mentioned earlier, Quarkus and Micronaut have modules that provide support for certain mechanisms and annotations from Spring, making it easier to migrate from Spring to these frameworks. The principle of these modules is to replace Spring annotations with those offered by the framework. However, this convenience comes at the cost of increased maintenance, which may impact the overall cost of maintaining the application in the long run.
Quarkus developers have gone a step further and created an e-book called "Quarkus for Spring Developers" to introduce Quarkus to software developers and provide them with tips on a quick and easy transition from Spring.
What do Quarkus and Micronaut have in common?
These frameworks are highly modular, allowing us to select only the specific modules required for our projects. They also follow the "convention over configuration" approach, meaning that Quarkus and Micronaut can be easily integrated into a project simply by adding them as a dependency and requiring minimal configuration to work seamlessly.
One of the key features is Inversion of Control (IOC) containers, which may vary slightly in implementation but share common traits. Proper use of Dependency Injection (DI) and IOC allows our code to be more easily tested, more adaptable to changes, and loosely coupled.
To address the limitations of native images when it comes to using reflection, both Micronaut and Quarkus use Ahead-of-Time (AOT) compilation to replace many runtime generation processes. This has helped reduce both the memory footprint and the startup time of the application. This feature is particularly useful in serverless environments, where microservices instances can be dynamically scaled based on load balancing needs. In my experience, integration tests take significantly less time through native images, which affects development and deployment time.
Decorating code with annotations is a common way to use mechanisms in those frameworks. These decorators often rely on the use of proxies, which are objects that act as intermediaries between the original object and the client. These proxies are generated ahead of time (AOT) rather than being created at runtime using runtime proxies or CGLIB. One well-known example of an aspect-oriented programming (AOP) decorator is the @Transactional annotation, which is used to mark a method or class as participating in a transaction.
There are several options for configuring these applications. We can use configuration files, config servers, or environment variables to make it simpler to use them as resources in Kubernetes or other deployment methods.
If we already know what Quarkus and Micronaut frameworks have in common then we can focus on the differences in each implementation. At first glance, these differences we may not notice or are insignificant because they are behind a decent layer of abstraction.
Overview of available common modules and implementations
Spring Boot with native images
Spring was first released in 2002 and is still being updated by Pivotal (which is now owned by VMware). In March of 2021, Spring added beta access to a new module called Spring Native, which allows Spring to create native images and use ahead-of-time (AOT) compilation. The generally available version of Spring Boot 3.0.0 released in November 2022 with GraalVM Native Image support supersedes the experimental Spring Native project. Although not all extensions to Spring Boot and Spring Cloud are fully supported yet.
Spring Boot 3.0.0 provides support for servlets on Tomcat, Jetty, or Undertow. When using the Webflux module with a reactive approach, Jetty is supported for the Web component, while Tomcat, Jetty 9, Undertow, and Reactor Netty all support Websockets.
Jackson is a default used library for serializing and deserializing JSON and XML data. Spring also supports Gson as a JSON serializer. However, if you want to use the HATEOAS (Hypermedia as the Engine of Application State) in your application, you will need to use Jackson, as Gson is not supported.
As an alternative to REST API communication, it is possible to use GraphQL, through the Spring GraphQL module.
Not all data modules are fully supported in native images, but we still have a couple of options to choose from, including Spring Data JDBC, Spring Data R2DBC, and Spring Data JPA. In the case of JPA, we are forced to use the Hibernate build-time bytecode enhancement plugin. By default, Spring uses HikariCP as a JDBC connection pool. It is important to remember that these are only abstractions over the actual connection to the database. Presently, Spring only supports database connectors that have metadata stored in the GraalVM Reachability Metadata Repository, or metadata provided by the user. The currently supported databases include Postgres, MySQL, and H2.
We have the following NoSQL databases to choose from: ElasticSearch, Neo4j, Redis, and MongoDB. Looking at the documentation of the modules NoSQL not listed here, I found a couple of notes saying that some functions may not work because they use CGlib proxy. Therefore, you need to check them on your own.
This wiki page provides information limitations for Spring Cloud modules. Nevertheless, we will discuss other modules below, as not everything is included here because not all modules are developed by the Spring Cloud team.
When it comes to integrating with cloud providers and cloud-native architecture, there are modules available for both GCP and Azure, providing options for users to choose from. We can check the list of supported features in the native compilation on the wiki. AWS has an ongoing milestone for Spring 3.0.0, the status for native support can be checked here.
There is also an extra integration with AWS lambda, Azure functions, and GCP, which can be used with Spring Cloud Function. Spring Cloud Kubernetes is unfortunately not yet supported.
The available options for tracing and metrics are limited to Micrometer. Spring Cloud Sleuth is supported in Spring 2.X with Spring Native, but not supported in Spring Boot 3.0.0, as it got moved to Micrometer Tracing.
Additionally, we can use the Eureka Client for service discovery, but Eureka Server is not supported.
Spring, despite being a mature tool, has in my opinion the biggest disadvantage among its competitors. Some of its modules were not written or designed with native compilation in mind. For this reason, some modules require special configuration in native images, have some limitations, or are not yet supported. Moreover, I experienced a couple of problems with compiling native images, part of which was related to the M1 chip from the Macbook. The process of compiling a simple application using buildpacks could take more than 10 minutes or sometimes take forever.
Micronaut was developed in May 2018 by Graeme Rocher, who is known for the Grails framework. For Micronaut, he created a foundation with Object Computing that brings together developers from well-known companies like JetBrains, Microsoft, and Amazon to develop Micronaut further. The framework itself is written very modularly, the developers claim that you can use their IOC container as a separate library without using other Micronaut features. It supports Java, Kotlin, and Groovy.
For the webserver, Micronaut does not use servlets by default, instead, it relies on the Netty Framework for both blocking and non-blocking IO. Netty is an asynchronous event-driven framework, which is distinguished by the fact that it can handle multiple clients on a single thread. This is, in contrast to classic servlets which create a per-client thread, blocked until the end of the request. There is also the possibility to use servlets through Micronaut Servlet, which supports Tomcat, Jetty, and Undertow.
Taking a closer look at the HTTP server, we can learn that Micronaut also implements the RFC-6570 specification, which is responsible for the URI template, and RFC-491 for custom HTTP Methods, the same as Spring.
Jackson is also used by Micronaut as the standard JSON and XML serializer; it has been set up to use the Bean Introspection API, which does away with reflection. Additionally, there is a Micronaut Serialization module that makes use of Bean Introspection API. This module also supports Jackson, JSON-B, and BSON annotations. The feature that I believe is the coolest, is the one that checks the annotations at compilation time. If, for instance, you enter the wrong date format in @JsonFormat, an error will be thrown during compilation.
The developers have also taken care of security issues, only classes annotated as @Serdeable can be serialized or deserialized. By doing so, we will avoid serializing objects that we would not want such as entities. There is also an option to import classes that we can't annotate ourselves, by using @SerdeImport annotation usually on the Application class.
As an alternative to REST API communication, it is possible to use GraphQL, through the Micronaut GraphQL module.
Micronaut Data, a module similar to Spring Data, supports a variety of JDBC drivers including H2, Postgres, Oracle, MariaDB, MySQL, and MS SQLServer. To use any of these drivers, simply add them as a dependency and Micronaut Data will automatically configure them. One main difference between Micronaut Data and Spring Data is that Micronaut Data eliminates the use of reflection and runtime proxies, and also checks queries for type safety during compilation. Micronaut uses Apache DBCP2, Oracle UCP, HikariCP, and Tomcat for connection pooling. There is also an integration for Jooq that allows you to write type-safe queries in Java.
Micronaut Hibernate Reactive and Micronaut R2DBC enable a reactive style of communication with the database using supported drivers such as H2, PostgreSQL, MySQL, MariaDB, and SQL Server.
We also have integrations for popular NoSQL databases such as MongoDB, Cassandra, DynamoDB, Redis, Neo4j, Elastic Search.
Micronaut has a wider range of integrations with Cloud services than Spring with native images. It supports all popular cloud providers and their services, such as AWS, GCP, Azure, and Oracle Cloud. There is also an abstraction over data buckets called Micronaut Object Storage, which supports the previously mentioned clouds.
Kubernetes has its integration, but only in the areas of service discovery, clients for config maps and secrets, and overhead over the official client from the Kubernetes Java SDK.
Micronaut supports Ribbon and Hystrix. They are Netflix libraries created for cloud purposes. The Ribbon is a client-side load balancer and Hystrix provides fault and latency tolerance, through various mechanisms like a circuit breaker. However, I don't recommend using Hystrix in new solutions, as it is in maintenance-only mode as of 2018. A sensible alternative could be Resilience4j, which has its integration with Micronaut, but this integration is not being developed by the Micronaut team.
The Micronaut Tracing module is also another abstraction, under which support for Jaeger, and Zipkin, both implement the Open Tracing API. In addition, the project has support for OpenTelemetry.
The last cloud abstraction is Micronaut Service discovery, which supports Eureka, Consul, and Spring Cloud Config Server. Thanks to the integration with the Spring module, we don't have to abandon this solution from the beginning, just create new services using Micronaut and postpone the migration of legacy services over time.
Micronaut has a wide range of integrations that work in native images, in many ways resembles Spring in a positive sense. They have set as their mission the elimination of reflection to zero and are not afraid to create their solutions if they think they can do something better. The result of their efforts is a much greater type safety relative to the competition.
RedHat, a company known for its Linux offerings and tools like Ansible and Openshift, as well as its OpenJDK build for Java, developed Quarkus in March 2019. It is the latest of the three tools created by RedHat. Quarkus developers advertise it as a native Kubernetes framework optimized for low memory consumption and fast startup times. Supported languages include Java, Kotlin, and Scala. For Scala, you need a separate module and additional configuration.
GraalVM has a special distribution for Quarkus called Mandrel, which was created for better maintainability. The Mandrel is based on top of the standard OpenJDK and Red Hat Enterprise Linux libraries, instead of Labs OpenJDK. It is fully focused on native image development and throws out unnecessary things from Quarkus’ point of view in GraalVM, such as Truffle Framework and Polyglot.
Quarkus offers support for both reactive and imperative programming styles, which are implemented using Vert.x, which is maintained by the Eclipse Foundation. Additionally, Quarkus includes a modified version of Undertow that runs on top of Vert.x and allows for the use of Servlets.
Quarkus uses RESTEasy, a certified implementation of the JAX-RS specification developed by RedHat, for its HTTP layer. RESTEasy also implements the MicroProfile REST Client API which allows developers to create type safe clients for their APIs. By using the appropriate annotations from JAX-RS on an interface, developers can create an instance of the REST Client, which allows them to call the controller as if they were making direct calls. This makes it easy to create and manage RESTful web services in a Quarkus application.
Quarkus does not have its JSON serialization mechanism and instead relies on Jackson and JSON-B. For XML serialization you need to include the quarkus-resteasy-jaxb and quarkus-resteasy modules in your project.
Quarkus, as an alternative to the classic REST API, allows us to use GraphQL via SmallRye GraphQL.
Quarkus supports a wide range of JDBC drivers, including DB2, Derby, H2, MariaDB, Microsoft SQL Server, MySQL, Oracle, and PostgreSQL. It uses Agroal for connection pooling and offers three options for interacting with the database: classic JDBC, JPA with Hibernate Framework, and Panache. Panache provides a Repository Pattern or Active Record which is quite an unpopular approach in Java Ecosystem. Reactive Hibernate with optional Panache supports SQL databases such as Db2, PostgreSQL, MariaDB/MySQL, Microsoft SQL Server, and Oracle.
Several NoSQL databases are supported, including MongoDB, Cassandra, Elastic Search, DynamoDB, Neo4j, and Redis. These databases have dedicated modules within Quarkus. In addition, MongoDB also supports Panache.
Quarkus has a dedicated extension that helps with generating resources for Kubernetes, OpenShift, and Knative, configuring resources, and using the Kubernetes API. There’s also another extension for OpenShift that is a wrapper over Kubernetes and container-image-s2i. Overall, this integration between OpenShift and Quarkus provides a powerful set of tools for managing and deploying applications in a Kubernetes environment.
Popular cloud providers such as AWS, Azure, and GCP have their integrations to facilitate the use of their services, one of them is Funqy. Funqy provides a common interface for creating functions that can be deployed on various serverless platforms, including AWS Lambda, Azure Functions, Google Cloud Functions, Knative, and Knative Events (Cloud Events).
The Micrometer is a tool that helps you track and measure the performance of your application. It collects runtime metrics and makes them available through an OpenMetrics-compliant endpoint, which can be used by Prometheus to monitor and analyze the metrics.
SmallRye Strock is the only standalone service discovery solution available in Quarkus, but if you are using a cloud platform such as Kubernetes, you can also use the service discovery solutions provided by the cloud platform. Identical to Micronaut, there is support for Spring Cloud Config Server, which will allow you to replace the Spring ecosystem in small steps.
In terms of telemetry and tracing, we have a choice between OpenTelemetry and OpenTracing, but OpenTracing is currently in a deprecated state and the developers recommend against using it.
Quarkus is a versatile framework that offers good integration with other tools developed by RedHat. If you are already familiar with RedHat tools, or plan to use them in your project, Quarkus might be a good choice for you. It is worth noting that Mandrel, a subproject of Quarkus, may offer additional features or improvements in the future that could make it stand out compared to other frameworks that use the plain GraalVM.
All of the frameworks support Maven and Gradle as standard build tools, but some of them have additional plugins to build cloud-native applications. A common functionality among these frameworks is the ability to build native images using Buildpacks. To create native images using Buildpacks, you will need to use Docker, but you will not need to manually install any native tools.
Micronaut and Quarkus have plugins that provide integration with native tools, but they require manual installation of these tools. Spring Native does not include built-in support for native tools, but there are samples available that show how to use them. It is worth noting that native tools do not support cross-compilation. This means that you will need to install native tools for each platform that you want to target.
I am impressed by Quarkus because it offers support for two additional methods of creating containers. Jib and S2i, which can be used to create containers on an OpenShift cluster.
Each framework provides different ways to create a new project and has additional tools and features that can make working with the project easier.
The starter website
All of the frameworks offer a simple website that allows you to generate a zip file containing a project template. When using these websites, you can choose the project name, the framework and Java versions, the programming language, the build tool, and any additional modules that enhance the functionality of the framework. None of the frameworks stand out in this regard as they all offer similar functionality.
Only the Ultimate edition of JetBrains' popular IDE supports the creation of projects directly within the IDE without the need to install additional plugins. This edition also includes additional features that can help speed up project navigation and development.
Quarkus is the only framework with an official plugin for the Community Edition (CE) version of IntelliJ, called “Quarkus Tools”. This plugin, developed by RedHat, adds the ability to easily create new Quarkus projects and provides code assistance. The IntelliJ plugin manager also includes plugins for Spring Boot and Quarkus created by the community, but I could not find any plugins for Micronaut.
In addition to providing tools for creating new projects, the frameworks also offer various commands that can be used for testing and deploying applications. These commands go beyond just project creation and provide additional functionality for managing and working with your projects.
One thing that is common to all frameworks is the use of GraalVM, they also share the limitations of compiling native images. We lose the biggest advantage resulting from the JVM (Java Virtual Machine) - write once, run anywhere. However, this disadvantage doesn't make much sense in the context of serverless environments, when images are mostly containerized, e.g. in a Docker. You need to be aware of the others because they already have a greater influence on how we write our native applications.
- The native image will contain only the code, which will be achieved through static code analysis from the application entry point. The rest of the code will be removed unless you indicate through hint files what elements of the application must be preserved. There is a GraalVM Hint Processor for this, allowing for the automatic generation of such files through annotations. These limitations are mainly related to mechanisms such as reflection, dynamic proxy, serialization, and dynamic class loading. Therefore, before using a library, check whether the functionality you need will work with native images.
- Classpath is fixed, established during build time, and cannot be changed
- JVMTI, Java Agents, JMX, JFR, and other bytecode-based tools are not supported with Native Image
- Some deprecated methods, such as Thread.stop() from Java.lang. Thread, are not supported
These are just a few of the limitations of using native images, I've highlighted the ones I believe are most significant. If you want to read a more detailed description, look directly at the developer’s website. It's important to consider that the limitations are changing along with the development of GraalVM, and it's a good idea to stay up to date.
I've found the Spring and Micronaut documentation to be the most comprehensive for understanding the framework and identifying differences from there. Quarkus, in my opinion, has significant shortcomings. The approach that suits me best is a mix of thorough documentation that presents itself as a whole and has book-like organization and order. It gives me the feeling that if I read everything in the order intended by the developers, I won't miss anything crucial. The tutorials with examples serve only as a supplement to the documentation, not as its basis.
Micronaut gets kudos for the whole section dedicated to the private API, which explains what's going on under the hood. Additionally, the mechanism’s descriptions are sufficiently thorough. For this reason, I have not been forced yet to look into the project's sources for a deeper understanding of how the framework's mechanisms work. They also have their series of webinars, available on YouTube.
The tutorials serve as the foundation for the Quarkus documentation. Navigating through them is quite annoying, and there is no clearly defined learning path. There is no description in the documentation of how the framework works internally, I'm not the only one who noticed this, as a dedicated issue was created on GitHub to solve this problem. Unfortunately, it has been hanging open for 2 years, and there is no sign that the developers are interested in developing the documentation in this manner.
As an example of understatement in Quarkus, I can give a vague description of the bean scope, which is a component of the core module. Quarkus has a bean scope, represented by the @Singleton annotation. It is similar to the @Scope(“singleton”) known from Spring, with the important difference being that it does not use client-proxy. Therefore, a question popped up in my head about whether I can use decorators or interceptors with @Singleton scope. I didn't find the answer in the documentation, so I was forced to test it myself with the help of a debugger. It turns out that when using annotations like @Transactional, a proxy is used, but a subclass proxy is not mentioned anywhere except in the source code of the AOT generators.
In my opinion, it is difficult to unequivocally choose any framework as the best. Like everything in our industry, it is relative, because every project has different requirements and priorities. For one, a specific implementation of a particular functionality may be crucial, for someone else, the most important thing will be trust in the maintainer that the framework will be developed and supported for a very long time. Regardless, before you start choosing a framework, write down your requirements and decide which choice satisfies you based on that.
That's it for the introduction to the series, if you feel something has been left out, or you want an elaboration on a particular issue, let us know in the comments.