UX/UI Design

Micronaut vs SpringBoot: which one is better?

Written by: Maciej Wajcht, Senior Java Developer

Grow your business

Receive an e-mail with business tips once a month and elevate your business

Some time ago, I attended the Devoxx Conference in Kraków, the biggest Java conference in Poland. I searched for inspiration and wanted to hear about the newest trends in the industry. Among many great lectures, one topic left me with a feeling that I should give it more attention – the Micronaut framework and GraalVM native image.

Conference specification

Conference speeches are time limited so they usually don’t give you a ready solution which you can just grab and use in your projects. They show you only some aspects, highlighting the best features. A closer look and a real-life application is necessary if you want to actually use it. The most popular framework for creating Java applications is currently Spring with Spring Boot. It is very powerful and easy to start using it. Many features work out of the box and you can start a local development with just adding a single dependency to the application.

However, Spring heavily depends on reflection and proxy objects. For this reason, Spring Boot applications need some time to start. On the other hand, Micronaut is designed to use AOT (Ahead Of Time) compilation and avoid reflection. It should make it easier to use GraalVM and native image. We may ask a question if startup time is really important for Java applications. The answer is not very obvious. If we have an application running all the time, the startup time is not crucial.

The situation changes on Serverless environments, where applications are started to handle the incoming request and closed afterwards. Our application may prepare a response within milliseconds but if it needs a couple of seconds to start, it may become a serious problem if we are billed for each minute of running our application.

Applications used for testing

To make a comparison, I decided to write two simple CRUD applications (CRUD stands for Create, Retrieve, Update, Delete and represents the set of most common actions on various entities). 

One application was created using Spring Boot 2.7.1, the second one using Micronaut Framework 3.5.3. Both used Java 17 and Gradle as a build tool. I wanted to compare the following aspects:

  • application startup time;
  • how easy is to write the application;
  • how easy is to test the application;
  • how easy is to prepare the native image.

To look more like a real application, I defined three entities: Order, Buyer, and Product.

Example: 3 entities of model application

Each entity is persisted in a database, has its own business logic and is exposed through REST API. As a result each application has 9 beans (3 REST controllers, 3 domain services and 3 database repositories).

Example entity structure

Startup time and memory consumption

* all measurements done on MacBook Air M1

We can see that even without GraalVM, Micronaut gets better startup times and less memory consumption. Combining the applications with GraalVM gets a huge performance boost. In both scenarios Micronaut gives us better numbers.

Micronaut from the Spring developer perspective

I work with Spring on a daily basis. When I thought about writing a new application, the Spring Boot was my first choice. When I heard about Micronaut for the first time I was afraid that I would need to learn everything from scratch and writing applications wouldn’t be as easy as using Spring. Now, I can confidently say that the use of Micronaut is just as easy as writing a Spring Boot application.

Production code

Here we have mainly similarities:

  • Both have a nice starting page (https://start.spring.io/ and https://micronaut.io/launch/) where you can choose the basic configuration (Java version, built tool, testing framework) , add some dependencies and generate a project. Both are well integrated with IntelliJ IDEA.
Spring perspective
Micronaut perspective
  • Database entities and domain classes are just identical
  • DB repositories also looks identical – the only difference is in the imports
package eu.espeo.springdemo.db;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

import org.springframework.data.repository.CrudRepository;
import
org.springframework.stereotype.Repository;


@Repository
public interface ProductRepository extends CrudRepository<Product,Integer> {
  @Override
  List<Product> findAll();

  Optional<Product> findByBusinessId(UUID businessId);

  void deleteByBusinessId(UUID businessId);
}
package eu.espeo.micronautdemo.db;

import java.util.List;
import java.util.Optional;
import java.util.UUID;


import io.micronaut.data.annotation.Repository;
import io.micronaut.data.repository.CrudRepository
;

@Repository
public interface ProductRepository extends CrudRepository<Product,Integer> {
  @Override
  List<Product> findAll();

  Optional<Product> findByBusinessId(UUID businessId);

  void deleteByBusinessId(UUID businessId);
}
  • REST controllers are very similar
package eu.espeo.springdemo.rest;

import static java.util.stream.Collectors.toList;

import java.util.List;
import java.util.UUID;

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;


import eu.espeo.springdemo.domain.ProductService;
import lombok.RequiredArgsConstructor;

@RestController
@RequestMapping(value = “/products”, produces = MediaType.APPLICATION_JSON_VALUE)

@RequiredArgsConstructor
public class ProductController {

  private final ProductService productService;

  @GetMapping
  public List<ProductDto> listProducts() {
    return productService.findAll().stream()
          .map(ProductDto::fromProduct)
          .collect(toList());
  }

  @GetMapping(value = “/{productId}”)
  public ProductDto getProduct(@PathVariable(“productId”) String productId) {
    return ProductDto.fromProduct(productService.findByBusinessId(UUID.fromString(productId)));
  }

  (…}
}

package eu.espeo.micronautdemo.rest;

import static java.util.stream.Collectors.toList;

import java.util.List;
import java.util.UUID;

import eu.espeo.micronautdemo.domain.ProductService;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Body;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Delete;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.PathVariable;
import io.micronaut.http.annotation.Post;

import lombok.RequiredArgsConstructor;

@Controller(value = “/products”, consumes = MediaType.APPLICATION_JSON, produces = MediaType.APPLICATION_JSON)
@RequiredArgsConstructor
public class ProductController {

  private final ProductService productService;

  @Get
  public List<ProductDto> listProducts() {
    return productService.findAll().stream()
        .map(ProductDto::fromProduct)
        .collect(toList());
  }

 @Get(value = “/{productId}”)
  public ProductDto getProduct(@PathVariable(“productId”) String productId) {
    return ProductDto.fromProduct(productService.findByBusinessId(UUID.fromString(productId)));
  }

  (…)
}










,

Basically that’s it. There are no other differences in production code. 

The advantage of Micronaut

However, there is one big advantage of Micronaut. It checks many things at compile time instead of at runtime. Let’s see the following repository method: findByName. We used the wrong return type because we return String instead of Product. How will both frameworks behave?

package eu.espeo.micronautdemo.db;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

import io.micronaut.data.annotation.Repository;
import io.micronaut.data.repository.CrudRepository;

@Repository
public interface ProductRepository extends CrudRepository<Product,Integer> {
(…)
String findByName(String name);
}

Micronaut will fail during project compilation with the following failure:

error: Unable to implement Repository method: ProductRepository.findByName(String name). Query results in a type [eu.espeo.micronautdemo.db.Product] whilst method returns an incompatible type: java.lang.String

String findByName(String name);

Very clear. And how about Spring? It will compile successfully and everything will be fine until we try to call this method:

java.lang.ClassCastException: class eu.espeo.springdemo.db.Product cannot be cast to class java.lang.String (eu.espeo.springdemo.db.Product is in unnamed module of loader ‘app’; java.lang.String is in module java.base of loader ‘bootstrap’)

Let’s hope everyone writes tests. Otherwise such an error will be thrown on production.

Spring Boot vs Micronaut 0:1

Test code for the REST controller

For Spring we can write:

  • An integration @SpringBootTest with TestRestTemplate injected. This test starts the Web server. We can easily send some requests to our application.
  • An integration @SpringBootTest using MockMvc. This test doesn’t start the Web server so it is faster, but sending requests and parsing responses are not as easy as when using TestRestTemplate.
  • For more specific scenarios you can mock some classes using @MockBean annotation.

For Micronaut you can write:

  • An integration @MicronautTest with HttpClient injected. This test starts the Netty server. As easy to use as SpringBoot test with TestRestTemplate.
  • For more specific scenarios you can mock some classes using @MockBean annotation.
package eu.espeo.springdemo.rest;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;

import java.math.BigDecimal;
import java.util.UUID;

import static org.assertj.core.api.BDDAssertions.then;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ProductControllerWebIntegrationTest {

  @LocalServerPort
  private int port;

  @Autowired
  private TestRestTemplate restTemplate;


  @Test
  void shouldSaveAndRetrieveProduct() {
    // given
    var productBusinessId = UUID.randomUUID();
    var productName = “Apple MacBook”;
    var price = BigDecimal.valueOf(11499.90);

    // when
    var createResponse = restTemplate
        .postForEntity(“http://localhost:” + port + “/products”,
            new ProductDto(productBusinessId, productName, price), ProductDto.class)
;
    then(createResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
    var product = restTemplate.getForObject(
        “http://localhost:” + port + “/products/” + productBusinessId, ProductDto.class);
;

    // then
    then(product).isNotNull();
    then(product.businessId()).isEqualTo(productBusinessId);
    then(product.name()).isEqualTo(productName);
    then(product.price()).isEqualByComparingTo(price);
  }
}


package eu.espeo.micronautdemo.rest;

import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.client.HttpClient;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;

import java.math.BigDecimal;
import java.util.UUID;

import static org.assertj.core.api.BDDAssertions.then;

@MicronautTest
class ProductControllerWebIntegrationTest {

  @Inject
  @Client(“/”)
  private HttpClient client;


  @Test
  void shouldSaveAndRetrieveProduct() {
    // given
    var productBusinessId = UUID.randomUUID();
    var productName = “Apple MacBook”;
    var price = BigDecimal.valueOf(11499.90);

    // when
    var createResponse = client.toBlocking()
        .exchange(HttpRequest.POST(“/products”, new ProductDto(productBusinessId, productName, price)))
;
    then((CharSequence) createResponse.getStatus()).isEqualTo(HttpStatus.OK);
    var product = client.toBlocking()
        .retrieve(HttpRequest.GET(“/products/” + productBusinessId), ProductDto.class)
;

    // then
    then(product).isNotNull();
    then(product.businessId()).isEqualTo(productBusinessId);
    then(product.name()).isEqualTo(productName);
    then(product.price()).isEqualByComparingTo(price);
  }
}








,

Generally, there is no big difference. Good thing for Micronaut is that it provides the default configuration of a database using TestContainers, so we can just start writing tests and everything should just work. On the other hand, Spring provides no test configuration so we need to remember to configure a database. This gives us a slight advantage over Spring Boot.

Spring Boot vs Micronaut 0:2

Native image (GraalVM)

Both frameworks support compilation to a native image. We can produce either a native application or a docker image containing a native application. Micronaut already uses AOT compilation so creation of native image should be easier. To be able to do it, I needed only 3 steps:

  • install GraalVM (if not already installed) and native-image tool
    • sdk install java 22.1.0.r17-grl
    • gu install native-image
  • Add Gradle dependency
    • compileOnly(“org.graalvm.nativeimage:svm”)
  • Annotate DTO classes used by REST API with @Introspected to generate BeanIntrospection metadata at compilation time. This information can be used, for example, to render the POJO as JSON using Jackson without using reflection.
package eu.espeo.micronautdemo.rest;

import java.math.BigDecimal;
import java.util.UUID;

import eu.espeo.micronautdemo.domain.Product;
import io.micronaut.core.annotation.Introspected;

@Introspected

public record ProductDto(
UUID businessId,
String name,
BigDecimal price
) {
(…some methods mapping DTO to domain model…)
}

That’s it. Now you can just execute ./gradlew nativeCompile (to build just an application) or ./gradlew dockerBuildNative (to build a Docker image – but currently it does not work on Macs with M1 chip), wait a couple of minutes (native compilation takes longer than standard build) and here you go.

I was really curious if Spring Boot applications will be as easy to convert as Micronaut applications. There is a spring-native project which has currently a beta support (it means that breaking changes may happen). The changes I needed to make was slightly different but still not complicated:

  • install GraalVM (if not already installed) and native-image tool
  • add a Spring AOT Gradle plugin
    • id ‘org.springframework.experimental.aot’ version ‘0.12.1’
  • add a spring-native dependency (but when using Gradle you can skip it because Spring AOT plugin will add this dependency automatically)
  • configure the build to use the release repository of spring-native (both for dependencies and plugins)
    • maven { url ‘https://repo.spring.io/release’ }

Now you can just execute ./gradlew nativeCompile (to build just an application) or ./gradlew bootBuildImage (to build a Docker image – but for some reason the process is stuck on my Mac with M1 chip), wait a couple of minutes (native compilation takes longer than standard build) and here you go.

As we can see, both frameworks compile well to the native image and both have some problems with generating Docker images on M1 Macs 🙂 None of them is perfect yet.

Spring Boot vs Micronaut 1:3

Summary

It’s great to have a choice. My test confirmed that Micronaut can be a good alternative to Spring Boot. It has some advantages but Spring is still very strong. I will keep an eye on both.