Hi, my name is Ivan Kozikov, I am a full stack Java developer at NIX United. I have Oracle and Kubernetes certifications, and I like to explore new technologies and learn new topics in the area of Java.
Every year JRebel resource conducts a survey among Java developers on which frameworks they use. In 2020, Spring Boot won with 83%. However, in 2021, its share dropped to 62%. One of those that more than doubled its presence in the market was Micronaut. The rapid growth of popularity of this framework raises a logical question: what is interesting about it? I decided to find out what problems Micronaut overcomes and understand if it can become an alternative to Spring Boot.
In this article, I will walk through the history of software architecture, which will help to understand why such frameworks emerged and what problems they solve. I will highlight the main features of Micronaut and compare two applications with identical technologies: one on this framework and the other on Spring Boot.
From Monoliths to Microservices and Beyond…
Modern software development began with a monolithic architecture. In it, the application is served through a single deployable file. If we are talking about Java, this is one JAR file, which hides all the logic and business processes of the application. You then offload that JAR file to wherever you need it.
This architecture has its advantages. First of all, it's very easy to start developing a product. You create one project and fill it with business logic without thinking about communication between different modules. You also need very few resources at the start and it's easier to perform integration testing for the whole application.
However, this architecture also has disadvantages. Applications on the monolithic architecture almost always outgrew the so-called "big layer of mud.” The components of the application became so intertwined that it was then difficult to maintain, and the larger the product, the more resources and effort it would take to change anything in the project.
Therefore, microservice architecture has replaced it. It divides the application into small services and creates separate deployment files depending on the business processes. But don't let the word "micro" mislead you — it refers to the business capabilities of the service, not its size.
Usually, microservices are focused on single processes and their support. This provides several advantages. First, because they are separate independent applications, you can tailor the necessary technology to the specific business process. Second, it is much easier to assemble and deal with the project.
However, there are also disadvantages. You first need to think about the relationship between services and their channels. Also, microservices require more resources to maintain their infrastructure than in the case of a monolith. And when you move to the cloud, this issue is even more critical, because you have to pay for the consumption of cloud infrastructure resources from your applications.
What is the Difference Between Frameworks and Microframeworks?
To speed up software development, frameworks began to be created. Historically, the model for many Java developers was Spring Boot. However, over time, its popularity declined, and this can be explained. Over the years, Spring Boot has gained quite a lot of "weight," which prevents it from working quickly and using fewer resources, as required by modern software development in the cloud environment. That is why microframeworks began to replace it.
Microframeworks are a fairly new kind of framework that aim to maximize the speed of web service development. Usually, they have most of the functionality cut — as opposed to full stack solutions like Spring Boot. For example, very often they lack authentication and authorization, abstractions for database access, web templates for mapping to UI components, etc. Micronaut started out the same way but has outgrown that stage. Today it has everything that makes it a full stack framework.
Main Advantages of Micronaut
The authors of this framework were inspired by Spring Boot but emphasized the minimal use of reflection and proxy classes, which speeds up its work. Micronaut is multilingual and supports Java, Groovy, and Kotlin.
Among the main advantages of Micronaut, I highlight the following:
Abstractions for accessing all popular databases. Micronaut has out-of-the-box solutions for working with databases. They also provide an API to create your own classes and methods to access databases. In addition, they support both variations: normal blocking access and reactive access.
Aspect-oriented API. In Spring Boot, you can develop software quickly thanks to annotations. But these instructions are built on reflection and creation of proxy classes at program execution. Micronaut provides a set of ready-to-use instructions.You can use its tools to write your own annotations that use reflection only at compile-time, not at runtime. This speeds up the launch of the application and improves its performance.
Natively built-in work with cloud environments. We will talk about this in detail further and I will reveal the important points separately.
Built-in set of testing tools. These allow you to quickly bring up the clients and servers you need for integration testing. You can also use the familiar JUnit and Mockito libraries.
What Does Full-time Compilation Give Us?
I already pointed out that Micronaut does not use reflection and proxy classes — this is possible through ahead-of-time compilation. Before executing an application at the time of package creation, Micronaut tries to comprehensively resolve all dependency injections and compile classes so that it does not have to while the application itself is running.
Today there are two main approaches to compilation: just in time (JOT) and ahead of time (AOT). JIT compilation has several main advantages. The first is the great speed of building an artifact, the JAR file. It doesn't need to compile additional classes — it just does this at runtime. It's also easier to load classes at runtime; with AOT-compilation this has to be done manually.
In AOT compilation, however, startup time is shorter, because everything the application needs to run will be compiled before it is even started. With this approach, the artifact size will be smaller because there are no proxy classes to work through which compilations are then run. On the plus side, fewer resources are required with this compilation.
It is important to emphasize that, out of the box, Micronaut has built-in support for GraalVM. This is a topic for a separate article, so I will not go deep into it here. Let me say one thing: GraalVM is a virtual machine for different programming languages. It allows the creation of executable image files, which can be run within containers. There the start and run speeds of the application are at maximum.
However, when I tried to use this in Micronaut, even guided by the comments of the framework's creator, when creating the native image I had to designate the key classes of the application as they will be precompiled at runtime. Therefore, this issue should be carefully researched compared to the advertised promises.
How Micronaut Works with Cloud Technology
Separately, native support for cloud technologies should be disclosed. I will highlight four main points:
Micronaut fundamentally supports cordoning. When we work with cloud environments, especially when there are multiple vendors, we need to create components specifically for the infrastructure in which we will use the application. To do this, Micronaut allows us to create conditional components that depend on certain conditions. This provides a set of configurations for different environments and tries to maximize the definition of the environment on which it runs. This greatly simplifies the work of the developer.
Micronaut has nested tools to determine the services needed to run the application. Even if it does not know a service’s real address, it will still try to find it. Therefore, there are several options: you can use built-in or add-on modules (e.g. Consul, Eureka, or Zookeeper).
Micronaut has the ability to make a client-side load balancer. It is possible to regulate the load of the application replicas on the client-side, which makes life easier for the developer.
Micronaut supports serverless architecture. I have repeatedly encountered developers saying, "I will never write lambda-functions in Java." In Micronaut we have two possibilities to write lambda-functions. The first is to use the API, which is directly given by the infrastructure. The second is to define controllers, as in a normal REST API, and to then use them within that infrastructure. Micronaut supports AWS, Azure, and Google Cloud Platform.
Some may argue that all this is also available in Spring Boot. But connecting cloud support there is only possible thanks to additional libraries or foreign modules, while in Micronaut, everything is built in natively.
Let's Compare Micronaut and Spring Boot Applications
Let's get to the fun part! I have two applications — one written in Spring Boot, the other in Micronaut. This is a so-called user service, which has a set of CRUD operations to work with users. We have a PostgreSQL database connected through a reactive driver, a Kafka message broker, and WEB Sockets. We also have an HTTP client for communicating with third-party services to get more information about our users.
Why such an application? Often in presentations about Micronaut, metrics are passed in the form of Hello World applications, where no libraries are connected and there is nothing in the real world. I want to show how it works in an example similar to practical use.
I want to point out how easy it is to switch from Spring Boot to Micronaut. Our project is pretty standard: we have a third-party client for HTTP, a REST controller for handling deals, services, a repository, etc. If we go into the controller, we can see that everything is easy to understand after Spring Boot. The annotations are very similar. It shouldn't be hard to learn it all. Even most instructions, like PathVariable, are one-to-one to Spring Boot.
@Controller("api/v1/users")
public class UserController {
@Inject
private UserService userService;
@Post
public Mono<MutableHttpResponse<UserDto>> insertUser(@Body Mono<UserDto> userDtoMono) {
return userService.createUser(userDtoMono)
.map(HttpResponse::ok)
.doOnError(error -> HttpResponse.badRequest(error.getMessage()));
}
The same goes for Service. If we were to write a Service annotation in Spring Boot, here we have a Singleton annotation that defines the scope to which it applies. There's also a similar mechanism for injecting dependencies. They, like in Spring Boot, can be used via constructors or made via property or method parameters. In my example, business logic is written to make our class work:
@Controller("api/v1/users")
public class UserController {
@Inject
private UserService userService;
@Post
public Mono<MutableHttpResponse<UserDto>> insertUser(@Body Mono<UserDto> userDtoMono) {
return userService.createUser(userDtoMono)
.map(HttpResponse::ok)
.doOnError(error -> HttpResponse.badRequest(error.getMessage()));
}
@Get
public Flux<UserDto> getUsers() {
return userService.getAllUsers();
}
@Get("{userId}")
public Mono<MutableHttpResponse<UserDto>> findById(@PathVariable long userId) {
return userService.findById(userId)
.map(HttpResponse::ok)
.defaultIfEmpty(HttpResponse.notFound());
}
@Put
public Mono<MutableHttpResponse<UserDto>> updateUser(@Body Mono<UserDto> userDto) {
return userService.updateUser(userDto)
.map(HttpResponse::ok)
.switchIfEmpty(Mono.just(HttpResponse.notFound()));
}
@Delete("{userId}")
public Mono<MutableHttpResponse<Long>> deleteUser(@PathVariable Long userId) {
return userService.deleteUser(userId)
.map(HttpResponse::ok)
.onErrorReturn(HttpResponse.notFound());
}
@Get("{name}/hello")
public Mono<String> sayHello(@PathVariable String name) {
return userService.sayHello(name);
}
The repository also has a familiar look after Spring Boot. The only thing is I use a reactive approach in both applications.
@Inject
private UserRepository userRepository;
@Inject
private UserProxyClient userProxyClient;
I personally really liked the HTTP client for communicating with other services. You can write it declaratively just by defining the interface and specifying what types of methods it will be, what Query values will be passed, what parts of the URL it will be, and what body it will be. It's all quick, plus you can make your own client. Again, this can be done using third-party libraries within Spring Boot with reflection and proxy classes.
@R2dbcRepository(dialect = Dialect.POSTGRES)
public interface UserRepository extends ReactiveStreamsCrudRepository<User, Long> {
Mono<User> findByEmail(String email);
@Override
@Executable
Mono<User> save(@Valid @NotNull User entity);
}
@Client("${placeholder.baseUrl}/${placeholder.usersFragment}")
public interface UserProxyClient {
@Get
Flux<ExternalUserDto> getUserDetailsByEmail(@NotNull @QueryValue("email") String email);
@Get("/{userId}")
Mono<ExternalUserDto> getUserDetailsById(@PathVariable String userId);
}
Now let's go directly to work in the terminal. I have two windows open. On the left side on the yellow background is Spring Boot, and on the right side on the gray background is Micronaut. I did a build of both packages — In Spring Boot it took almost 5 seconds, while Micronaut took longer because of AOT compilation; in our case, the process took almost twice as long.
Next, I compared the size of the artifact. The JAR file for Spring Boot is 40 MB, and for Micronaut 38 MB. Not much less, but still less.
After that, I ran an application startup speed test. In Spring Boot Netty the server started on port 8081 and lasted 4.74 seconds. But in Micronaut we have 1.5 seconds. In my opinion, quite a significant advantage.
The next step is a very interesting test. I have a Node.js script whose path passes to the JAR file as an argument. It runs the application and every half-second it tries to get the data from the URL I wrote to it — that is, our users. This script terminates when it gets the first response. In Spring Boot it finished in 6.1 seconds, and in Micronaut it finished in 2.9 seconds — again, twice as fast. At the same time, the metrics show that Spring Boot started in 4.5 seconds and the result came in 1.5 seconds. For Micronaut, these figures are about 1.5 and 1.3 seconds, respectively. That is, the gain is obtained exactly due to the faster start of the application, and practically, Spring Boot could correspond as fast if it did not do additional compilation at the start.
Next test: let's start the applications (start takes 4.4 seconds and 1.3 seconds, in favor of Micronaut) and see how much memory both frameworks use. I use jcmd — I pass the identifier to the process and get heap_info. The metrics show that in total the Spring Boot application requested 149 MB to run and actually used 63 MB. We repeat the same for Micronaut, with the same command, but changing the process ID. The result: the application asked for 55 MB and used 26 MB. That is, the difference in resources is 2.5 – 3 times.
I will end with another metric to show that Micronaut is not a silver bullet and has room to grow. With ApacheBench, I simulated 500 requests to the Spring server for Spring Boot with concurrency for 24 requests. That is, we're simulating a situation where 24 users are simultaneously making requests to the application. With a reactive database, Spring Boot shows a pretty good result: it can pass about 500 requests per second. After all, JIT compilation works well on system peaks. Let's copy the procedure to Micronaut and repeat it a few times. The result is about 106 requests per second. I checked the figures on different systems and machines, and they were about the same, give or take.
The Conclusion is Simple
Micronaut is not an ideal that can immediately replace Spring Boot. It still has some points that are more convenient or functional in the first framework. However, in some areas the more popular product is inferior to less popular, but a quite advanced competitor. That said, Spring Boot also has a ways to go. For example, the same AOT compilation has optionally existed in Java since version 9 in 2017.
I’d like to add one more thought: developers should not be afraid to try new technologies. They can provide us with great opportunities and allow us to go beyond the standard frameworks we usually work with.
Top comments (1)
Micronaut will stream the content of ‘Flux’ that brings a bit of overhead, if you don’t want it just use ‘collectList’ and return ‘Mono’.